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:
@@ -9,7 +9,10 @@ describe('server routes', () => {
|
||||
expect(catchAll && 'status' in catchAll ? catchAll.status : undefined).toBe(404);
|
||||
|
||||
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) {
|
||||
expect(route.renderMode, route.path).toBe(RenderMode.Prerender);
|
||||
|
||||
@@ -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', () => {
|
||||
const flattened = flattenRoutes(routes);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ type LazyPage = () => Promise<Type<unknown>>;
|
||||
|
||||
const PAGE_LOADERS: Record<RouteId, LazyPage> = {
|
||||
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),
|
||||
servicesSoftware: () =>
|
||||
import('./features/services/software/software').then((module) => module.ServicesSoftwarePage),
|
||||
|
||||
@@ -11,8 +11,10 @@ import { SITE_CONTENT_DATA } from './site-content';
|
||||
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;
|
||||
|
||||
const UNLIMITED_SCOPE = /\balles\b|\banything\b|any problem|jedes Problem|end-to-end für alles/i;
|
||||
|
||||
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[] {
|
||||
@@ -41,7 +43,7 @@ describe('content claims and attribution', () => {
|
||||
/8/.test(metric.value) && /90/.test(metric.value);
|
||||
const runtimeMetrics = allMetrics(site).filter(matchesRuntime);
|
||||
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);
|
||||
for (const caseId of CASE_STUDY_IDS.filter((id) => id !== 'innofocus')) {
|
||||
expect(site.cases[caseId].metrics.filter(matchesRuntime)).toHaveLength(0);
|
||||
@@ -83,4 +85,27 @@ describe('content claims and attribution', () => {
|
||||
).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,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +39,8 @@ describe('content completeness', () => {
|
||||
const site = SITE_CONTENT_DATA[locale];
|
||||
|
||||
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.projects).toBe(site.projects);
|
||||
expect(site.pages.stack).toBe(site.stack);
|
||||
|
||||
@@ -52,7 +52,16 @@ describe('content exclusions', () => {
|
||||
});
|
||||
|
||||
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) {
|
||||
await harness.navigateByUrl(path);
|
||||
|
||||
@@ -101,23 +101,43 @@ export interface ServicePageCopy extends PageCopy {
|
||||
readonly offeringCaseBackedLabel: string;
|
||||
}
|
||||
|
||||
export interface AudienceEntryCopy {
|
||||
readonly id: 'recruiters' | 'companies';
|
||||
readonly headline: string;
|
||||
export interface HomeServiceAreaCopy {
|
||||
readonly id: 'workplaces' | 'servers' | 'software' | 'ai';
|
||||
readonly title: string;
|
||||
readonly body: string;
|
||||
readonly bullets: readonly string[];
|
||||
readonly ctas: readonly CtaCopy[];
|
||||
readonly routeId: RouteId;
|
||||
}
|
||||
|
||||
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 metrics: readonly MetricCopy[];
|
||||
readonly audiences: readonly AudienceEntryCopy[];
|
||||
readonly timeline: readonly PitchTimelineEntryCopy[];
|
||||
readonly coreStack: readonly StackGroupCopy[];
|
||||
readonly featuredCaseIds: readonly CaseStudyId[];
|
||||
readonly systemsMap: {
|
||||
readonly heading: string;
|
||||
readonly intro: string;
|
||||
};
|
||||
readonly timelineHeading: string;
|
||||
readonly stackHeading: string;
|
||||
}
|
||||
|
||||
export interface ServicesOverviewPageCopy extends PageCopy {
|
||||
readonly systemsMap: { readonly heading: string; readonly intro: string };
|
||||
}
|
||||
|
||||
export type ContactFieldId =
|
||||
@@ -198,6 +218,8 @@ export interface SiteSeoCopy {
|
||||
export interface SiteContent {
|
||||
readonly pages: Record<RouteId, PageCopy>;
|
||||
readonly home: HomePageCopy;
|
||||
readonly pitch: PitchPageCopy;
|
||||
readonly servicesOverview: ServicesOverviewPageCopy;
|
||||
readonly services: Record<ServicePageId, ServicePageCopy>;
|
||||
readonly cases: Record<CaseStudyId, CaseStudyCopy>;
|
||||
readonly contact: ContactPageCopy;
|
||||
|
||||
@@ -9,9 +9,11 @@ import {
|
||||
type HomePageCopy,
|
||||
type LegalPageCopy,
|
||||
type PageCopy,
|
||||
type PitchPageCopy,
|
||||
type ProjectsPageCopy,
|
||||
type ServicePageCopy,
|
||||
type ServicePageId,
|
||||
type ServicesOverviewPageCopy,
|
||||
type StackPageCopy,
|
||||
} from './content.contracts';
|
||||
import { SITE_CONTENT } from './content.token';
|
||||
@@ -29,6 +31,14 @@ export class ContentService {
|
||||
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> {
|
||||
return computed(() => this.content[this.localeService.locale()].services[id]);
|
||||
}
|
||||
|
||||
@@ -1,103 +1,93 @@
|
||||
import { SITE_CONFIG } from '../site-config';
|
||||
import { type HomePageCopy } from '../content.contracts';
|
||||
|
||||
export const HOME_DE: HomePageCopy = {
|
||||
routeId: 'home',
|
||||
title: 'Startseite | Antonio Ledebuhr',
|
||||
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: {
|
||||
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:
|
||||
'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',
|
||||
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: [
|
||||
'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: [
|
||||
serviceAreasHeading: 'Wobei ich helfen kann',
|
||||
serviceAreas: [
|
||||
{
|
||||
id: 'experience-years',
|
||||
value: 'ca. 7 Jahre',
|
||||
label: 'berufliche Erfahrung mit Webanwendungen',
|
||||
id: 'workplaces',
|
||||
title: 'Arbeitsplätze und Netz',
|
||||
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',
|
||||
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: 'servers',
|
||||
title: 'Server und Cluster',
|
||||
body: 'Einzelne Hosts, Virtualisierung oder ein Cluster. Der Weg dorthin bleibt nachvollziehbar, on-premise oder auf Azure AKS.',
|
||||
routeId: 'servicesClusters',
|
||||
},
|
||||
{
|
||||
id: 'myspa-team-lead',
|
||||
value: '4 + 2',
|
||||
label: 'Leitung des Backend-Teams und des DevOps-Teams bis zum Start von MySpa',
|
||||
caseId: 'myspa',
|
||||
id: 'software',
|
||||
title: 'Software und Daten',
|
||||
body: 'Produktsoftware und Datenarbeit: von der Fachlichkeit über die API bis zur Oberfläche, inklusive SQL.',
|
||||
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',
|
||||
headline: 'Für Recruiterinnen und Recruiter',
|
||||
body: 'Ein kurzer Überblick über Erfahrung, ausgewählte Fälle, den technischen Stack und den Lebenslauf.',
|
||||
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: 'describe',
|
||||
title: 'Lage in Deinen Worten',
|
||||
body: 'Du beschreibst das Ziel, den Schmerz oder den wiederkehrenden Ablauf. Fachsprache ist nicht nötig.',
|
||||
},
|
||||
{
|
||||
id: 'companies',
|
||||
headline: 'Für Unternehmen',
|
||||
body: 'Vier Leistungsbereiche, vom ersten Workshop bis zum Betrieb — und ein kurzes Briefing für die Anfrage.',
|
||||
bullets: [
|
||||
'Software: Fullstack-Produkte mit Java, .NET, Angular und Datenarbeit in SQL.',
|
||||
'Hardware und Netzwerk: Server, Geräte, Microsoft 365 und wartbare Infrastruktur.',
|
||||
'Cluster: Docker und Kubernetes on-premise und auf Azure AKS, inklusive Pipelines.',
|
||||
'KI-Integration: zwei umgesetzte Werkzeuge bei der Rösterei Tangermünde, weitere Bausteine als Angebot.',
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Leistungen ansehen', routeId: 'services' },
|
||||
{ label: 'Projekt anfragen', routeId: 'contact' },
|
||||
],
|
||||
id: 'diagnose',
|
||||
title: 'Technische Diagnose',
|
||||
body: 'Ich ordne die Lage den vier Bereichen zu, nenne die Grenze und sage, was außerhalb liegt.',
|
||||
},
|
||||
{
|
||||
id: 'implement',
|
||||
title: 'Umsetzung',
|
||||
body: 'Ein Ansprechpartner setzt um: Software, Infrastruktur, Cluster oder ein schmales KI-Werkzeug, je nach Briefing.',
|
||||
},
|
||||
{
|
||||
id: 'operate',
|
||||
title: 'Betrieb oder Übergabe',
|
||||
body: 'Der Stand bleibt bedienbar. Übergabe heißt nachvollziehbare Strukturen, kein undokumentierter Zwischenstand.',
|
||||
},
|
||||
],
|
||||
featuredCaseIds: ['innofocus', 'roesterei', 'myspa', 'hdi'],
|
||||
systemsMap: {
|
||||
heading: 'Wie die Schichten zusammenhängen',
|
||||
intro: 'Hardware, Cluster, Software und KI-Anbindung — und wo die öffentlichen Fälle ansetzen.',
|
||||
},
|
||||
proofCaseId: 'roesterei',
|
||||
proofHeading: 'Ein belegter Weg vom Netz bis zum Shop',
|
||||
proofNote: 'Antonio Ledebuhr hält eine wirtschaftliche Beteiligung an der Rösterei Tangermünde.',
|
||||
featuredCaseIds: ['innofocus', 'myspa', 'hdi'],
|
||||
sections: [
|
||||
{
|
||||
id: 'profile',
|
||||
headline: 'In dreißig Sekunden',
|
||||
id: 'scope',
|
||||
headline: 'Was diese Seite abdeckt',
|
||||
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',
|
||||
headline: 'Ausgewählte Fälle',
|
||||
id: 'proof',
|
||||
headline: 'Warum die Rösterei als Beleg steht',
|
||||
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: [
|
||||
{ label: 'Leistungen', routeId: 'services' },
|
||||
{ label: 'Projekte', routeId: 'projects' },
|
||||
{ label: 'Kontakt', routeId: 'contact' },
|
||||
{ label: 'Projektbriefing starten', routeId: 'contact' },
|
||||
{ label: 'Leistungen ansehen', routeId: 'services' },
|
||||
{ label: 'Fall Rösterei Tangermünde', routeId: 'projects', fragment: 'roesterei' },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -35,6 +35,7 @@ export const PROJECTS_DE: ProjectsPageCopy = {
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Lebenslauf als PDF', href: SITE_CONFIG.cvAssetPath },
|
||||
{ label: 'Technik-Stack ansehen', routeId: 'stack' },
|
||||
{ label: 'Kontakt', routeId: 'contact' },
|
||||
],
|
||||
caseLabels: {
|
||||
@@ -134,6 +135,7 @@ export const ABOUT_DE: PageCopy = {
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Projekte', routeId: 'projects' },
|
||||
{ label: 'Für Recruiterinnen und Recruiter', routeId: 'pitch' },
|
||||
{ label: 'Kontakt', routeId: 'contact' },
|
||||
],
|
||||
};
|
||||
@@ -173,8 +175,10 @@ export const CONTACT_DE: ContactPageCopy = {
|
||||
id: 'projectType',
|
||||
control: 'select',
|
||||
label: 'Art des Vorhabens',
|
||||
required: true,
|
||||
hint: 'freiwillig — wenn noch unklar, einfach offen lassen oder „Noch unklar“ wählen',
|
||||
required: false,
|
||||
options: [
|
||||
{ value: 'unsure', label: 'Noch unklar' },
|
||||
{ value: 'software', label: 'Software' },
|
||||
{ value: 'hardware-network', label: 'Hardware und Netzwerk' },
|
||||
{ value: 'clusters', label: 'Cluster' },
|
||||
|
||||
133
src/app/core/content/de/pitch.ts
Normal file
133
src/app/core/content/de/pitch.ts
Normal 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}` },
|
||||
],
|
||||
};
|
||||
@@ -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',
|
||||
title: 'Leistungen | Antonio Ledebuhr',
|
||||
description:
|
||||
@@ -45,6 +45,10 @@ export const SERVICES_OVERVIEW_DE: PageCopy = {
|
||||
{ label: 'KI-Integration', routeId: 'servicesAi' },
|
||||
{ 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 = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type SiteContent } from '../content.contracts';
|
||||
import { CASES_DE } from './cases';
|
||||
import { HOME_DE } from './home';
|
||||
import { PITCH_DE } from './pitch';
|
||||
import {
|
||||
ABOUT_DE,
|
||||
CONTACT_DE,
|
||||
@@ -20,6 +21,8 @@ import {
|
||||
|
||||
export const SITE_CONTENT_DE: SiteContent = {
|
||||
home: HOME_DE,
|
||||
pitch: PITCH_DE,
|
||||
servicesOverview: SERVICES_OVERVIEW_DE,
|
||||
services: {
|
||||
servicesSoftware: SERVICES_SOFTWARE_DE,
|
||||
servicesHardwareNetwork: SERVICES_HARDWARE_DE,
|
||||
@@ -42,6 +45,7 @@ export const SITE_CONTENT_DE: SiteContent = {
|
||||
},
|
||||
pages: {
|
||||
home: HOME_DE,
|
||||
pitch: PITCH_DE,
|
||||
services: SERVICES_OVERVIEW_DE,
|
||||
servicesSoftware: SERVICES_SOFTWARE_DE,
|
||||
servicesHardwareNetwork: SERVICES_HARDWARE_DE,
|
||||
|
||||
@@ -1,103 +1,93 @@
|
||||
import { SITE_CONFIG } from '../site-config';
|
||||
import { type HomePageCopy } from '../content.contracts';
|
||||
|
||||
export const HOME_EN: HomePageCopy = {
|
||||
routeId: 'home',
|
||||
title: 'Home | 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.',
|
||||
'You describe what the business needs to achieve. One technical counterpart owns the path from diagnosis through delivery to operations or handover.',
|
||||
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:
|
||||
'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',
|
||||
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: [
|
||||
'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: [
|
||||
serviceAreasHeading: 'Where this work fits',
|
||||
serviceAreas: [
|
||||
{
|
||||
id: 'experience-years',
|
||||
value: 'about 7 years',
|
||||
label: 'professional experience with web applications',
|
||||
id: 'workplaces',
|
||||
title: 'Workplaces and network',
|
||||
body: 'Devices, the network and the services your team uses every day — cut so that Tuesday still works.',
|
||||
routeId: 'servicesHardwareNetwork',
|
||||
},
|
||||
{
|
||||
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: 'servers',
|
||||
title: 'Servers and clusters',
|
||||
body: 'A single host, virtualisation or a cluster. The path there stays readable, on-premise or on Azure AKS.',
|
||||
routeId: 'servicesClusters',
|
||||
},
|
||||
{
|
||||
id: 'myspa-team-lead',
|
||||
value: '4 + 2',
|
||||
label: 'led the backend team and the DevOps team through the MySpa launch',
|
||||
caseId: 'myspa',
|
||||
id: 'software',
|
||||
title: 'Software and data',
|
||||
body: 'Product software and data work: from the domain through the API to the interface, including SQL.',
|
||||
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',
|
||||
headline: 'For recruiters',
|
||||
body: 'A short path through experience, selected cases, the public stack and the curriculum vitae.',
|
||||
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: 'describe',
|
||||
title: 'The situation in your words',
|
||||
body: 'You describe the goal, the pain or the recurring process. Technical language is not required.',
|
||||
},
|
||||
{
|
||||
id: 'companies',
|
||||
headline: 'For companies',
|
||||
body: 'Four service areas, from the first workshop through to operations — and a short briefing for an enquiry.',
|
||||
bullets: [
|
||||
'Software: fullstack product work with Java, .NET, Angular and SQL data work.',
|
||||
'Hardware and network: servers, devices, Microsoft 365 and maintainable infrastructure.',
|
||||
'Clusters: Docker and Kubernetes on-premise and on Azure AKS, including pipelines.',
|
||||
'AI integration: two delivered tools at Rösterei Tangermünde, further building blocks as an offer.',
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Browse services', routeId: 'services' },
|
||||
{ label: 'Start a project enquiry', routeId: 'contact' },
|
||||
],
|
||||
id: 'diagnose',
|
||||
title: 'Technical diagnosis',
|
||||
body: 'I map the brief onto the four areas, name the boundary and say what sits outside it.',
|
||||
},
|
||||
{
|
||||
id: 'implement',
|
||||
title: 'Implementation',
|
||||
body: 'One counterpart delivers: software, infrastructure, a cluster or a narrow AI tool, according to the brief.',
|
||||
},
|
||||
{
|
||||
id: 'operate',
|
||||
title: 'Operations or handover',
|
||||
body: 'The result stays operable. Handover means readable structures, not an undocumented snapshot.',
|
||||
},
|
||||
],
|
||||
featuredCaseIds: ['innofocus', 'roesterei', 'myspa', 'hdi'],
|
||||
systemsMap: {
|
||||
heading: 'How the layers connect',
|
||||
intro: 'Hardware, clusters, software and AI integration — and where the public cases attach.',
|
||||
},
|
||||
proofCaseId: 'roesterei',
|
||||
proofHeading: 'A verified path from the network to the shop',
|
||||
proofNote: 'Antonio Ledebuhr holds an economic stake in Rösterei Tangermünde.',
|
||||
featuredCaseIds: ['innofocus', 'myspa', 'hdi'],
|
||||
sections: [
|
||||
{
|
||||
id: 'profile',
|
||||
headline: 'Thirty seconds',
|
||||
id: 'scope',
|
||||
headline: 'What this page covers',
|
||||
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',
|
||||
headline: 'Selected cases',
|
||||
id: 'proof',
|
||||
headline: 'Why the roastery is the proof',
|
||||
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: [
|
||||
{ label: 'Services', routeId: 'services' },
|
||||
{ label: 'Projects', routeId: 'projects' },
|
||||
{ label: 'Contact', routeId: 'contact' },
|
||||
{ label: 'Start a project briefing', routeId: 'contact' },
|
||||
{ label: 'Browse services', routeId: 'services' },
|
||||
{ label: 'Rösterei Tangermünde case', routeId: 'projects', fragment: 'roesterei' },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -35,6 +35,7 @@ export const PROJECTS_EN: ProjectsPageCopy = {
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Curriculum vitae as PDF', href: SITE_CONFIG.cvAssetPath },
|
||||
{ label: 'View the stack', routeId: 'stack' },
|
||||
{ label: 'Contact', routeId: 'contact' },
|
||||
],
|
||||
caseLabels: {
|
||||
@@ -134,6 +135,7 @@ export const ABOUT_EN: PageCopy = {
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Projects', routeId: 'projects' },
|
||||
{ label: 'For recruiters', routeId: 'pitch' },
|
||||
{ label: 'Contact', routeId: 'contact' },
|
||||
],
|
||||
};
|
||||
@@ -173,8 +175,10 @@ export const CONTACT_EN: ContactPageCopy = {
|
||||
id: 'projectType',
|
||||
control: 'select',
|
||||
label: 'Type of work',
|
||||
required: true,
|
||||
hint: 'optional — leave blank or pick “Not sure yet” if the category is still open',
|
||||
required: false,
|
||||
options: [
|
||||
{ value: 'unsure', label: 'Not sure yet' },
|
||||
{ value: 'software', label: 'Software' },
|
||||
{ value: 'hardware-network', label: 'Hardware and network' },
|
||||
{ value: 'clusters', label: 'Clusters' },
|
||||
|
||||
133
src/app/core/content/en/pitch.ts
Normal file
133
src/app/core/content/en/pitch.ts
Normal 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}` },
|
||||
],
|
||||
};
|
||||
@@ -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',
|
||||
title: 'Services | Antonio Ledebuhr',
|
||||
description:
|
||||
@@ -45,6 +45,10 @@ export const SERVICES_OVERVIEW_EN: PageCopy = {
|
||||
{ label: 'AI integration', routeId: 'servicesAi' },
|
||||
{ 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 = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type SiteContent } from '../content.contracts';
|
||||
import { CASES_EN } from './cases';
|
||||
import { HOME_EN } from './home';
|
||||
import { PITCH_EN } from './pitch';
|
||||
import {
|
||||
ABOUT_EN,
|
||||
CONTACT_EN,
|
||||
@@ -20,6 +21,8 @@ import {
|
||||
|
||||
export const SITE_CONTENT_EN: SiteContent = {
|
||||
home: HOME_EN,
|
||||
pitch: PITCH_EN,
|
||||
servicesOverview: SERVICES_OVERVIEW_EN,
|
||||
services: {
|
||||
servicesSoftware: SERVICES_SOFTWARE_EN,
|
||||
servicesHardwareNetwork: SERVICES_HARDWARE_EN,
|
||||
@@ -42,6 +45,7 @@ export const SITE_CONTENT_EN: SiteContent = {
|
||||
},
|
||||
pages: {
|
||||
home: HOME_EN,
|
||||
pitch: PITCH_EN,
|
||||
services: SERVICES_OVERVIEW_EN,
|
||||
servicesSoftware: SERVICES_SOFTWARE_EN,
|
||||
servicesHardwareNetwork: SERVICES_HARDWARE_EN,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type RouteId =
|
||||
| 'home'
|
||||
| 'pitch'
|
||||
| 'services'
|
||||
| 'servicesSoftware'
|
||||
| 'servicesHardwareNetwork'
|
||||
@@ -15,6 +16,7 @@ export type RouteId =
|
||||
|
||||
export const ROUTE_IDS: readonly RouteId[] = [
|
||||
'home',
|
||||
'pitch',
|
||||
'services',
|
||||
'servicesSoftware',
|
||||
'servicesHardwareNetwork',
|
||||
|
||||
@@ -27,6 +27,14 @@ describe('route paths', () => {
|
||||
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', () => {
|
||||
const paths = prerenderablePaths();
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export const LOCALE_PREFIX: Record<AppLocale, string> = {
|
||||
export const ROUTE_SEGMENTS: Record<AppLocale, Record<RouteId, string>> = {
|
||||
de: {
|
||||
home: '',
|
||||
pitch: 'pitch',
|
||||
services: 'leistungen',
|
||||
servicesSoftware: 'leistungen/software',
|
||||
servicesHardwareNetwork: 'leistungen/hardware-netzwerk',
|
||||
@@ -24,6 +25,7 @@ export const ROUTE_SEGMENTS: Record<AppLocale, Record<RouteId, string>> = {
|
||||
},
|
||||
en: {
|
||||
home: '',
|
||||
pitch: 'pitch',
|
||||
services: 'services',
|
||||
servicesSoftware: 'services/software',
|
||||
servicesHardwareNetwork: 'services/hardware-network',
|
||||
|
||||
@@ -113,6 +113,8 @@ describe('crawl assets', () => {
|
||||
const required = [
|
||||
absoluteUrl(routePath('home', 'de')),
|
||||
absoluteUrl(routePath('home', 'en')),
|
||||
absoluteUrl(routePath('pitch', 'de')),
|
||||
absoluteUrl(routePath('pitch', 'en')),
|
||||
absoluteUrl(routePath('about', 'de')),
|
||||
absoluteUrl(routePath('about', 'en')),
|
||||
absoluteUrl(routePath('services', 'de')),
|
||||
|
||||
@@ -58,4 +58,28 @@ describe('SeoService', () => {
|
||||
);
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,6 +61,7 @@ describe('JSON-LD builders', () => {
|
||||
const home = buildJsonLdGraph('home', locale, SITE_CONTENT_DATA);
|
||||
expect(home['@context']).toBe('https://schema.org');
|
||||
expect(graphTypes('home', locale)).toEqual(['Person', 'ProfessionalService']);
|
||||
expect(graphTypes('pitch', locale), `${locale}.pitch`).toEqual(['WebPage']);
|
||||
|
||||
for (const routeId of SERVICE_ROUTES) {
|
||||
expect(graphTypes(routeId, locale), `${locale}.${routeId}`).toEqual(['Service']);
|
||||
|
||||
@@ -3,40 +3,39 @@
|
||||
<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'" />
|
||||
<div appReveal class="home-reveal">
|
||||
<app-systems-map
|
||||
[heading]="copy.systemsMap.heading"
|
||||
[intro]="copy.systemsMap.intro"
|
||||
[headingLevel]="2"
|
||||
/>
|
||||
</div>
|
||||
@if (section.id === 'scope') {
|
||||
<section class="stack" [attr.aria-labelledby]="'home-service-areas'">
|
||||
<h2 id="home-service-areas">{{ copy.serviceAreasHeading }}</h2>
|
||||
<ul class="home-service-areas">
|
||||
@for (area of copy.serviceAreas; track area.id) {
|
||||
<li>
|
||||
<a
|
||||
class="home-service-card glass-surface stack"
|
||||
[routerLink]="areaLink(area.routeId)"
|
||||
>
|
||||
<h3>{{ area.title }}</h3>
|
||||
<p>{{ area.body }}</p>
|
||||
</a>
|
||||
</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">
|
||||
<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) {
|
||||
<app-case-card [caseStudy]="caseStudy" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@for (audience of copy.audiences; track audience.id) {
|
||||
<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>
|
||||
}
|
||||
<app-cta-row [ctas]="copy.ctas" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -11,8 +11,51 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page h3 {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page p,
|
||||
.page li {
|
||||
max-width: 40rem;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
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 { ContentSection } from '../../shared/content-section/content-section';
|
||||
import { CtaRow } from '../../shared/cta-row/cta-row';
|
||||
import { MetricList } from '../../shared/metric-list/metric-list';
|
||||
import { RevealDirective } from '../../shared/motion/reveal.directive';
|
||||
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({
|
||||
selector: 'app-home-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PageHero, ContentSection, MetricList, CaseCard, CtaRow, SystemsMap, RevealDirective],
|
||||
imports: [PageHero, ContentSection, ProcessSteps, CaseCard, CtaRow, RevealDirective, RouterLink],
|
||||
templateUrl: './home.html',
|
||||
styleUrl: './home.scss',
|
||||
})
|
||||
export class HomePage {
|
||||
private readonly content = inject(ContentService);
|
||||
private readonly navigation = inject(NavigationService);
|
||||
|
||||
protected readonly page = this.content.home();
|
||||
protected readonly proofCase = computed(() => this.content.caseStudy(this.page().proofCaseId)());
|
||||
protected readonly featuredCases = computed(() => {
|
||||
const ids = this.page().featuredCaseIds;
|
||||
return ids.map((id) => this.content.caseStudy(id)());
|
||||
});
|
||||
|
||||
protected areaLink(routeId: RouteId): unknown[] {
|
||||
return this.navigation.link(routeId);
|
||||
}
|
||||
}
|
||||
|
||||
53
src/app/features/pitch/pitch.html
Normal file
53
src/app/features/pitch/pitch.html
Normal 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>
|
||||
}
|
||||
24
src/app/features/pitch/pitch.ts
Normal file
24
src/app/features/pitch/pitch.ts
Normal 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)());
|
||||
});
|
||||
}
|
||||
@@ -4,5 +4,10 @@
|
||||
@for (section of copy.sections; track section.id) {
|
||||
<app-content-section [section]="section" />
|
||||
}
|
||||
<app-systems-map
|
||||
[heading]="copy.systemsMap.heading"
|
||||
[intro]="copy.systemsMap.intro"
|
||||
[headingLevel]="2"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -2,14 +2,15 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { ContentService } from '../../core/content/content.service';
|
||||
import { ContentSection } from '../../shared/content-section/content-section';
|
||||
import { PageHero } from '../../shared/page-hero/page-hero';
|
||||
import { SystemsMap } from '../../shared/systems-map/systems-map';
|
||||
|
||||
@Component({
|
||||
selector: 'app-services-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PageHero, ContentSection],
|
||||
imports: [PageHero, ContentSection, SystemsMap],
|
||||
templateUrl: './services.html',
|
||||
styleUrl: '../../shared/page-shell.scss',
|
||||
})
|
||||
export class ServicesPage {
|
||||
protected readonly page = inject(ContentService).page('services');
|
||||
protected readonly page = inject(ContentService).servicesOverview();
|
||||
}
|
||||
|
||||
@@ -6,5 +6,10 @@
|
||||
@if (hero().playfulLine; as playfulLine) {
|
||||
<p class="playful">{{ playfulLine }}</p>
|
||||
}
|
||||
@if (hero().body; as body) {
|
||||
@for (paragraph of body; track $index) {
|
||||
<p>{{ paragraph }}</p>
|
||||
}
|
||||
}
|
||||
<app-cta-row [ctas]="ctas()" />
|
||||
</header>
|
||||
|
||||
@@ -27,3 +27,9 @@ h1 {
|
||||
font-size: var(--text-sm);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.page-hero p:not(.proof):not(.playful) {
|
||||
margin: 0;
|
||||
max-width: 40rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user