content: repair visitor-facing copy and briefing accessibility
Public pages no longer describe their own pipeline, case and service labels are real accessible names, and the contact briefing writes localized fields without empty optional lines. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -40,6 +40,7 @@ describe('content completeness', () => {
|
|||||||
|
|
||||||
expect(site.pages.home).toBe(site.home);
|
expect(site.pages.home).toBe(site.home);
|
||||||
expect(site.pages.contact).toBe(site.contact);
|
expect(site.pages.contact).toBe(site.contact);
|
||||||
|
expect(site.pages.projects).toBe(site.projects);
|
||||||
expect(site.pages.stack).toBe(site.stack);
|
expect(site.pages.stack).toBe(site.stack);
|
||||||
expect(site.pages.imprint).toBe(site.legal.imprint);
|
expect(site.pages.imprint).toBe(site.legal.imprint);
|
||||||
expect(site.pages.privacy).toBe(site.legal.privacy);
|
expect(site.pages.privacy).toBe(site.legal.privacy);
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ describe('content parity', () => {
|
|||||||
const english = SITE_CONTENT_DATA.en.cases[caseId];
|
const english = SITE_CONTENT_DATA.en.cases[caseId];
|
||||||
expect(german.summary, caseId).not.toBe(english.summary);
|
expect(german.summary, caseId).not.toBe(english.summary);
|
||||||
expect(german.situation, caseId).not.toBe(english.situation);
|
expect(german.situation, caseId).not.toBe(english.situation);
|
||||||
|
expect(german.linkLabel, caseId).not.toBe(english.linkLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(APP_LOCALES).toEqual(['de', 'en']);
|
expect(APP_LOCALES).toEqual(['de', 'en']);
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export interface CaseStudyCopy {
|
|||||||
readonly metrics: readonly MetricCopy[];
|
readonly metrics: readonly MetricCopy[];
|
||||||
readonly stack: readonly string[];
|
readonly stack: readonly string[];
|
||||||
readonly tags: readonly string[];
|
readonly tags: readonly string[];
|
||||||
|
readonly linkLabel: string;
|
||||||
readonly transparencyNote?: string;
|
readonly transparencyNote?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +87,7 @@ export interface ServiceSequenceCopy {
|
|||||||
readonly implementation: SectionCopy;
|
readonly implementation: SectionCopy;
|
||||||
readonly operation: SectionCopy;
|
readonly operation: SectionCopy;
|
||||||
readonly referenceCaseId: CaseStudyId;
|
readonly referenceCaseId: CaseStudyId;
|
||||||
|
readonly referenceHeadline: string;
|
||||||
readonly referenceNote: string;
|
readonly referenceNote: string;
|
||||||
readonly inquiry: SectionCopy;
|
readonly inquiry: SectionCopy;
|
||||||
}
|
}
|
||||||
@@ -162,6 +164,18 @@ export interface StackPageCopy extends PageCopy {
|
|||||||
readonly groups: readonly StackGroupCopy[];
|
readonly groups: readonly StackGroupCopy[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CaseStudyLabels {
|
||||||
|
readonly situation: string;
|
||||||
|
readonly approach: string;
|
||||||
|
readonly outcome: string;
|
||||||
|
readonly stack: string;
|
||||||
|
readonly tags: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectsPageCopy extends PageCopy {
|
||||||
|
readonly caseLabels: CaseStudyLabels;
|
||||||
|
}
|
||||||
|
|
||||||
export type ServicePageId =
|
export type ServicePageId =
|
||||||
'servicesSoftware' | 'servicesHardwareNetwork' | 'servicesClusters' | 'servicesAi';
|
'servicesSoftware' | 'servicesHardwareNetwork' | 'servicesClusters' | 'servicesAi';
|
||||||
|
|
||||||
@@ -171,6 +185,7 @@ export interface SiteContent {
|
|||||||
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;
|
||||||
|
readonly projects: ProjectsPageCopy;
|
||||||
readonly stack: StackPageCopy;
|
readonly stack: StackPageCopy;
|
||||||
readonly legal: Record<'imprint' | 'privacy', LegalPageCopy>;
|
readonly legal: Record<'imprint' | 'privacy', LegalPageCopy>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type HomePageCopy,
|
type HomePageCopy,
|
||||||
type LegalPageCopy,
|
type LegalPageCopy,
|
||||||
type PageCopy,
|
type PageCopy,
|
||||||
|
type ProjectsPageCopy,
|
||||||
type ServicePageCopy,
|
type ServicePageCopy,
|
||||||
type ServicePageId,
|
type ServicePageId,
|
||||||
type StackPageCopy,
|
type StackPageCopy,
|
||||||
@@ -47,6 +48,10 @@ export class ContentService {
|
|||||||
return computed(() => this.content[this.localeService.locale()].contact);
|
return computed(() => this.content[this.localeService.locale()].contact);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
projects(): Signal<ProjectsPageCopy> {
|
||||||
|
return computed(() => this.content[this.localeService.locale()].projects);
|
||||||
|
}
|
||||||
|
|
||||||
stack(): Signal<StackPageCopy> {
|
stack(): Signal<StackPageCopy> {
|
||||||
return computed(() => this.content[this.localeService.locale()].stack);
|
return computed(() => this.content[this.localeService.locale()].stack);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export const CASES_DE: Record<'innofocus' | 'roesterei' | 'myspa' | 'hdi', CaseS
|
|||||||
'Azure DevOps',
|
'Azure DevOps',
|
||||||
],
|
],
|
||||||
tags: ['eCommerce', 'ERP', 'Datenmigration', 'SQL'],
|
tags: ['eCommerce', 'ERP', 'Datenmigration', 'SQL'],
|
||||||
|
linkLabel: 'Fall lesen: Innofocus / reifen.com',
|
||||||
},
|
},
|
||||||
roesterei: {
|
roesterei: {
|
||||||
id: 'roesterei',
|
id: 'roesterei',
|
||||||
@@ -80,6 +81,7 @@ export const CASES_DE: Record<'innofocus' | 'roesterei' | 'myspa' | 'hdi', CaseS
|
|||||||
'Microsoft 365',
|
'Microsoft 365',
|
||||||
],
|
],
|
||||||
tags: ['eCommerce', 'interne IT', 'Shopify', 'KI-Werkzeuge'],
|
tags: ['eCommerce', 'interne IT', 'Shopify', 'KI-Werkzeuge'],
|
||||||
|
linkLabel: 'Fall lesen: Rösterei Tangermünde',
|
||||||
transparencyNote:
|
transparencyNote:
|
||||||
'Antonio Ledebuhr hält eine wirtschaftliche Beteiligung an der Rösterei Tangermünde.',
|
'Antonio Ledebuhr hält eine wirtschaftliche Beteiligung an der Rösterei Tangermünde.',
|
||||||
},
|
},
|
||||||
@@ -123,6 +125,7 @@ export const CASES_DE: Record<'innofocus' | 'roesterei' | 'myspa' | 'hdi', CaseS
|
|||||||
'GitLab EE',
|
'GitLab EE',
|
||||||
],
|
],
|
||||||
tags: ['IoT', 'Backend', 'DevOps', 'Docker'],
|
tags: ['IoT', 'Backend', 'DevOps', 'Docker'],
|
||||||
|
linkLabel: 'Fall lesen: Aracom IT Services / MySpa',
|
||||||
},
|
},
|
||||||
hdi: {
|
hdi: {
|
||||||
id: 'hdi',
|
id: 'hdi',
|
||||||
@@ -165,5 +168,6 @@ export const CASES_DE: Record<'innofocus' | 'roesterei' | 'myspa' | 'hdi', CaseS
|
|||||||
'Azure DevOps',
|
'Azure DevOps',
|
||||||
],
|
],
|
||||||
tags: ['Versicherung', 'Fullstack', 'Kubernetes', 'Azure'],
|
tags: ['Versicherung', 'Fullstack', 'Kubernetes', 'Azure'],
|
||||||
|
linkLabel: 'Fall lesen: HDI Specialty',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export const HOME_DE: HomePageCopy = {
|
|||||||
id: 'profile',
|
id: 'profile',
|
||||||
headline: 'In dreißig Sekunden',
|
headline: 'In dreißig Sekunden',
|
||||||
body: [
|
body: [
|
||||||
'Antonio Ledebuhr arbeitet als Fullstack- und DevOps-Ingenieur aus Tangermünde. Der öffentliche Stand zeigt ausgewählte Stationen, keinen vollständigen Lebenslauf.',
|
'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.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import {
|
|||||||
type ContactPageCopy,
|
type ContactPageCopy,
|
||||||
type LegalPageCopy,
|
type LegalPageCopy,
|
||||||
type PageCopy,
|
type PageCopy,
|
||||||
|
type ProjectsPageCopy,
|
||||||
type StackPageCopy,
|
type StackPageCopy,
|
||||||
} from '../content.contracts';
|
} from '../content.contracts';
|
||||||
|
|
||||||
export const PROJECTS_DE: PageCopy = {
|
export const PROJECTS_DE: ProjectsPageCopy = {
|
||||||
routeId: 'projects',
|
routeId: 'projects',
|
||||||
title: 'Projekte | Antonio Ledebuhr',
|
title: 'Projekte | Antonio Ledebuhr',
|
||||||
description:
|
description:
|
||||||
@@ -26,36 +27,23 @@ export const PROJECTS_DE: PageCopy = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'reading',
|
id: 'reading',
|
||||||
headline: 'Wie die Fälle gelesen werden sollen',
|
headline: 'Was jeder Fall festhält',
|
||||||
body: [
|
body: [
|
||||||
'Jeder Fall nennt Auftraggeber, Rolle, Zeitraum, Lage, Vorgehen, Ergebnis, Technik und Schlagworte. Zahlen stehen nur dort, wo sie belegt sind.',
|
'Jeder Fall dokumentiert Auftrag, Rolle, Zeitraum, Lage, Vorgehen und Ergebnis. Zahlen stehen nur dort, wo sie belegt sind.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'situation',
|
|
||||||
headline: 'Lage',
|
|
||||||
body: ['Die Lage beschreibt den Ausgangspunkt des jeweiligen Auftrags.'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'approach',
|
|
||||||
headline: 'Vorgehen',
|
|
||||||
body: ['Das Vorgehen nennt die konkreten Schritte der Arbeit.'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'outcome',
|
|
||||||
headline: 'Ergebnis',
|
|
||||||
body: ['Das Ergebnis nennt nur Folgen, die belegt sind.'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'stack',
|
|
||||||
headline: 'Technik',
|
|
||||||
body: ['Die Technikliste folgt dem jeweiligen Auftrag.'],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
ctas: [
|
ctas: [
|
||||||
{ label: 'Lebenslauf als PDF', href: SITE_CONFIG.cvAssetPath },
|
{ label: 'Lebenslauf als PDF', href: SITE_CONFIG.cvAssetPath },
|
||||||
{ label: 'Kontakt', routeId: 'contact' },
|
{ label: 'Kontakt', routeId: 'contact' },
|
||||||
],
|
],
|
||||||
|
caseLabels: {
|
||||||
|
situation: 'Lage',
|
||||||
|
approach: 'Vorgehen',
|
||||||
|
outcome: 'Ergebnis',
|
||||||
|
stack: 'Technik',
|
||||||
|
tags: 'Schlagworte',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const STACK_DE: StackPageCopy = {
|
export const STACK_DE: StackPageCopy = {
|
||||||
@@ -66,21 +54,19 @@ export const STACK_DE: StackPageCopy = {
|
|||||||
hero: {
|
hero: {
|
||||||
headline: 'Der Stack, der auf diesen Seiten vorkommt',
|
headline: 'Der Stack, der auf diesen Seiten vorkommt',
|
||||||
proof:
|
proof:
|
||||||
'Die Gruppen folgen der öffentlichen Rasterdarstellung. Neue Namen werden hier nicht ergänzt.',
|
'Die Gruppen gehören zur Produktarbeit in Java und Spring sowie in C# und .NET, zur Datenarbeit in SQL Server, zu Containern und Clustern und zu Servern vor Ort.',
|
||||||
},
|
},
|
||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
id: 'reading',
|
id: 'reading',
|
||||||
headline: 'Wie das Raster zu lesen ist',
|
headline: 'Eine Auswahl, keine Rangliste',
|
||||||
body: [
|
body: ['Die Übersicht ist eine Auswahl aus der öffentlichen Arbeit, keine Rangliste.'],
|
||||||
'Die Kacheln zeigen die Technologien, die auf dieser Seite öffentlich geführt werden. Sie sind keine Rangliste und keine Vollständigkeitserklärung.',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'groups',
|
id: 'groups',
|
||||||
headline: 'Die sieben Gruppen',
|
headline: 'Wo die Gruppen in der Arbeit vorkommen',
|
||||||
body: [
|
body: [
|
||||||
'Programmierung, Datenbanken, DevOps, Betriebssysteme, Infrastructure as Code, Hypervisor und Werkzeuge. Die Titel der Gruppen sind lokalisiert, die Einträge bleiben unverändert.',
|
'Produktarbeit in Java und Spring sowie in C# und .NET, mit Angular auf der Oberfläche. Datenarbeit in SQL Server und T-SQL. Container und Cluster mit Docker, Kubernetes und Azure AKS. Pipelines in GitLab CI/CD und Azure DevOps, GitOps mit Argo CD. Server und Virtualisierung vor Ort.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -135,9 +121,9 @@ export const ABOUT_DE: PageCopy = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'accent',
|
id: 'accent',
|
||||||
headline: 'Ein Satz zur Rösterei',
|
headline: 'Rösterei und Drehmoment',
|
||||||
body: [
|
body: [
|
||||||
'Die wirtschaftliche Beteiligung an der Rösterei Tangermünde steht im Lebenslauf. Mehr Biografie gehört nicht auf diese Seite.',
|
'Die Rösterei-Arbeit bleibt der persönliche Akzent: Rösten und Drehmoment statt Leerlauf. Antonio Ledebuhr hält eine wirtschaftliche Beteiligung an der Rösterei Tangermünde.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export const SERVICES_SOFTWARE_DE: ServicePageCopy = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
referenceCaseId: 'innofocus',
|
referenceCaseId: 'innofocus',
|
||||||
|
referenceHeadline: 'Passender öffentlicher Fall',
|
||||||
referenceNote:
|
referenceNote:
|
||||||
'Passender öffentlicher Fall: die ERP-Datenmigration für Innofocus / reifen.com, mit T-SQL, .NET, Angular und Keycloak.',
|
'Passender öffentlicher Fall: die ERP-Datenmigration für Innofocus / reifen.com, mit T-SQL, .NET, Angular und Keycloak.',
|
||||||
inquiry: {
|
inquiry: {
|
||||||
@@ -195,6 +196,7 @@ export const SERVICES_HARDWARE_DE: ServicePageCopy = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
referenceCaseId: 'roesterei',
|
referenceCaseId: 'roesterei',
|
||||||
|
referenceHeadline: 'Passender öffentlicher Fall',
|
||||||
referenceNote:
|
referenceNote:
|
||||||
'Passender öffentlicher Fall: die Gesamtverantwortung für die Technik der Rösterei Tangermünde, vom Netz bis zum Shop.',
|
'Passender öffentlicher Fall: die Gesamtverantwortung für die Technik der Rösterei Tangermünde, vom Netz bis zum Shop.',
|
||||||
inquiry: {
|
inquiry: {
|
||||||
@@ -275,6 +277,7 @@ export const SERVICES_CLUSTERS_DE: ServicePageCopy = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
referenceCaseId: 'hdi',
|
referenceCaseId: 'hdi',
|
||||||
|
referenceHeadline: 'Passender öffentlicher Fall',
|
||||||
referenceNote:
|
referenceNote:
|
||||||
'Passender öffentlicher Fall: die Migration der Umgebungen von HDI Specialty von Azure App Services auf Azure AKS.',
|
'Passender öffentlicher Fall: die Migration der Umgebungen von HDI Specialty von Azure App Services auf Azure AKS.',
|
||||||
inquiry: {
|
inquiry: {
|
||||||
@@ -310,7 +313,7 @@ export const SERVICES_AI_DE: ServicePageCopy = {
|
|||||||
id: 'offer-boundary',
|
id: 'offer-boundary',
|
||||||
headline: 'Was Angebot bleibt',
|
headline: 'Was Angebot bleibt',
|
||||||
body: [
|
body: [
|
||||||
'Prozessanalyse, externe Modelle und APIs, lokale Modelle, Abruf angereicherter Texte mit Datenschutzfokus, Mensch in der Schleife und Mehr-Agenten-Abläufe sind Angebote. Sie werden je Auftrag geschnitten und geprüft, nicht als allgemeine Kundenerfahrung dargestellt.',
|
'Prozessanalyse, externe Modelle und APIs, lokale Modelle, RAG (Abruf angereicherter Texte) mit Datenschutzfokus, Mensch in der Schleife und Mehr-Agenten-Abläufe sind Angebote. Sie werden je Auftrag geschnitten und geprüft, nicht als allgemeine Kundenerfahrung dargestellt.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -355,6 +358,7 @@ export const SERVICES_AI_DE: ServicePageCopy = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
referenceCaseId: 'roesterei',
|
referenceCaseId: 'roesterei',
|
||||||
|
referenceHeadline: 'Passender öffentlicher Fall',
|
||||||
referenceNote:
|
referenceNote:
|
||||||
'Passender öffentlicher Fall: die internen KI-Werkzeuge der Rösterei Tangermünde für Bewertungen und Shop-Verwaltung.',
|
'Passender öffentlicher Fall: die internen KI-Werkzeuge der Rösterei Tangermünde für Bewertungen und Shop-Verwaltung.',
|
||||||
inquiry: {
|
inquiry: {
|
||||||
@@ -389,7 +393,7 @@ export const SERVICES_AI_DE: ServicePageCopy = {
|
|||||||
{
|
{
|
||||||
id: 'external-models',
|
id: 'external-models',
|
||||||
title: 'Externe Modelle und APIs',
|
title: 'Externe Modelle und APIs',
|
||||||
body: 'Anbindung eines externen Modells oder einer API an einen bestehenden Ablauf. Ob das zum Auftrag passt, wird je Mandantin und Mandant geprüft und begrenzt.',
|
body: 'Anbindung eines externen Modells oder einer API an einen bestehenden Ablauf. Ob das zum Auftrag passt, wird je Auftrag geprüft und begrenzt.',
|
||||||
status: 'offer',
|
status: 'offer',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -400,8 +404,8 @@ export const SERVICES_AI_DE: ServicePageCopy = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'rag-privacy',
|
id: 'rag-privacy',
|
||||||
title: 'Textabruf mit Datenschutzgrenze',
|
title: 'RAG mit Datenschutzgrenze',
|
||||||
body: 'Wenn interne Texte ein Modell stützen sollen, wird der Abruf samt Datenschutzgrenze am konkreten Bestand entworfen. Der Zuschnitt gilt nur für diesen Auftrag und wird mit der Fachseite geprüft.',
|
body: 'Wenn interne Texte ein Modell stützen sollen, wird RAG — der Abruf angereicherter Texte — samt Datenschutzgrenze am konkreten Bestand entworfen. Der Zuschnitt gilt nur für diesen Auftrag und wird mit der Fachseite geprüft.',
|
||||||
status: 'offer',
|
status: 'offer',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const SITE_CONTENT_DE: SiteContent = {
|
|||||||
},
|
},
|
||||||
cases: CASES_DE,
|
cases: CASES_DE,
|
||||||
contact: CONTACT_DE,
|
contact: CONTACT_DE,
|
||||||
|
projects: PROJECTS_DE,
|
||||||
stack: STACK_DE,
|
stack: STACK_DE,
|
||||||
legal: {
|
legal: {
|
||||||
imprint: IMPRINT_DE,
|
imprint: IMPRINT_DE,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export const CASES_EN: Record<'innofocus' | 'roesterei' | 'myspa' | 'hdi', CaseS
|
|||||||
'Azure DevOps',
|
'Azure DevOps',
|
||||||
],
|
],
|
||||||
tags: ['eCommerce', 'ERP', 'data migration', 'SQL'],
|
tags: ['eCommerce', 'ERP', 'data migration', 'SQL'],
|
||||||
|
linkLabel: 'Read the case: Innofocus / reifen.com',
|
||||||
},
|
},
|
||||||
roesterei: {
|
roesterei: {
|
||||||
id: 'roesterei',
|
id: 'roesterei',
|
||||||
@@ -80,6 +81,7 @@ export const CASES_EN: Record<'innofocus' | 'roesterei' | 'myspa' | 'hdi', CaseS
|
|||||||
'Microsoft 365',
|
'Microsoft 365',
|
||||||
],
|
],
|
||||||
tags: ['eCommerce', 'internal IT', 'Shopify', 'AI tools'],
|
tags: ['eCommerce', 'internal IT', 'Shopify', 'AI tools'],
|
||||||
|
linkLabel: 'Read the case: Rösterei Tangermünde',
|
||||||
transparencyNote: 'Antonio Ledebuhr holds an economic stake in Rösterei Tangermünde.',
|
transparencyNote: 'Antonio Ledebuhr holds an economic stake in Rösterei Tangermünde.',
|
||||||
},
|
},
|
||||||
myspa: {
|
myspa: {
|
||||||
@@ -122,6 +124,7 @@ export const CASES_EN: Record<'innofocus' | 'roesterei' | 'myspa' | 'hdi', CaseS
|
|||||||
'GitLab EE',
|
'GitLab EE',
|
||||||
],
|
],
|
||||||
tags: ['IoT', 'backend', 'DevOps', 'Docker'],
|
tags: ['IoT', 'backend', 'DevOps', 'Docker'],
|
||||||
|
linkLabel: 'Read the case: Aracom IT Services / MySpa',
|
||||||
},
|
},
|
||||||
hdi: {
|
hdi: {
|
||||||
id: 'hdi',
|
id: 'hdi',
|
||||||
@@ -164,5 +167,6 @@ export const CASES_EN: Record<'innofocus' | 'roesterei' | 'myspa' | 'hdi', CaseS
|
|||||||
'Azure DevOps',
|
'Azure DevOps',
|
||||||
],
|
],
|
||||||
tags: ['insurance', 'fullstack', 'Kubernetes', 'Azure'],
|
tags: ['insurance', 'fullstack', 'Kubernetes', 'Azure'],
|
||||||
|
linkLabel: 'Read the case: HDI Specialty',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export const HOME_EN: HomePageCopy = {
|
|||||||
id: 'profile',
|
id: 'profile',
|
||||||
headline: 'Thirty seconds',
|
headline: 'Thirty seconds',
|
||||||
body: [
|
body: [
|
||||||
'Antonio Ledebuhr works as a fullstack and DevOps engineer from Tangermünde. The public site shows selected stations, not the full curriculum vitae.',
|
'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.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import {
|
|||||||
type ContactPageCopy,
|
type ContactPageCopy,
|
||||||
type LegalPageCopy,
|
type LegalPageCopy,
|
||||||
type PageCopy,
|
type PageCopy,
|
||||||
|
type ProjectsPageCopy,
|
||||||
type StackPageCopy,
|
type StackPageCopy,
|
||||||
} from '../content.contracts';
|
} from '../content.contracts';
|
||||||
|
|
||||||
export const PROJECTS_EN: PageCopy = {
|
export const PROJECTS_EN: ProjectsPageCopy = {
|
||||||
routeId: 'projects',
|
routeId: 'projects',
|
||||||
title: 'Projects | Antonio Ledebuhr',
|
title: 'Projects | Antonio Ledebuhr',
|
||||||
description:
|
description:
|
||||||
@@ -26,36 +27,23 @@ export const PROJECTS_EN: PageCopy = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'reading',
|
id: 'reading',
|
||||||
headline: 'How to read the cases',
|
headline: 'What each case documents',
|
||||||
body: [
|
body: [
|
||||||
'Each case names the client, role, period, situation, approach, outcome, stack and tags. Figures appear only where they are verified.',
|
'Each case documents the engagement, the role, the period, the situation, the approach and the outcome. Figures appear only where they are verified.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'situation',
|
|
||||||
headline: 'Situation',
|
|
||||||
body: ['The situation describes the starting point of the engagement.'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'approach',
|
|
||||||
headline: 'Approach',
|
|
||||||
body: ['The approach lists the concrete steps of the work.'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'outcome',
|
|
||||||
headline: 'Outcome',
|
|
||||||
body: ['The outcome names only results that are verified.'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'stack',
|
|
||||||
headline: 'Stack',
|
|
||||||
body: ['The stack list follows the individual engagement.'],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
ctas: [
|
ctas: [
|
||||||
{ label: 'Curriculum vitae as PDF', href: SITE_CONFIG.cvAssetPath },
|
{ label: 'Curriculum vitae as PDF', href: SITE_CONFIG.cvAssetPath },
|
||||||
{ label: 'Contact', routeId: 'contact' },
|
{ label: 'Contact', routeId: 'contact' },
|
||||||
],
|
],
|
||||||
|
caseLabels: {
|
||||||
|
situation: 'Situation',
|
||||||
|
approach: 'Approach',
|
||||||
|
outcome: 'Outcome',
|
||||||
|
stack: 'Stack',
|
||||||
|
tags: 'Tags',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const STACK_EN: StackPageCopy = {
|
export const STACK_EN: StackPageCopy = {
|
||||||
@@ -65,21 +53,20 @@ export const STACK_EN: StackPageCopy = {
|
|||||||
'The public stack in seven groups: programming, databases, DevOps, operating systems, infrastructure as code, hypervisors and tools.',
|
'The public stack in seven groups: programming, databases, DevOps, operating systems, infrastructure as code, hypervisors and tools.',
|
||||||
hero: {
|
hero: {
|
||||||
headline: 'The stack that appears on these pages',
|
headline: 'The stack that appears on these pages',
|
||||||
proof: 'The groups follow the public grid. No extra names are added here.',
|
proof:
|
||||||
|
'The groups belong to product work in Java and Spring and in C# and .NET, to SQL Server data work, to containers and clusters, and to servers on site.',
|
||||||
},
|
},
|
||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
id: 'reading',
|
id: 'reading',
|
||||||
headline: 'How to read the grid',
|
headline: 'A selection, not a ranking',
|
||||||
body: [
|
body: ['The overview is a selection from the public work, not a ranking.'],
|
||||||
'The tiles show the technologies listed in public on this site. They are not a ranking and not a claim of completeness.',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'groups',
|
id: 'groups',
|
||||||
headline: 'The seven groups',
|
headline: 'Where the groups show up in the work',
|
||||||
body: [
|
body: [
|
||||||
'Programming, databases, DevOps, operating systems, infrastructure as code, hypervisors and tools. Group titles are localised; the entries stay as they are.',
|
'Product work in Java and Spring and in C# and .NET, with Angular on the front end. SQL Server and T-SQL data work. Containers and clusters with Docker, Kubernetes and Azure AKS. Pipelines in GitLab CI/CD and Azure DevOps, GitOps with Argo CD. Servers and virtualisation on site.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -134,9 +121,9 @@ export const ABOUT_EN: PageCopy = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'accent',
|
id: 'accent',
|
||||||
headline: 'One line on the roastery',
|
headline: 'Roastery and torque',
|
||||||
body: [
|
body: [
|
||||||
'The economic stake in Rösterei Tangermünde is on the curriculum vitae. Further biography does not belong on this page.',
|
'The roastery work remains the personal accent: brewing and torque rather than idle. Antonio Ledebuhr holds an economic stake in Rösterei Tangermünde.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export const SERVICES_SOFTWARE_EN: ServicePageCopy = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
referenceCaseId: 'innofocus',
|
referenceCaseId: 'innofocus',
|
||||||
|
referenceHeadline: 'A fitting public case',
|
||||||
referenceNote:
|
referenceNote:
|
||||||
'Fitting public case: the ERP data migration for Innofocus / reifen.com, with T-SQL, .NET, Angular and Keycloak.',
|
'Fitting public case: the ERP data migration for Innofocus / reifen.com, with T-SQL, .NET, Angular and Keycloak.',
|
||||||
inquiry: {
|
inquiry: {
|
||||||
@@ -195,6 +196,7 @@ export const SERVICES_HARDWARE_EN: ServicePageCopy = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
referenceCaseId: 'roesterei',
|
referenceCaseId: 'roesterei',
|
||||||
|
referenceHeadline: 'A fitting public case',
|
||||||
referenceNote:
|
referenceNote:
|
||||||
'Fitting public case: full technical responsibility for Rösterei Tangermünde, from the network to the shop.',
|
'Fitting public case: full technical responsibility for Rösterei Tangermünde, from the network to the shop.',
|
||||||
inquiry: {
|
inquiry: {
|
||||||
@@ -275,6 +277,7 @@ export const SERVICES_CLUSTERS_EN: ServicePageCopy = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
referenceCaseId: 'hdi',
|
referenceCaseId: 'hdi',
|
||||||
|
referenceHeadline: 'A fitting public case',
|
||||||
referenceNote:
|
referenceNote:
|
||||||
'Fitting public case: the migration of the HDI Specialty environments from Azure App Services to Azure AKS.',
|
'Fitting public case: the migration of the HDI Specialty environments from Azure App Services to Azure AKS.',
|
||||||
inquiry: {
|
inquiry: {
|
||||||
@@ -355,6 +358,7 @@ export const SERVICES_AI_EN: ServicePageCopy = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
referenceCaseId: 'roesterei',
|
referenceCaseId: 'roesterei',
|
||||||
|
referenceHeadline: 'A fitting public case',
|
||||||
referenceNote:
|
referenceNote:
|
||||||
'Fitting public case: the in-house AI tools at Rösterei Tangermünde for reviews and shop management.',
|
'Fitting public case: the in-house AI tools at Rösterei Tangermünde for reviews and shop management.',
|
||||||
inquiry: {
|
inquiry: {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const SITE_CONTENT_EN: SiteContent = {
|
|||||||
},
|
},
|
||||||
cases: CASES_EN,
|
cases: CASES_EN,
|
||||||
contact: CONTACT_EN,
|
contact: CONTACT_EN,
|
||||||
|
projects: PROJECTS_EN,
|
||||||
stack: STACK_EN,
|
stack: STACK_EN,
|
||||||
legal: {
|
legal: {
|
||||||
imprint: IMPRINT_EN,
|
imprint: IMPRINT_EN,
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ export interface ShellCopy {
|
|||||||
readonly otherLocaleName: string;
|
readonly otherLocaleName: string;
|
||||||
readonly cvLabel: string;
|
readonly cvLabel: string;
|
||||||
readonly contactCta: string;
|
readonly contactCta: string;
|
||||||
readonly legalReviewNotice: string;
|
|
||||||
readonly notFoundHomeLabel: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SHELL_COPY: Record<AppLocale, ShellCopy> = {
|
export const SHELL_COPY: Record<AppLocale, ShellCopy> = {
|
||||||
@@ -25,9 +23,6 @@ export const SHELL_COPY: Record<AppLocale, ShellCopy> = {
|
|||||||
otherLocaleName: 'English',
|
otherLocaleName: 'English',
|
||||||
cvLabel: 'Lebenslauf als PDF',
|
cvLabel: 'Lebenslauf als PDF',
|
||||||
contactCta: 'Kontakt',
|
contactCta: 'Kontakt',
|
||||||
legalReviewNotice:
|
|
||||||
'Dieser Rechtstext ist ungeprüft und muss vor der Veröffentlichung vom Seitenbetreiber geprüft werden.',
|
|
||||||
notFoundHomeLabel: 'Zur Startseite',
|
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
skipLink: 'Skip to content',
|
skipLink: 'Skip to content',
|
||||||
@@ -39,8 +34,5 @@ export const SHELL_COPY: Record<AppLocale, ShellCopy> = {
|
|||||||
otherLocaleName: 'Deutsch',
|
otherLocaleName: 'Deutsch',
|
||||||
cvLabel: 'Curriculum vitae as PDF',
|
cvLabel: 'Curriculum vitae as PDF',
|
||||||
contactCta: 'Contact',
|
contactCta: 'Contact',
|
||||||
legalReviewNotice:
|
|
||||||
'This legal text is unreviewed and must be checked by the site owner before publication.',
|
|
||||||
notFoundHomeLabel: 'Back to home',
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
@if (page(); as copy) {
|
@if (page(); as copy) {
|
||||||
<div class="content-container stack page">
|
<div class="content-container stack page">
|
||||||
<app-page-hero [hero]="copy.hero" [ctas]="copy.ctas" />
|
<app-page-hero [hero]="copy.hero" [ctas]="copy.ctas" />
|
||||||
@for (section of introSections(); track section.id) {
|
@for (section of copy.sections; track section.id) {
|
||||||
<app-content-section [section]="section" />
|
<app-content-section [section]="section" />
|
||||||
}
|
}
|
||||||
@for (caseStudy of cases(); track caseStudy.id) {
|
@for (caseStudy of cases(); track caseStudy.id) {
|
||||||
<app-case-study
|
<app-case-study
|
||||||
[caseStudy]="caseStudy"
|
[caseStudy]="caseStudy"
|
||||||
[situationLabel]="caseLabels().situation"
|
[situationLabel]="copy.caseLabels.situation"
|
||||||
[approachLabel]="caseLabels().approach"
|
[approachLabel]="copy.caseLabels.approach"
|
||||||
[outcomeLabel]="caseLabels().outcome"
|
[outcomeLabel]="copy.caseLabels.outcome"
|
||||||
[stackLabel]="caseLabels().stack"
|
[stackLabel]="copy.caseLabels.stack"
|
||||||
|
[tagsLabel]="copy.caseLabels.tags"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||||
import { ContentService } from '../../core/content/content.service';
|
import { ContentService } from '../../core/content/content.service';
|
||||||
import { CaseStudy } from '../../shared/case-study/case-study';
|
import { CaseStudy } from '../../shared/case-study/case-study';
|
||||||
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';
|
||||||
|
|
||||||
const INTRO_SECTION_IDS = new Set(['selection', 'reading']);
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-projects-page',
|
selector: 'app-projects-page',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
@@ -16,20 +14,6 @@ const INTRO_SECTION_IDS = new Set(['selection', 'reading']);
|
|||||||
export class ProjectsPage {
|
export class ProjectsPage {
|
||||||
private readonly content = inject(ContentService);
|
private readonly content = inject(ContentService);
|
||||||
|
|
||||||
protected readonly page = this.content.page('projects');
|
protected readonly page = this.content.projects();
|
||||||
protected readonly cases = this.content.cases();
|
protected readonly cases = this.content.cases();
|
||||||
protected readonly introSections = computed(() =>
|
|
||||||
this.page().sections.filter((section) => INTRO_SECTION_IDS.has(section.id)),
|
|
||||||
);
|
|
||||||
protected readonly caseLabels = computed(() => {
|
|
||||||
const headlines = new Map(
|
|
||||||
this.page().sections.map((section) => [section.id, section.headline] as const),
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
situation: headlines.get('situation') ?? '',
|
|
||||||
approach: headlines.get('approach') ?? '',
|
|
||||||
outcome: headlines.get('outcome') ?? '',
|
|
||||||
stack: headlines.get('stack') ?? '',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
<section class="stack" [attr.aria-labelledby]="'reference-' + copy.routeId">
|
<section class="stack" [attr.aria-labelledby]="'reference-' + copy.routeId">
|
||||||
<h2 [id]="'reference-' + copy.routeId">{{ copy.sequence.referenceNote }}</h2>
|
<h2 [id]="'reference-' + copy.routeId">{{ copy.sequence.referenceHeadline }}</h2>
|
||||||
|
<p>{{ copy.sequence.referenceNote }}</p>
|
||||||
<app-case-card [caseStudy]="referenceCase()" />
|
<app-case-card [caseStudy]="referenceCase()" />
|
||||||
</section>
|
</section>
|
||||||
<app-content-section [section]="copy.sequence.inquiry" />
|
<app-content-section [section]="copy.sequence.inquiry" />
|
||||||
|
|||||||
@@ -10,5 +10,5 @@
|
|||||||
<li>{{ tag }}</li>
|
<li>{{ tag }}</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
<a [routerLink]="projectsLink()" [fragment]="caseStudy().id">{{ caseStudy().client }}</a>
|
<a [routerLink]="projectsLink()" [fragment]="caseStudy().id">{{ caseStudy().linkLabel }}</a>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -46,6 +46,10 @@
|
|||||||
<li>{{ item }}</li>
|
<li>{{ item }}</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="stack" [attr.aria-labelledby]="caseStudy().id + '-tags'">
|
||||||
|
<h3 [id]="caseStudy().id + '-tags'">{{ tagsLabel() }}</h3>
|
||||||
<ul class="cluster tags">
|
<ul class="cluster tags">
|
||||||
@for (tag of caseStudy().tags; track $index) {
|
@for (tag of caseStudy().tags; track $index) {
|
||||||
<li>{{ tag }}</li>
|
<li>{{ tag }}</li>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export class CaseStudy {
|
|||||||
readonly approachLabel = input.required<string>();
|
readonly approachLabel = input.required<string>();
|
||||||
readonly outcomeLabel = input.required<string>();
|
readonly outcomeLabel = input.required<string>();
|
||||||
readonly stackLabel = input.required<string>();
|
readonly stackLabel = input.required<string>();
|
||||||
|
readonly tagsLabel = input.required<string>();
|
||||||
|
|
||||||
protected readonly headingId = computed(() => `case-heading-${this.caseStudy().id}`);
|
protected readonly headingId = computed(() => `case-heading-${this.caseStudy().id}`);
|
||||||
protected readonly metricsLabelId = computed(() => `case-metrics-${this.caseStudy().id}`);
|
protected readonly metricsLabelId = computed(() => `case-metrics-${this.caseStudy().id}`);
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
<label [attr.for]="controlId(field)">
|
<label [attr.for]="controlId(field)">
|
||||||
{{ field.label }}
|
{{ field.label }}
|
||||||
@if (field.required) {
|
@if (field.required) {
|
||||||
<span class="required" [attr.aria-label]="copy().requiredMarkerLabel">*</span>
|
<span class="required" aria-hidden="true">*</span>
|
||||||
|
<span class="visually-hidden">{{ copy().requiredMarkerLabel }}</span>
|
||||||
}
|
}
|
||||||
</label>
|
</label>
|
||||||
@if (field.hint) {
|
@if (field.hint) {
|
||||||
@@ -52,9 +53,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (incompleteMessage(); as message) {
|
<p class="incomplete" aria-live="polite">{{ incompleteMessage() }}</p>
|
||||||
<p class="incomplete" aria-live="polite">{{ message }}</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
<p class="note">{{ copy().noBackendNote }}</p>
|
<p class="note">{{ copy().noBackendNote }}</p>
|
||||||
|
|
||||||
|
|||||||
@@ -38,19 +38,20 @@ describe('ContactBriefing', () => {
|
|||||||
|
|
||||||
expect(root.querySelector('form[action]')).toBeNull();
|
expect(root.querySelector('form[action]')).toBeNull();
|
||||||
expect(root.textContent).toContain(copy.incompleteHint);
|
expect(root.textContent).toContain(copy.incompleteHint);
|
||||||
|
expect(root.querySelector('[aria-live="polite"]')).toBeTruthy();
|
||||||
expect(root.querySelector(`a[href="${SITE_CONFIG.calendarUrl}"]`)).toBeNull();
|
expect(root.querySelector(`a[href="${SITE_CONFIG.calendarUrl}"]`)).toBeNull();
|
||||||
expect(root.textContent).not.toContain(copy.calendarLabel);
|
expect(root.textContent).not.toContain(copy.calendarLabel);
|
||||||
|
|
||||||
const special = 'Äpfel & Birnen?\nPreis=100';
|
const special = 'Äpfel & Birnen?\nPreis=100';
|
||||||
setControl(root, 'contact-name', special);
|
setControl(root, 'contact-name', special);
|
||||||
setControl(root, 'contact-email', 'team@example.com');
|
setControl(root, 'contact-email', 'team@example.com');
|
||||||
setControl(root, 'contact-company', 'Rösterei & Co');
|
|
||||||
setControl(root, 'contact-projectType', 'software');
|
setControl(root, 'contact-projectType', 'software');
|
||||||
setControl(root, 'contact-situation', special);
|
setControl(root, 'contact-situation', special);
|
||||||
setControl(root, 'contact-timeframe', 'Q3');
|
setControl(root, 'contact-timeframe', 'Q3');
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(root.textContent).not.toContain(copy.incompleteHint);
|
expect(root.textContent).not.toContain(copy.incompleteHint);
|
||||||
|
expect(root.querySelector('[aria-live="polite"]')).toBeTruthy();
|
||||||
|
|
||||||
const submit = root.querySelector<HTMLAnchorElement>('a.submit');
|
const submit = root.querySelector<HTMLAnchorElement>('a.submit');
|
||||||
expect(submit?.getAttribute('href')?.startsWith('mailto:info@antoniolede.de?subject=')).toBe(
|
expect(submit?.getAttribute('href')?.startsWith('mailto:info@antoniolede.de?subject=')).toBe(
|
||||||
@@ -70,7 +71,15 @@ describe('ContactBriefing', () => {
|
|||||||
const decodedBody = decodeURIComponent(bodyPart);
|
const decodedBody = decodeURIComponent(bodyPart);
|
||||||
expect(decodedBody).toContain(special);
|
expect(decodedBody).toContain(special);
|
||||||
expect(decodedBody).toContain('team@example.com');
|
expect(decodedBody).toContain('team@example.com');
|
||||||
expect(decodedBody).toContain('Rösterei & Co');
|
const projectType = copy.fields.find((field) => field.id === 'projectType');
|
||||||
|
const projectTypeLabel = projectType?.options?.find(
|
||||||
|
(option) => option.value === 'software',
|
||||||
|
)?.label;
|
||||||
|
expect(projectTypeLabel).toBeTruthy();
|
||||||
|
expect(decodedBody).toContain(`${projectType!.label}: ${projectTypeLabel}`);
|
||||||
|
const company = copy.fields.find((field) => field.id === 'company');
|
||||||
|
expect(company).toBeTruthy();
|
||||||
|
expect(decodedBody).not.toContain(`${company!.label}:`);
|
||||||
expect(decodeURIComponent(subjectPart.slice('subject='.length))).toBe(copy.mailSubject);
|
expect(decodeURIComponent(subjectPart.slice('subject='.length))).toBe(copy.mailSubject);
|
||||||
|
|
||||||
expect(fetchSpy).not.toHaveBeenCalled();
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -67,7 +67,18 @@ export class ContactBriefing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private assembleBody(copy: ContactPageCopy, values: Record<ContactFieldId, string>): string {
|
private assembleBody(copy: ContactPageCopy, values: Record<ContactFieldId, string>): string {
|
||||||
const lines = copy.fields.map((field) => `${field.label}: ${values[field.id]}`);
|
const lines = copy.fields.flatMap((field) => {
|
||||||
|
const raw = values[field.id];
|
||||||
|
if (!field.required && raw.trim().length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [`${field.label}: ${this.displayValue(field, raw)}`];
|
||||||
|
});
|
||||||
return [copy.mailIntro, '', ...lines, '', copy.mailSignature].join('\n');
|
return [copy.mailIntro, '', ...lines, '', copy.mailSignature].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private displayValue(field: ContactFieldCopy, value: string): string {
|
||||||
|
return field.options?.find((option) => option.value === value)?.label ?? value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user