Add the recruiter Pitch route and rebuild Home for direct-customer work.

Pitch takes the verified profile, metrics and public cases off Home so `/` can speak to small companies in plain language. The Systems Map now sits on the services overview, and both locales stay table-driven.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-26 12:52:12 +02:00
parent b15dfd7801
commit 86d6dd085d
35 changed files with 738 additions and 188 deletions

View File

@@ -6,6 +6,8 @@ Fullstack and DevOps engineer in Tangermünde. The public site documents a curat
- https://antoniolede.de/ - https://antoniolede.de/
- https://antoniolede.de/en - https://antoniolede.de/en
- https://antoniolede.de/pitch — Recruiter-Profil / recruiter profile
- https://antoniolede.de/en/pitch — Recruiter profile / Recruiter-Profil
- https://antoniolede.de/ueber-mich - https://antoniolede.de/ueber-mich
- https://antoniolede.de/en/about - https://antoniolede.de/en/about

View File

@@ -6,6 +6,12 @@
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en" /> <xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/" /> <xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/" />
</url> </url>
<url>
<loc>https://antoniolede.de/pitch</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/pitch" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/pitch" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/pitch" />
</url>
<url> <url>
<loc>https://antoniolede.de/leistungen</loc> <loc>https://antoniolede.de/leistungen</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen" /> <xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen" />
@@ -78,6 +84,12 @@
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en" /> <xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/" /> <xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/" />
</url> </url>
<url>
<loc>https://antoniolede.de/en/pitch</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/pitch" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/pitch" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/pitch" />
</url>
<url> <url>
<loc>https://antoniolede.de/en/services</loc> <loc>https://antoniolede.de/en/services</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen" /> <xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen" />

View File

@@ -9,7 +9,10 @@ describe('server routes', () => {
expect(catchAll && 'status' in catchAll ? catchAll.status : undefined).toBe(404); expect(catchAll && 'status' in catchAll ? catchAll.status : undefined).toBe(404);
const prerendered = serverRoutes.filter((route) => route.path !== '**'); const prerendered = serverRoutes.filter((route) => route.path !== '**');
expect(prerendered.length).toBeGreaterThan(0); expect(prerendered).toHaveLength(26);
expect(prerendered.map((route) => route.path)).toEqual(
expect.arrayContaining(['pitch', 'en/pitch']),
);
for (const route of prerendered) { for (const route of prerendered) {
expect(route.renderMode, route.path).toBe(RenderMode.Prerender); expect(route.renderMode, route.path).toBe(RenderMode.Prerender);

View File

@@ -64,6 +64,17 @@ describe('app routes', () => {
); );
}); });
it('registers pitch as a lazy route in both locales', () => {
const flattened = flattenRoutes(routes);
const german = flattened.find((entry) => entry.path === 'pitch');
const english = flattened.find((entry) => entry.path === 'en/pitch');
const pitchRoute = routes.find((route) => route.path === 'pitch');
expect(german?.data).toEqual({ routeId: 'pitch', locale: 'de' });
expect(english?.data).toEqual({ routeId: 'pitch', locale: 'en' });
expect(pitchRoute?.loadComponent).toBeTypeOf('function');
});
it('attaches the final page title for every locale route', () => { it('attaches the final page title for every locale route', () => {
const flattened = flattenRoutes(routes); const flattened = flattenRoutes(routes);

View File

@@ -11,6 +11,7 @@ type LazyPage = () => Promise<Type<unknown>>;
const PAGE_LOADERS: Record<RouteId, LazyPage> = { const PAGE_LOADERS: Record<RouteId, LazyPage> = {
home: () => import('./features/home/home').then((module) => module.HomePage), home: () => import('./features/home/home').then((module) => module.HomePage),
pitch: () => import('./features/pitch/pitch').then((module) => module.PitchPage),
services: () => import('./features/services/services').then((module) => module.ServicesPage), services: () => import('./features/services/services').then((module) => module.ServicesPage),
servicesSoftware: () => servicesSoftware: () =>
import('./features/services/software/software').then((module) => module.ServicesSoftwarePage), import('./features/services/software/software').then((module) => module.ServicesSoftwarePage),

View File

@@ -11,8 +11,10 @@ import { SITE_CONTENT_DATA } from './site-content';
const DELIVERED_OFFER_WORDING = const DELIVERED_OFFER_WORDING =
/für kunden umgesetzt|im kundeneinsatz|in production for clients|delivered for clients|langjährige erfahrung mit rag|years of rag/i; /für kunden umgesetzt|im kundeneinsatz|in production for clients|delivered for clients|langjährige erfahrung mit rag|years of rag/i;
const UNLIMITED_SCOPE = /\balles\b|\banything\b|any problem|jedes Problem|end-to-end für alles/i;
function allMetrics(site: SiteContent): readonly MetricCopy[] { function allMetrics(site: SiteContent): readonly MetricCopy[] {
return [...site.home.metrics, ...CASE_STUDY_IDS.flatMap((id) => site.cases[id].metrics)]; return [...site.pitch.metrics, ...CASE_STUDY_IDS.flatMap((id) => site.cases[id].metrics)];
} }
function allOfferings(site: SiteContent): readonly OfferingCopy[] { function allOfferings(site: SiteContent): readonly OfferingCopy[] {
@@ -41,7 +43,7 @@ describe('content claims and attribution', () => {
/8/.test(metric.value) && /90/.test(metric.value); /8/.test(metric.value) && /90/.test(metric.value);
const runtimeMetrics = allMetrics(site).filter(matchesRuntime); const runtimeMetrics = allMetrics(site).filter(matchesRuntime);
expect(runtimeMetrics.every((metric) => metric.caseId === 'innofocus')).toBe(true); expect(runtimeMetrics.every((metric) => metric.caseId === 'innofocus')).toBe(true);
expect(site.home.metrics.filter(matchesRuntime)).toHaveLength(1); expect(site.pitch.metrics.filter(matchesRuntime)).toHaveLength(1);
expect(site.cases.innofocus.metrics.filter(matchesRuntime)).toHaveLength(1); expect(site.cases.innofocus.metrics.filter(matchesRuntime)).toHaveLength(1);
for (const caseId of CASE_STUDY_IDS.filter((id) => id !== 'innofocus')) { for (const caseId of CASE_STUDY_IDS.filter((id) => id !== 'innofocus')) {
expect(site.cases[caseId].metrics.filter(matchesRuntime)).toHaveLength(0); expect(site.cases[caseId].metrics.filter(matchesRuntime)).toHaveLength(0);
@@ -83,4 +85,27 @@ describe('content claims and attribution', () => {
).toBe(false); ).toBe(false);
} }
}); });
it('keeps Home free of unlimited-scope promises', () => {
for (const locale of APP_LOCALES) {
const home = SITE_CONTENT_DATA[locale].home;
const text = [
home.title,
home.description,
home.hero.headline,
home.hero.proof ?? '',
home.hero.playfulLine ?? '',
...(home.hero.body ?? []),
...home.sections.flatMap((section) => [section.headline, ...(section.body ?? [])]),
...home.serviceAreas.flatMap((area) => [area.title, area.body]),
...home.process.flatMap((step) => [step.title, step.body]),
home.proofNote,
...home.ctas.map((cta) => cta.label),
].join('\n');
expect(UNLIMITED_SCOPE.test(text), `${locale} home contains unlimited-scope wording`).toBe(
false,
);
}
});
}); });

View File

@@ -39,6 +39,8 @@ describe('content completeness', () => {
const site = SITE_CONTENT_DATA[locale]; const site = SITE_CONTENT_DATA[locale];
expect(site.pages.home).toBe(site.home); expect(site.pages.home).toBe(site.home);
expect(site.pages.pitch).toBe(site.pitch);
expect(site.pages.services).toBe(site.servicesOverview);
expect(site.pages.contact).toBe(site.contact); expect(site.pages.contact).toBe(site.contact);
expect(site.pages.projects).toBe(site.projects); expect(site.pages.projects).toBe(site.projects);
expect(site.pages.stack).toBe(site.stack); expect(site.pages.stack).toBe(site.stack);

View File

@@ -52,7 +52,16 @@ describe('content exclusions', () => {
}); });
const harness = await RouterTestingHarness.create(); const harness = await RouterTestingHarness.create();
const paths = ['/', '/en', '/projekte', '/en/projects', '/ueber-mich', '/en/about']; const paths = [
'/',
'/en',
'/pitch',
'/en/pitch',
'/projekte',
'/en/projects',
'/ueber-mich',
'/en/about',
];
for (const path of paths) { for (const path of paths) {
await harness.navigateByUrl(path); await harness.navigateByUrl(path);

View File

@@ -101,23 +101,43 @@ export interface ServicePageCopy extends PageCopy {
readonly offeringCaseBackedLabel: string; readonly offeringCaseBackedLabel: string;
} }
export interface AudienceEntryCopy { export interface HomeServiceAreaCopy {
readonly id: 'recruiters' | 'companies'; readonly id: 'workplaces' | 'servers' | 'software' | 'ai';
readonly headline: string; readonly title: string;
readonly body: string; readonly body: string;
readonly bullets: readonly string[]; readonly routeId: RouteId;
readonly ctas: readonly CtaCopy[];
} }
export interface HomePageCopy extends PageCopy { export interface HomePageCopy extends PageCopy {
readonly serviceAreas: readonly HomeServiceAreaCopy[];
readonly serviceAreasHeading: string;
readonly process: readonly ProcessStepCopy[];
readonly processHeading: string;
readonly proofCaseId: CaseStudyId;
readonly proofHeading: string;
readonly proofNote: string;
readonly featuredCaseIds: readonly CaseStudyId[];
}
export interface PitchTimelineEntryCopy {
readonly id: string;
readonly period: string;
readonly role: string;
readonly body: string;
}
export interface PitchPageCopy extends PageCopy {
readonly profile: readonly string[]; readonly profile: readonly string[];
readonly metrics: readonly MetricCopy[]; readonly metrics: readonly MetricCopy[];
readonly audiences: readonly AudienceEntryCopy[]; readonly timeline: readonly PitchTimelineEntryCopy[];
readonly coreStack: readonly StackGroupCopy[];
readonly featuredCaseIds: readonly CaseStudyId[]; readonly featuredCaseIds: readonly CaseStudyId[];
readonly systemsMap: { readonly timelineHeading: string;
readonly heading: string; readonly stackHeading: string;
readonly intro: string; }
};
export interface ServicesOverviewPageCopy extends PageCopy {
readonly systemsMap: { readonly heading: string; readonly intro: string };
} }
export type ContactFieldId = export type ContactFieldId =
@@ -198,6 +218,8 @@ export interface SiteSeoCopy {
export interface SiteContent { export interface SiteContent {
readonly pages: Record<RouteId, PageCopy>; readonly pages: Record<RouteId, PageCopy>;
readonly home: HomePageCopy; readonly home: HomePageCopy;
readonly pitch: PitchPageCopy;
readonly servicesOverview: ServicesOverviewPageCopy;
readonly services: Record<ServicePageId, ServicePageCopy>; readonly services: Record<ServicePageId, ServicePageCopy>;
readonly cases: Record<CaseStudyId, CaseStudyCopy>; readonly cases: Record<CaseStudyId, CaseStudyCopy>;
readonly contact: ContactPageCopy; readonly contact: ContactPageCopy;

View File

@@ -9,9 +9,11 @@ import {
type HomePageCopy, type HomePageCopy,
type LegalPageCopy, type LegalPageCopy,
type PageCopy, type PageCopy,
type PitchPageCopy,
type ProjectsPageCopy, type ProjectsPageCopy,
type ServicePageCopy, type ServicePageCopy,
type ServicePageId, type ServicePageId,
type ServicesOverviewPageCopy,
type StackPageCopy, type StackPageCopy,
} from './content.contracts'; } from './content.contracts';
import { SITE_CONTENT } from './content.token'; import { SITE_CONTENT } from './content.token';
@@ -29,6 +31,14 @@ export class ContentService {
return computed(() => this.content[this.localeService.locale()].home); return computed(() => this.content[this.localeService.locale()].home);
} }
pitch(): Signal<PitchPageCopy> {
return computed(() => this.content[this.localeService.locale()].pitch);
}
servicesOverview(): Signal<ServicesOverviewPageCopy> {
return computed(() => this.content[this.localeService.locale()].servicesOverview);
}
service(id: ServicePageId): Signal<ServicePageCopy> { service(id: ServicePageId): Signal<ServicePageCopy> {
return computed(() => this.content[this.localeService.locale()].services[id]); return computed(() => this.content[this.localeService.locale()].services[id]);
} }

View File

@@ -1,103 +1,93 @@
import { SITE_CONFIG } from '../site-config';
import { type HomePageCopy } from '../content.contracts'; import { type HomePageCopy } from '../content.contracts';
export const HOME_DE: HomePageCopy = { export const HOME_DE: HomePageCopy = {
routeId: 'home', routeId: 'home',
title: 'Startseite | Antonio Ledebuhr', title: 'Startseite | Antonio Ledebuhr',
description: description:
'Fullstack- und DevOps-Ingenieur in Tangermünde: rund sieben Jahre Webanwendungen, direkter Kundenkontakt und Teamverantwortung, freelance seit 04/2023.', 'Du beschreibst, was Dein Unternehmen erreichen soll. Ein technischer Ansprechpartner übernimmt den Weg von der Diagnose bis zum Betrieb oder zur Übergabe.',
hero: { hero: {
headline: 'Fullstack- und DevOps-Ingenieur in Tangermünde', headline:
'Du beschreibst, was Dein Unternehmen erreichen soll. Ich kümmere mich um den technischen Weg.',
proof: proof:
'Rund sieben Jahre berufliche Erfahrung mit dem Bau und Betrieb von Webanwendungen. Freelance seit 04/2023.', 'Bei der Rösterei Tangermünde reicht die öffentliche Arbeit vom Netz und den Arbeitsplätzen bis zum Shop und zwei internen KI-Werkzeugen.',
playfulLine: 'IT mit Drehmoment', playfulLine: 'IT mit Drehmoment',
body: [
'Du schilderst das Ziel, den Engpass oder den wiederkehrenden Ablauf in Deinen Worten. Ein Ansprechpartner führt von der Diagnose über die Umsetzung bis zum Betrieb oder zur Übergabe.',
],
}, },
profile: [ serviceAreasHeading: 'Wobei ich helfen kann',
'Rund sieben Jahre berufliche Erfahrung mit dem Bau und Betrieb von Webanwendungen.', serviceAreas: [
'Fullstack-Entwicklung, DevOps, direkter Kundenkontakt vom ersten Anforderungsworkshop bis in den Produktivbetrieb sowie Personal- und Teamverantwortung.',
'Freelance seit 04/2023, mit Sitz in Tangermünde.',
'Heimspiel Java mit Spring; dazu C# und .NET, Angular, SQL, Docker und Kubernetes, Azure einschließlich AKS sowie GitLab CI/CD und Azure DevOps.',
],
metrics: [
{ {
id: 'experience-years', id: 'workplaces',
value: 'ca. 7 Jahre', title: 'Arbeitsplätze und Netz',
label: 'berufliche Erfahrung mit Webanwendungen', body: 'Geräte, Netz und die Dienste, mit denen Dein Team täglich arbeitet — so geschnitten, dass sich der Alltag tragen lässt.',
routeId: 'servicesHardwareNetwork',
}, },
{ {
id: 'innofocus-migration-runtime', id: 'servers',
value: '8 h → ca. 90 min', title: 'Server und Cluster',
label: 'Laufzeit der Datenmigration bei Innofocus / reifen.com', body: 'Einzelne Hosts, Virtualisierung oder ein Cluster. Der Weg dorthin bleibt nachvollziehbar, on-premise oder auf Azure AKS.',
note: 'bei größerem Funktionsumfang und höherer Datenqualität', routeId: 'servicesClusters',
caseId: 'innofocus',
}, },
{ {
id: 'myspa-team-lead', id: 'software',
value: '4 + 2', title: 'Software und Daten',
label: 'Leitung des Backend-Teams und des DevOps-Teams bis zum Start von MySpa', body: 'Produktsoftware und Datenarbeit: von der Fachlichkeit über die API bis zur Oberfläche, inklusive SQL.',
caseId: 'myspa', routeId: 'servicesSoftware',
},
{
id: 'ai',
title: 'KI-Abläufe',
body: 'Zwei umgesetzte Werkzeuge bei der Rösterei Tangermünde. Weitere Bausteine — lokale Modelle, RAG, Mensch in der Schleife — werden auftragsbezogen geprüft.',
routeId: 'servicesAi',
}, },
], ],
audiences: [ processHeading: 'So läuft die Zusammenarbeit',
process: [
{ {
id: 'recruiters', id: 'describe',
headline: 'Für Recruiterinnen und Recruiter', title: 'Lage in Deinen Worten',
body: 'Ein kurzer Überblick über Erfahrung, ausgewählte Fälle, den technischen Stack und den Lebenslauf.', body: 'Du beschreibst das Ziel, den Schmerz oder den wiederkehrenden Ablauf. Fachsprache ist nicht nötig.',
bullets: [
'Rund sieben Jahre Fullstack- und DevOps-Arbeit mit direktem Kundenkontakt und Teamverantwortung.',
'Vier öffentlich dargestellte Fälle aus eCommerce, interner IT, IoT und Versicherung.',
'Heimspiel Java und Spring, dazu C#/.NET, Angular, SQL sowie Docker und Kubernetes.',
'Lebenslauf als PDF zum direkten Download.',
],
ctas: [
{ label: 'Ausgewählte Projekte', routeId: 'projects' },
{ label: 'Technik-Stack ansehen', routeId: 'stack' },
{
label: 'Lebenslauf als PDF',
href: SITE_CONFIG.cvAssetPath,
},
],
}, },
{ {
id: 'companies', id: 'diagnose',
headline: 'Für Unternehmen', title: 'Technische Diagnose',
body: 'Vier Leistungsbereiche, vom ersten Workshop bis zum Betrieb — und ein kurzes Briefing für die Anfrage.', body: 'Ich ordne die Lage den vier Bereichen zu, nenne die Grenze und sage, was außerhalb liegt.',
bullets: [ },
'Software: Fullstack-Produkte mit Java, .NET, Angular und Datenarbeit in SQL.', {
'Hardware und Netzwerk: Server, Geräte, Microsoft 365 und wartbare Infrastruktur.', id: 'implement',
'Cluster: Docker und Kubernetes on-premise und auf Azure AKS, inklusive Pipelines.', title: 'Umsetzung',
'KI-Integration: zwei umgesetzte Werkzeuge bei der Rösterei Tangermünde, weitere Bausteine als Angebot.', body: 'Ein Ansprechpartner setzt um: Software, Infrastruktur, Cluster oder ein schmales KI-Werkzeug, je nach Briefing.',
], },
ctas: [ {
{ label: 'Leistungen ansehen', routeId: 'services' }, id: 'operate',
{ label: 'Projekt anfragen', routeId: 'contact' }, title: 'Betrieb oder Übergabe',
], body: 'Der Stand bleibt bedienbar. Übergabe heißt nachvollziehbare Strukturen, kein undokumentierter Zwischenstand.',
}, },
], ],
featuredCaseIds: ['innofocus', 'roesterei', 'myspa', 'hdi'], proofCaseId: 'roesterei',
systemsMap: { proofHeading: 'Ein belegter Weg vom Netz bis zum Shop',
heading: 'Wie die Schichten zusammenhängen', proofNote: 'Antonio Ledebuhr hält eine wirtschaftliche Beteiligung an der Rösterei Tangermünde.',
intro: 'Hardware, Cluster, Software und KI-Anbindung — und wo die öffentlichen Fälle ansetzen.', featuredCaseIds: ['innofocus', 'myspa', 'hdi'],
},
sections: [ sections: [
{ {
id: 'profile', id: 'scope',
headline: 'In dreißig Sekunden', headline: 'Was diese Seite abdeckt',
body: [ body: [
'Antonio Ledebuhr arbeitet als Fullstack- und DevOps-Ingenieur aus Tangermünde. Diese Seite zeigt eine kuratierte Auswahl von Stationen; der vollständige Lebenslauf steht als PDF bereit.', 'Vier Bereiche: Arbeitsplätze und Netz, Server und Cluster, Software und Daten sowie KI-Abläufe. Was nicht dazu gehört, wird im Briefing benannt — nicht stillschweigend mitverkauft.',
], ],
}, },
{ {
id: 'featured-cases', id: 'proof',
headline: 'Ausgewählte Fälle', headline: 'Warum die Rösterei als Beleg steht',
body: [ body: [
'Die vier öffentlichen Fälle decken eCommerce und ERP, Laden-IT, IoT und Versicherung ab. Jeder Fall ist auf der Projektseite vollständig beschrieben.', 'Die Rösterei Tangermünde ist der öffentliche Fall, der die Kette vom Netz bis zum Shop zeigt. Die übrigen drei Fälle stützen einzelne Bereiche.',
], ],
}, },
], ],
ctas: [ ctas: [
{ label: 'Leistungen', routeId: 'services' }, { label: 'Projektbriefing starten', routeId: 'contact' },
{ label: 'Projekte', routeId: 'projects' }, { label: 'Leistungen ansehen', routeId: 'services' },
{ label: 'Kontakt', routeId: 'contact' }, { label: 'Fall Rösterei Tangermünde', routeId: 'projects', fragment: 'roesterei' },
], ],
}; };

View File

@@ -35,6 +35,7 @@ export const PROJECTS_DE: ProjectsPageCopy = {
], ],
ctas: [ ctas: [
{ label: 'Lebenslauf als PDF', href: SITE_CONFIG.cvAssetPath }, { label: 'Lebenslauf als PDF', href: SITE_CONFIG.cvAssetPath },
{ label: 'Technik-Stack ansehen', routeId: 'stack' },
{ label: 'Kontakt', routeId: 'contact' }, { label: 'Kontakt', routeId: 'contact' },
], ],
caseLabels: { caseLabels: {
@@ -134,6 +135,7 @@ export const ABOUT_DE: PageCopy = {
], ],
ctas: [ ctas: [
{ label: 'Projekte', routeId: 'projects' }, { label: 'Projekte', routeId: 'projects' },
{ label: 'Für Recruiterinnen und Recruiter', routeId: 'pitch' },
{ label: 'Kontakt', routeId: 'contact' }, { label: 'Kontakt', routeId: 'contact' },
], ],
}; };
@@ -173,8 +175,10 @@ export const CONTACT_DE: ContactPageCopy = {
id: 'projectType', id: 'projectType',
control: 'select', control: 'select',
label: 'Art des Vorhabens', label: 'Art des Vorhabens',
required: true, hint: 'freiwillig — wenn noch unklar, einfach offen lassen oder „Noch unklar“ wählen',
required: false,
options: [ options: [
{ value: 'unsure', label: 'Noch unklar' },
{ value: 'software', label: 'Software' }, { value: 'software', label: 'Software' },
{ value: 'hardware-network', label: 'Hardware und Netzwerk' }, { value: 'hardware-network', label: 'Hardware und Netzwerk' },
{ value: 'clusters', label: 'Cluster' }, { value: 'clusters', label: 'Cluster' },

View File

@@ -0,0 +1,133 @@
import { SITE_CONFIG } from '../site-config';
import { type PitchPageCopy } from '../content.contracts';
export const PITCH_DE: PitchPageCopy = {
routeId: 'pitch',
title: 'Profil | Antonio Ledebuhr',
description:
'Fullstack- und DevOps-Ingenieur in Tangermünde: rund sieben Jahre Webanwendungen, direkter Kundenkontakt und Teamverantwortung. Freelance seit 04/2023.',
hero: {
headline: 'Fullstack- und DevOps-Ingenieur in Tangermünde',
proof:
'Rund sieben Jahre berufliche Erfahrung mit dem Bau und Betrieb von Webanwendungen. Freelance seit 04/2023.',
playfulLine: 'IT mit Drehmoment',
},
profile: [
'Rund sieben Jahre berufliche Erfahrung mit dem Bau und Betrieb von Webanwendungen.',
'Fullstack-Entwicklung, DevOps, direkter Kundenkontakt vom ersten Anforderungsworkshop bis in den Produktivbetrieb sowie Personal- und Teamverantwortung.',
'Freelance seit 04/2023, mit Sitz in Tangermünde.',
'Heimspiel Java mit Spring; dazu C# und .NET, Angular, SQL, Docker und Kubernetes, Azure einschließlich AKS sowie GitLab CI/CD und Azure DevOps.',
],
metrics: [
{
id: 'experience-years',
value: 'ca. 7 Jahre',
label: 'berufliche Erfahrung mit Webanwendungen',
},
{
id: 'innofocus-migration-runtime',
value: '8 h → ca. 90 min',
label: 'Laufzeit der Datenmigration bei Innofocus / reifen.com',
note: 'bei größerem Funktionsumfang und höherer Datenqualität',
caseId: 'innofocus',
},
{
id: 'myspa-team-lead',
value: '4 + 2',
label: 'Leitung des Backend-Teams und des DevOps-Teams bis zum Start von MySpa',
caseId: 'myspa',
},
],
timelineHeading: 'Stationen',
timeline: [
{
id: 'self-employed',
period: 'seit 12/2021',
role: 'unternehmerisch selbständig seit 12/2021',
body: 'Eigene Verantwortung für Auftrag, Technik und Betrieb — ohne die früheren Angestelltenverhältnisse auf dieser Seite zu listen.',
},
{
id: 'freelance',
period: 'seit 04/2023',
role: 'freiberuflich seit 04/2023',
body: 'Freiberufliche Fullstack- und DevOps-Arbeit mit direktem Kundenkontakt, vom Anforderungsworkshop bis in den Produktivbetrieb.',
},
{
id: 'hdi',
period: '05/2023 01/2024',
role: 'Senior Fullstack / DevOps bei HDI Specialty',
body: 'Exposure-Management-Software und die Migration der Umgebungen von Azure App Services auf Azure AKS.',
},
{
id: 'roesterei',
period: 'seit 10/2023',
role: 'Gesamtverantwortung für die Technik der Rösterei Tangermünde',
body: 'Netz, Arbeitsplätze, Shop und zwei interne KI-Werkzeuge aus einer Hand. Antonio Ledebuhr hält eine wirtschaftliche Beteiligung an der Rösterei.',
},
{
id: 'myspa',
period: '03/2024 09/2024',
role: 'Lead Backend / DevOps bei Aracom IT Services / MySpa',
body: 'Leitung des Backend-Teams (4) und des DevOps-Teams (2) bis zum Start der Software, mit direktem Kundenkontakt.',
},
{
id: 'innofocus',
period: 'seit 09/2025',
role: 'Senior Backend- und Datenbankingenieur bei Innofocus / reifen.com',
body: 'Alleinverantwortung für die ERP-Datenmigration. Laufzeit von rund acht Stunden auf etwa 90 Minuten.',
},
],
stackHeading: 'Kernstack',
coreStack: [
{
id: 'java',
title: 'Java und Spring',
body: 'Heimspiel: Java mit Spring Boot, Spring Data und Tests.',
},
{ id: 'dotnet', title: 'C# und .NET', body: 'ASP.NET Core, Entity Framework und xUnit.' },
{
id: 'angular',
title: 'Angular und TypeScript',
body: 'Einzelanwendungen, RxJS-zeitige SPA-Arbeit und SCSS.',
},
{ id: 'sql', title: 'SQL', body: 'Microsoft SQL Server, T-SQL, MySQL und MariaDB.' },
{
id: 'containers',
title: 'Docker und Kubernetes',
body: 'Container und Cluster on-premise und in der Cloud.',
},
{
id: 'azure',
title: 'Azure einschließlich AKS',
body: 'Azure AKS, Azure DevOps und verwandte Cloud-Dienste.',
},
{
id: 'cicd',
title: 'GitLab CI/CD und Azure DevOps',
body: 'Pipelines, GitOps mit Argo CD wo es zum Auftrag passt.',
},
],
featuredCaseIds: ['innofocus', 'roesterei', 'myspa', 'hdi'],
sections: [
{
id: 'profile',
headline: 'In dreißig Sekunden',
body: [
'Antonio Ledebuhr arbeitet als Fullstack- und DevOps-Ingenieur aus Tangermünde. Diese Seite zeigt die öffentliche Auswahl von Stationen; der vollständige Lebenslauf steht als PDF bereit.',
],
},
{
id: 'featured-cases',
headline: 'Ausgewählte Fälle',
body: [
'Die vier öffentlichen Fälle decken eCommerce und ERP, Laden-IT, IoT und Versicherung ab. Jeder Fall ist auf der Projektseite vollständig beschrieben.',
],
},
],
ctas: [
{ label: 'Ausgewählte Projekte', routeId: 'projects' },
{ label: 'Technik-Stack ansehen', routeId: 'stack' },
{ label: 'Lebenslauf als PDF', href: SITE_CONFIG.cvAssetPath },
{ label: 'E-Mail schreiben', href: `mailto:${SITE_CONFIG.contactEmail}` },
],
};

View File

@@ -1,6 +1,6 @@
import { type PageCopy, type ServicePageCopy } from '../content.contracts'; import { type ServicePageCopy, type ServicesOverviewPageCopy } from '../content.contracts';
export const SERVICES_OVERVIEW_DE: PageCopy = { export const SERVICES_OVERVIEW_DE: ServicesOverviewPageCopy = {
routeId: 'services', routeId: 'services',
title: 'Leistungen | Antonio Ledebuhr', title: 'Leistungen | Antonio Ledebuhr',
description: description:
@@ -45,6 +45,10 @@ export const SERVICES_OVERVIEW_DE: PageCopy = {
{ label: 'KI-Integration', routeId: 'servicesAi' }, { label: 'KI-Integration', routeId: 'servicesAi' },
{ label: 'Projekt anfragen', routeId: 'contact' }, { label: 'Projekt anfragen', routeId: 'contact' },
], ],
systemsMap: {
heading: 'Wie die Schichten zusammenhängen',
intro: 'Hardware, Cluster, Software und KI-Anbindung — und wo die öffentlichen Fälle ansetzen.',
},
}; };
export const SERVICES_SOFTWARE_DE: ServicePageCopy = { export const SERVICES_SOFTWARE_DE: ServicePageCopy = {

View File

@@ -1,6 +1,7 @@
import { type SiteContent } from '../content.contracts'; import { type SiteContent } from '../content.contracts';
import { CASES_DE } from './cases'; import { CASES_DE } from './cases';
import { HOME_DE } from './home'; import { HOME_DE } from './home';
import { PITCH_DE } from './pitch';
import { import {
ABOUT_DE, ABOUT_DE,
CONTACT_DE, CONTACT_DE,
@@ -20,6 +21,8 @@ import {
export const SITE_CONTENT_DE: SiteContent = { export const SITE_CONTENT_DE: SiteContent = {
home: HOME_DE, home: HOME_DE,
pitch: PITCH_DE,
servicesOverview: SERVICES_OVERVIEW_DE,
services: { services: {
servicesSoftware: SERVICES_SOFTWARE_DE, servicesSoftware: SERVICES_SOFTWARE_DE,
servicesHardwareNetwork: SERVICES_HARDWARE_DE, servicesHardwareNetwork: SERVICES_HARDWARE_DE,
@@ -42,6 +45,7 @@ export const SITE_CONTENT_DE: SiteContent = {
}, },
pages: { pages: {
home: HOME_DE, home: HOME_DE,
pitch: PITCH_DE,
services: SERVICES_OVERVIEW_DE, services: SERVICES_OVERVIEW_DE,
servicesSoftware: SERVICES_SOFTWARE_DE, servicesSoftware: SERVICES_SOFTWARE_DE,
servicesHardwareNetwork: SERVICES_HARDWARE_DE, servicesHardwareNetwork: SERVICES_HARDWARE_DE,

View File

@@ -1,103 +1,93 @@
import { SITE_CONFIG } from '../site-config';
import { type HomePageCopy } from '../content.contracts'; import { type HomePageCopy } from '../content.contracts';
export const HOME_EN: HomePageCopy = { export const HOME_EN: HomePageCopy = {
routeId: 'home', routeId: 'home',
title: 'Home | Antonio Ledebuhr', title: 'Home | Antonio Ledebuhr',
description: description:
'Fullstack and DevOps engineer in Tangermünde: about seven years of web application work, direct customer contact and team responsibility, freelance since 04/2023.', 'You describe what the business needs to achieve. One technical counterpart owns the path from diagnosis through delivery to operations or handover.',
hero: { hero: {
headline: 'Fullstack and DevOps engineer based in Tangermünde', headline:
'You describe what the business needs to achieve. I take the technical path from there.',
proof: proof:
'About seven years of professional experience building and operating web applications. Freelance since 04/2023.', 'At Rösterei Tangermünde the public work runs from the network and workplaces through to the shop and two in-house AI tools.',
playfulLine: 'Full-stack, full throttle', playfulLine: 'Full-stack, full throttle',
body: [
'You put the outcome, the bottleneck or the recurring process in your own words. One counterpart then owns diagnosis, implementation and operations or handover.',
],
}, },
profile: [ serviceAreasHeading: 'Where this work fits',
'About seven years of professional experience building and operating web applications.', serviceAreas: [
'Fullstack development, DevOps, direct customer contact from the first requirements workshop through to production, and personnel and team responsibility.',
'Freelance since 04/2023, based in Tangermünde.',
'Home ground is Java with Spring; also C# and .NET, Angular, SQL, Docker and Kubernetes, Azure including AKS, plus GitLab CI/CD and Azure DevOps.',
],
metrics: [
{ {
id: 'experience-years', id: 'workplaces',
value: 'about 7 years', title: 'Workplaces and network',
label: 'professional experience with web applications', body: 'Devices, the network and the services your team uses every day — cut so that Tuesday still works.',
routeId: 'servicesHardwareNetwork',
}, },
{ {
id: 'innofocus-migration-runtime', id: 'servers',
value: '8 h → about 90 min', title: 'Servers and clusters',
label: 'data-migration run time at Innofocus / reifen.com', body: 'A single host, virtualisation or a cluster. The path there stays readable, on-premise or on Azure AKS.',
note: 'with a larger feature scope and higher data quality', routeId: 'servicesClusters',
caseId: 'innofocus',
}, },
{ {
id: 'myspa-team-lead', id: 'software',
value: '4 + 2', title: 'Software and data',
label: 'led the backend team and the DevOps team through the MySpa launch', body: 'Product software and data work: from the domain through the API to the interface, including SQL.',
caseId: 'myspa', routeId: 'servicesSoftware',
},
{
id: 'ai',
title: 'AI workflows',
body: 'Two delivered tools at Rösterei Tangermünde. Further building blocks — local models, retrieval, a person in the loop — are scoped per engagement.',
routeId: 'servicesAi',
}, },
], ],
audiences: [ processHeading: 'How the work runs',
process: [
{ {
id: 'recruiters', id: 'describe',
headline: 'For recruiters', title: 'The situation in your words',
body: 'A short path through experience, selected cases, the public stack and the curriculum vitae.', body: 'You describe the goal, the pain or the recurring process. Technical language is not required.',
bullets: [
'About seven years of fullstack and DevOps work with direct customer contact and team responsibility.',
'Four public cases across eCommerce, in-house IT, IoT and insurance.',
'Home ground Java and Spring, plus C#/.NET, Angular, SQL, Docker and Kubernetes.',
'Curriculum vitae as a PDF download.',
],
ctas: [
{ label: 'Selected projects', routeId: 'projects' },
{ label: 'View the stack', routeId: 'stack' },
{
label: 'Curriculum vitae as PDF',
href: SITE_CONFIG.cvAssetPath,
},
],
}, },
{ {
id: 'companies', id: 'diagnose',
headline: 'For companies', title: 'Technical diagnosis',
body: 'Four service areas, from the first workshop through to operations — and a short briefing for an enquiry.', body: 'I map the brief onto the four areas, name the boundary and say what sits outside it.',
bullets: [ },
'Software: fullstack product work with Java, .NET, Angular and SQL data work.', {
'Hardware and network: servers, devices, Microsoft 365 and maintainable infrastructure.', id: 'implement',
'Clusters: Docker and Kubernetes on-premise and on Azure AKS, including pipelines.', title: 'Implementation',
'AI integration: two delivered tools at Rösterei Tangermünde, further building blocks as an offer.', body: 'One counterpart delivers: software, infrastructure, a cluster or a narrow AI tool, according to the brief.',
], },
ctas: [ {
{ label: 'Browse services', routeId: 'services' }, id: 'operate',
{ label: 'Start a project enquiry', routeId: 'contact' }, title: 'Operations or handover',
], body: 'The result stays operable. Handover means readable structures, not an undocumented snapshot.',
}, },
], ],
featuredCaseIds: ['innofocus', 'roesterei', 'myspa', 'hdi'], proofCaseId: 'roesterei',
systemsMap: { proofHeading: 'A verified path from the network to the shop',
heading: 'How the layers connect', proofNote: 'Antonio Ledebuhr holds an economic stake in Rösterei Tangermünde.',
intro: 'Hardware, clusters, software and AI integration — and where the public cases attach.', featuredCaseIds: ['innofocus', 'myspa', 'hdi'],
},
sections: [ sections: [
{ {
id: 'profile', id: 'scope',
headline: 'Thirty seconds', headline: 'What this page covers',
body: [ body: [
'Antonio Ledebuhr works as a fullstack and DevOps engineer from Tangermünde. This site shows a curated selection of stations; the full curriculum vitae is available as a PDF.', 'Four areas: workplaces and network, servers and clusters, software and data, and AI workflows. What sits outside that set is named in the briefing — it is not sold by implication.',
], ],
}, },
{ {
id: 'featured-cases', id: 'proof',
headline: 'Selected cases', headline: 'Why the roastery is the proof',
body: [ body: [
'The four public cases cover eCommerce and ERP, shop-floor IT, IoT and insurance. Each case is written in full on the projects page.', 'Rösterei Tangermünde is the public case that shows the chain from the network to the shop. The other three cases support individual areas.',
], ],
}, },
], ],
ctas: [ ctas: [
{ label: 'Services', routeId: 'services' }, { label: 'Start a project briefing', routeId: 'contact' },
{ label: 'Projects', routeId: 'projects' }, { label: 'Browse services', routeId: 'services' },
{ label: 'Contact', routeId: 'contact' }, { label: 'Rösterei Tangermünde case', routeId: 'projects', fragment: 'roesterei' },
], ],
}; };

View File

@@ -35,6 +35,7 @@ export const PROJECTS_EN: ProjectsPageCopy = {
], ],
ctas: [ ctas: [
{ label: 'Curriculum vitae as PDF', href: SITE_CONFIG.cvAssetPath }, { label: 'Curriculum vitae as PDF', href: SITE_CONFIG.cvAssetPath },
{ label: 'View the stack', routeId: 'stack' },
{ label: 'Contact', routeId: 'contact' }, { label: 'Contact', routeId: 'contact' },
], ],
caseLabels: { caseLabels: {
@@ -134,6 +135,7 @@ export const ABOUT_EN: PageCopy = {
], ],
ctas: [ ctas: [
{ label: 'Projects', routeId: 'projects' }, { label: 'Projects', routeId: 'projects' },
{ label: 'For recruiters', routeId: 'pitch' },
{ label: 'Contact', routeId: 'contact' }, { label: 'Contact', routeId: 'contact' },
], ],
}; };
@@ -173,8 +175,10 @@ export const CONTACT_EN: ContactPageCopy = {
id: 'projectType', id: 'projectType',
control: 'select', control: 'select',
label: 'Type of work', label: 'Type of work',
required: true, hint: 'optional — leave blank or pick “Not sure yet” if the category is still open',
required: false,
options: [ options: [
{ value: 'unsure', label: 'Not sure yet' },
{ value: 'software', label: 'Software' }, { value: 'software', label: 'Software' },
{ value: 'hardware-network', label: 'Hardware and network' }, { value: 'hardware-network', label: 'Hardware and network' },
{ value: 'clusters', label: 'Clusters' }, { value: 'clusters', label: 'Clusters' },

View File

@@ -0,0 +1,133 @@
import { SITE_CONFIG } from '../site-config';
import { type PitchPageCopy } from '../content.contracts';
export const PITCH_EN: PitchPageCopy = {
routeId: 'pitch',
title: 'Profile | Antonio Ledebuhr',
description:
'Fullstack and DevOps engineer in Tangermünde: about seven years of web application work, direct customer contact and team responsibility. Freelance since 04/2023.',
hero: {
headline: 'Fullstack and DevOps engineer based in Tangermünde',
proof:
'About seven years of professional experience building and operating web applications. Freelance since 04/2023.',
playfulLine: 'Full-stack, full throttle',
},
profile: [
'About seven years of professional experience building and operating web applications.',
'Fullstack development, DevOps, direct customer contact from the first requirements workshop through to production, and personnel and team responsibility.',
'Freelance since 04/2023, based in Tangermünde.',
'Home ground is Java with Spring; also C# and .NET, Angular, SQL, Docker and Kubernetes, Azure including AKS, plus GitLab CI/CD and Azure DevOps.',
],
metrics: [
{
id: 'experience-years',
value: 'about 7 years',
label: 'professional experience with web applications',
},
{
id: 'innofocus-migration-runtime',
value: '8 h → about 90 min',
label: 'data-migration run time at Innofocus / reifen.com',
note: 'with a larger feature scope and higher data quality',
caseId: 'innofocus',
},
{
id: 'myspa-team-lead',
value: '4 + 2',
label: 'led the backend team and the DevOps team through the MySpa launch',
caseId: 'myspa',
},
],
timelineHeading: 'Stations',
timeline: [
{
id: 'self-employed',
period: 'since 12/2021',
role: 'entrepreneurially self-employed since 12/2021',
body: 'Own responsibility for the brief, the technology and operations — earlier employment stations are not listed on this site.',
},
{
id: 'freelance',
period: 'since 04/2023',
role: 'freelance since 04/2023',
body: 'Freelance fullstack and DevOps work with direct customer contact, from the requirements workshop through to production.',
},
{
id: 'hdi',
period: '05/2023 01/2024',
role: 'Senior fullstack / DevOps at HDI Specialty',
body: 'Exposure-management software and the move of the environments from Azure App Services onto Azure AKS.',
},
{
id: 'roesterei',
period: 'since 10/2023',
role: 'Full technical responsibility at Rösterei Tangermünde',
body: 'Network, workplaces, shop and two in-house AI tools from one counterpart. Antonio Ledebuhr holds an economic stake in the roastery.',
},
{
id: 'myspa',
period: '03/2024 09/2024',
role: 'Lead backend / DevOps at Aracom IT Services / MySpa',
body: 'Led the backend team (4) and the DevOps team (2) through the software launch, with direct customer contact.',
},
{
id: 'innofocus',
period: 'since 09/2025',
role: 'Senior backend and database engineer at Innofocus / reifen.com',
body: 'Sole responsibility for the ERP data migration. Run time from about eight hours down to about 90 minutes.',
},
],
stackHeading: 'Core stack',
coreStack: [
{
id: 'java',
title: 'Java and Spring',
body: 'Home ground: Java with Spring Boot, Spring Data and tests.',
},
{ id: 'dotnet', title: 'C# and .NET', body: 'ASP.NET Core, Entity Framework and xUnit.' },
{
id: 'angular',
title: 'Angular and TypeScript',
body: 'Single-page apps, RxJS-era SPA work and SCSS.',
},
{ id: 'sql', title: 'SQL', body: 'Microsoft SQL Server, T-SQL, MySQL and MariaDB.' },
{
id: 'containers',
title: 'Docker and Kubernetes',
body: 'Containers and clusters on-premise and in the cloud.',
},
{
id: 'azure',
title: 'Azure including AKS',
body: 'Azure AKS, Azure DevOps and related cloud services.',
},
{
id: 'cicd',
title: 'GitLab CI/CD and Azure DevOps',
body: 'Pipelines, and GitOps with Argo CD where the brief fits.',
},
],
featuredCaseIds: ['innofocus', 'roesterei', 'myspa', 'hdi'],
sections: [
{
id: 'profile',
headline: 'Thirty seconds',
body: [
'Antonio Ledebuhr works as a fullstack and DevOps engineer from Tangermünde. This page shows the public selection of stations; the full curriculum vitae is available as a PDF.',
],
},
{
id: 'featured-cases',
headline: 'Selected cases',
body: [
'The four public cases cover eCommerce and ERP, shop-floor IT, IoT and insurance. Each case is written in full on the projects page.',
],
},
],
ctas: [
{ label: 'Selected projects', routeId: 'projects' },
{ label: 'View the stack', routeId: 'stack' },
{ label: 'Curriculum vitae as PDF', href: SITE_CONFIG.cvAssetPath },
{ label: 'Write an email', href: `mailto:${SITE_CONFIG.contactEmail}` },
],
};

View File

@@ -1,6 +1,6 @@
import { type PageCopy, type ServicePageCopy } from '../content.contracts'; import { type ServicePageCopy, type ServicesOverviewPageCopy } from '../content.contracts';
export const SERVICES_OVERVIEW_EN: PageCopy = { export const SERVICES_OVERVIEW_EN: ServicesOverviewPageCopy = {
routeId: 'services', routeId: 'services',
title: 'Services | Antonio Ledebuhr', title: 'Services | Antonio Ledebuhr',
description: description:
@@ -45,6 +45,10 @@ export const SERVICES_OVERVIEW_EN: PageCopy = {
{ label: 'AI integration', routeId: 'servicesAi' }, { label: 'AI integration', routeId: 'servicesAi' },
{ label: 'Start a project enquiry', routeId: 'contact' }, { label: 'Start a project enquiry', routeId: 'contact' },
], ],
systemsMap: {
heading: 'How the layers connect',
intro: 'Hardware, clusters, software and AI integration — and where the public cases attach.',
},
}; };
export const SERVICES_SOFTWARE_EN: ServicePageCopy = { export const SERVICES_SOFTWARE_EN: ServicePageCopy = {

View File

@@ -1,6 +1,7 @@
import { type SiteContent } from '../content.contracts'; import { type SiteContent } from '../content.contracts';
import { CASES_EN } from './cases'; import { CASES_EN } from './cases';
import { HOME_EN } from './home'; import { HOME_EN } from './home';
import { PITCH_EN } from './pitch';
import { import {
ABOUT_EN, ABOUT_EN,
CONTACT_EN, CONTACT_EN,
@@ -20,6 +21,8 @@ import {
export const SITE_CONTENT_EN: SiteContent = { export const SITE_CONTENT_EN: SiteContent = {
home: HOME_EN, home: HOME_EN,
pitch: PITCH_EN,
servicesOverview: SERVICES_OVERVIEW_EN,
services: { services: {
servicesSoftware: SERVICES_SOFTWARE_EN, servicesSoftware: SERVICES_SOFTWARE_EN,
servicesHardwareNetwork: SERVICES_HARDWARE_EN, servicesHardwareNetwork: SERVICES_HARDWARE_EN,
@@ -42,6 +45,7 @@ export const SITE_CONTENT_EN: SiteContent = {
}, },
pages: { pages: {
home: HOME_EN, home: HOME_EN,
pitch: PITCH_EN,
services: SERVICES_OVERVIEW_EN, services: SERVICES_OVERVIEW_EN,
servicesSoftware: SERVICES_SOFTWARE_EN, servicesSoftware: SERVICES_SOFTWARE_EN,
servicesHardwareNetwork: SERVICES_HARDWARE_EN, servicesHardwareNetwork: SERVICES_HARDWARE_EN,

View File

@@ -1,5 +1,6 @@
export type RouteId = export type RouteId =
| 'home' | 'home'
| 'pitch'
| 'services' | 'services'
| 'servicesSoftware' | 'servicesSoftware'
| 'servicesHardwareNetwork' | 'servicesHardwareNetwork'
@@ -15,6 +16,7 @@ export type RouteId =
export const ROUTE_IDS: readonly RouteId[] = [ export const ROUTE_IDS: readonly RouteId[] = [
'home', 'home',
'pitch',
'services', 'services',
'servicesSoftware', 'servicesSoftware',
'servicesHardwareNetwork', 'servicesHardwareNetwork',

View File

@@ -27,6 +27,14 @@ describe('route paths', () => {
expect(routePath('projects', 'en')).toBe('/en/projects'); expect(routePath('projects', 'en')).toBe('/en/projects');
}); });
it('exposes pitch in both locales and keeps prerenderablePaths at 26', () => {
expect(ROUTE_SEGMENTS.de.pitch).toBe('pitch');
expect(ROUTE_SEGMENTS.en.pitch).toBe('pitch');
expect(routePath('pitch', 'de')).toBe('/pitch');
expect(routePath('pitch', 'en')).toBe('/en/pitch');
expect(prerenderablePaths()).toHaveLength(26);
});
it('includes both locales in prerenderable paths and excludes the wildcard', () => { it('includes both locales in prerenderable paths and excludes the wildcard', () => {
const paths = prerenderablePaths(); const paths = prerenderablePaths();

View File

@@ -9,6 +9,7 @@ export const LOCALE_PREFIX: Record<AppLocale, string> = {
export const ROUTE_SEGMENTS: Record<AppLocale, Record<RouteId, string>> = { export const ROUTE_SEGMENTS: Record<AppLocale, Record<RouteId, string>> = {
de: { de: {
home: '', home: '',
pitch: 'pitch',
services: 'leistungen', services: 'leistungen',
servicesSoftware: 'leistungen/software', servicesSoftware: 'leistungen/software',
servicesHardwareNetwork: 'leistungen/hardware-netzwerk', servicesHardwareNetwork: 'leistungen/hardware-netzwerk',
@@ -24,6 +25,7 @@ export const ROUTE_SEGMENTS: Record<AppLocale, Record<RouteId, string>> = {
}, },
en: { en: {
home: '', home: '',
pitch: 'pitch',
services: 'services', services: 'services',
servicesSoftware: 'services/software', servicesSoftware: 'services/software',
servicesHardwareNetwork: 'services/hardware-network', servicesHardwareNetwork: 'services/hardware-network',

View File

@@ -113,6 +113,8 @@ describe('crawl assets', () => {
const required = [ const required = [
absoluteUrl(routePath('home', 'de')), absoluteUrl(routePath('home', 'de')),
absoluteUrl(routePath('home', 'en')), absoluteUrl(routePath('home', 'en')),
absoluteUrl(routePath('pitch', 'de')),
absoluteUrl(routePath('pitch', 'en')),
absoluteUrl(routePath('about', 'de')), absoluteUrl(routePath('about', 'de')),
absoluteUrl(routePath('about', 'en')), absoluteUrl(routePath('about', 'en')),
absoluteUrl(routePath('services', 'de')), absoluteUrl(routePath('services', 'de')),

View File

@@ -58,4 +58,28 @@ describe('SeoService', () => {
); );
expect(document.querySelectorAll('script[type="application/ld+json"]')).toHaveLength(1); expect(document.querySelectorAll('script[type="application/ld+json"]')).toHaveLength(1);
}); });
it('writes pitch metadata, canonical and reciprocal hreflang', () => {
const seo = TestBed.inject(SeoService);
seo.apply('pitch', 'de');
expect(document.querySelector('link[rel="canonical"]')?.getAttribute('href')).toBe(
`${SITE_CONFIG.siteOrigin}${routePath('pitch', 'de')}`,
);
expect(document.querySelector('meta[name="robots"]')?.getAttribute('content')).toBe(
'index, follow',
);
expect(
document.querySelector('link[rel="alternate"][hreflang="de-DE"]')?.getAttribute('href'),
).toBe(`${SITE_CONFIG.siteOrigin}${routePath('pitch', 'de')}`);
expect(
document.querySelector('link[rel="alternate"][hreflang="en"]')?.getAttribute('href'),
).toBe(`${SITE_CONFIG.siteOrigin}${routePath('pitch', 'en')}`);
expect(
document.querySelector('link[rel="alternate"][hreflang="x-default"]')?.getAttribute('href'),
).toBe(`${SITE_CONFIG.siteOrigin}${routePath('pitch', 'de')}`);
expect(document.querySelector('script[type="application/ld+json"]')?.textContent).toContain(
'WebPage',
);
});
}); });

View File

@@ -61,6 +61,7 @@ describe('JSON-LD builders', () => {
const home = buildJsonLdGraph('home', locale, SITE_CONTENT_DATA); const home = buildJsonLdGraph('home', locale, SITE_CONTENT_DATA);
expect(home['@context']).toBe('https://schema.org'); expect(home['@context']).toBe('https://schema.org');
expect(graphTypes('home', locale)).toEqual(['Person', 'ProfessionalService']); expect(graphTypes('home', locale)).toEqual(['Person', 'ProfessionalService']);
expect(graphTypes('pitch', locale), `${locale}.pitch`).toEqual(['WebPage']);
for (const routeId of SERVICE_ROUTES) { for (const routeId of SERVICE_ROUTES) {
expect(graphTypes(routeId, locale), `${locale}.${routeId}`).toEqual(['Service']); expect(graphTypes(routeId, locale), `${locale}.${routeId}`).toEqual(['Service']);

View File

@@ -3,40 +3,39 @@
<app-page-hero [hero]="copy.hero" [ctas]="copy.ctas" /> <app-page-hero [hero]="copy.hero" [ctas]="copy.ctas" />
@for (section of copy.sections; track section.id) { @for (section of copy.sections; track section.id) {
<app-content-section [section]="section" /> <app-content-section [section]="section" />
@if (section.id === 'profile') { @if (section.id === 'scope') {
<ul class="stack"> <section class="stack" [attr.aria-labelledby]="'home-service-areas'">
@for (line of copy.profile; track $index) { <h2 id="home-service-areas">{{ copy.serviceAreasHeading }}</h2>
<li>{{ line }}</li> <ul class="home-service-areas">
} @for (area of copy.serviceAreas; track area.id) {
</ul> <li>
<app-metric-list [metrics]="copy.metrics" [labelledBy]="'section-profile'" /> <a
<div appReveal class="home-reveal"> class="home-service-card glass-surface stack"
<app-systems-map [routerLink]="areaLink(area.routeId)"
[heading]="copy.systemsMap.heading" >
[intro]="copy.systemsMap.intro" <h3>{{ area.title }}</h3>
[headingLevel]="2" <p>{{ area.body }}</p>
/> </a>
</div> </li>
}
</ul>
</section>
<section class="stack" [attr.aria-labelledby]="'home-process'">
<h2 id="home-process">{{ copy.processHeading }}</h2>
<app-process-steps [steps]="copy.process" [labelledBy]="'home-process'" />
</section>
} }
@if (section.id === 'featured-cases') { @if (section.id === 'proof') {
<div appReveal class="home-reveal stack"> <div appReveal class="home-reveal stack">
<h2 id="home-proof">{{ copy.proofHeading }}</h2>
<app-case-card [caseStudy]="proofCase()" />
<p class="home-proof-note" role="note">{{ copy.proofNote }}</p>
@for (caseStudy of featuredCases(); track caseStudy.id) { @for (caseStudy of featuredCases(); track caseStudy.id) {
<app-case-card [caseStudy]="caseStudy" /> <app-case-card [caseStudy]="caseStudy" />
} }
</div> </div>
} }
} }
@for (audience of copy.audiences; track audience.id) { <app-cta-row [ctas]="copy.ctas" />
<section class="stack" [id]="audience.id" [attr.aria-labelledby]="'audience-' + audience.id">
<h2 [id]="'audience-' + audience.id">{{ audience.headline }}</h2>
<p>{{ audience.body }}</p>
<ul>
@for (bullet of audience.bullets; track $index) {
<li>{{ bullet }}</li>
}
</ul>
<app-cta-row [ctas]="audience.ctas" />
</section>
}
</div> </div>
} }

View File

@@ -11,8 +11,51 @@
font-weight: 600; font-weight: 600;
} }
.page h3 {
margin: 0;
font-size: var(--text-lg);
font-weight: 600;
}
.page p, .page p,
.page li { .page li {
max-width: 40rem; max-width: 40rem;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.home-service-areas {
display: grid;
gap: var(--space-4);
list-style: none;
margin: 0;
padding: 0;
}
.home-service-card {
display: grid;
gap: var(--space-2);
padding: var(--space-4);
border-radius: var(--radius-md);
color: inherit;
text-decoration: none;
}
.home-service-card p {
margin: 0;
}
.home-proof-note {
margin: 0;
}
@media (hover: hover) and (pointer: fine) {
.home-service-card:hover {
border-color: var(--surface-glass-border-strong);
}
}
@media (prefers-reduced-motion: reduce) {
.home-service-card {
transition: none;
}
}

View File

@@ -1,26 +1,34 @@
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { ContentService } from '../../core/content/content.service'; import { ContentService } from '../../core/content/content.service';
import { type RouteId } from '../../core/routing/route-ids';
import { NavigationService } from '../../core/navigation/navigation.service';
import { CaseCard } from '../../shared/case-card/case-card'; import { CaseCard } from '../../shared/case-card/case-card';
import { ContentSection } from '../../shared/content-section/content-section'; import { ContentSection } from '../../shared/content-section/content-section';
import { CtaRow } from '../../shared/cta-row/cta-row'; import { CtaRow } from '../../shared/cta-row/cta-row';
import { MetricList } from '../../shared/metric-list/metric-list';
import { RevealDirective } from '../../shared/motion/reveal.directive'; import { RevealDirective } from '../../shared/motion/reveal.directive';
import { PageHero } from '../../shared/page-hero/page-hero'; import { PageHero } from '../../shared/page-hero/page-hero';
import { SystemsMap } from '../../shared/systems-map/systems-map'; import { ProcessSteps } from '../../shared/process-steps/process-steps';
@Component({ @Component({
selector: 'app-home-page', selector: 'app-home-page',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PageHero, ContentSection, MetricList, CaseCard, CtaRow, SystemsMap, RevealDirective], imports: [PageHero, ContentSection, ProcessSteps, CaseCard, CtaRow, RevealDirective, RouterLink],
templateUrl: './home.html', templateUrl: './home.html',
styleUrl: './home.scss', styleUrl: './home.scss',
}) })
export class HomePage { export class HomePage {
private readonly content = inject(ContentService); private readonly content = inject(ContentService);
private readonly navigation = inject(NavigationService);
protected readonly page = this.content.home(); protected readonly page = this.content.home();
protected readonly proofCase = computed(() => this.content.caseStudy(this.page().proofCaseId)());
protected readonly featuredCases = computed(() => { protected readonly featuredCases = computed(() => {
const ids = this.page().featuredCaseIds; const ids = this.page().featuredCaseIds;
return ids.map((id) => this.content.caseStudy(id)()); return ids.map((id) => this.content.caseStudy(id)());
}); });
protected areaLink(routeId: RouteId): unknown[] {
return this.navigation.link(routeId);
}
} }

View File

@@ -0,0 +1,53 @@
@if (page(); as copy) {
<div class="content-container stack page">
<app-page-hero [hero]="copy.hero" [ctas]="copy.ctas" />
@for (section of copy.sections; track section.id) {
<app-content-section [section]="section" />
@if (section.id === 'profile') {
<ul class="stack">
@for (line of copy.profile; track $index) {
<li>{{ line }}</li>
}
</ul>
<app-metric-list [metrics]="copy.metrics" [labelledBy]="'section-profile'" />
}
@if (section.id === 'featured-cases') {
<div class="stack">
@for (caseStudy of featuredCases(); track caseStudy.id) {
<app-case-card [caseStudy]="caseStudy" />
}
</div>
}
}
<section class="stack" [attr.aria-labelledby]="'pitch-timeline'">
<h2 id="pitch-timeline">{{ copy.timelineHeading }}</h2>
<ol class="stack">
@for (entry of copy.timeline; track entry.id) {
<li>
<p>
<strong>{{ entry.period }}</strong>
· {{ entry.role }}
</p>
<p>{{ entry.body }}</p>
</li>
}
</ol>
</section>
<section class="stack" [attr.aria-labelledby]="'pitch-stack'">
<h2 id="pitch-stack">{{ copy.stackHeading }}</h2>
<ul class="stack">
@for (group of copy.coreStack; track group.id) {
<li>
<p>
<strong>{{ group.title }}</strong>
@if (group.body) {
— {{ group.body }}
}
</p>
</li>
}
</ul>
</section>
<app-cta-row [ctas]="copy.ctas" />
</div>
}

View File

@@ -0,0 +1,24 @@
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { ContentService } from '../../core/content/content.service';
import { CaseCard } from '../../shared/case-card/case-card';
import { ContentSection } from '../../shared/content-section/content-section';
import { CtaRow } from '../../shared/cta-row/cta-row';
import { MetricList } from '../../shared/metric-list/metric-list';
import { PageHero } from '../../shared/page-hero/page-hero';
@Component({
selector: 'app-pitch-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PageHero, ContentSection, MetricList, CaseCard, CtaRow],
templateUrl: './pitch.html',
styleUrl: '../../shared/page-shell.scss',
})
export class PitchPage {
private readonly content = inject(ContentService);
protected readonly page = this.content.pitch();
protected readonly featuredCases = computed(() => {
const ids = this.page().featuredCaseIds;
return ids.map((id) => this.content.caseStudy(id)());
});
}

View File

@@ -4,5 +4,10 @@
@for (section of copy.sections; track section.id) { @for (section of copy.sections; track section.id) {
<app-content-section [section]="section" /> <app-content-section [section]="section" />
} }
<app-systems-map
[heading]="copy.systemsMap.heading"
[intro]="copy.systemsMap.intro"
[headingLevel]="2"
/>
</div> </div>
} }

View File

@@ -2,14 +2,15 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ContentService } from '../../core/content/content.service'; import { ContentService } from '../../core/content/content.service';
import { ContentSection } from '../../shared/content-section/content-section'; import { ContentSection } from '../../shared/content-section/content-section';
import { PageHero } from '../../shared/page-hero/page-hero'; import { PageHero } from '../../shared/page-hero/page-hero';
import { SystemsMap } from '../../shared/systems-map/systems-map';
@Component({ @Component({
selector: 'app-services-page', selector: 'app-services-page',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PageHero, ContentSection], imports: [PageHero, ContentSection, SystemsMap],
templateUrl: './services.html', templateUrl: './services.html',
styleUrl: '../../shared/page-shell.scss', styleUrl: '../../shared/page-shell.scss',
}) })
export class ServicesPage { export class ServicesPage {
protected readonly page = inject(ContentService).page('services'); protected readonly page = inject(ContentService).servicesOverview();
} }

View File

@@ -6,5 +6,10 @@
@if (hero().playfulLine; as playfulLine) { @if (hero().playfulLine; as playfulLine) {
<p class="playful">{{ playfulLine }}</p> <p class="playful">{{ playfulLine }}</p>
} }
@if (hero().body; as body) {
@for (paragraph of body; track $index) {
<p>{{ paragraph }}</p>
}
}
<app-cta-row [ctas]="ctas()" /> <app-cta-row [ctas]="ctas()" />
</header> </header>

View File

@@ -27,3 +27,9 @@ h1 {
font-size: var(--text-sm); font-size: var(--text-sm);
font-style: italic; font-style: italic;
} }
.page-hero p:not(.proof):not(.playful) {
margin: 0;
max-width: 40rem;
color: var(--color-text-muted);
}