Collapse the four service routes into one anchored page.

Legacy detail URLs now 308 to locale-neutral fragments, and crawl plus JSON-LD follow the 18-path set.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-27 00:41:16 +02:00
parent b95dbd8e1a
commit 2635d0c639
29 changed files with 343 additions and 382 deletions

View File

@@ -1,6 +1,6 @@
# Antonio Ledebuhr # Antonio Ledebuhr
Fullstack and DevOps engineer in Tangermünde. The public site documents a curated set of stations, four service areas, and a local mailto briefing. German is the default language; English is the full second version. Fullstack and DevOps engineer in Tangermünde. The public site documents six public cases, one services page with four anchored areas, and a briefing form that only opens a local mail client. German is the default language; English is the full second version.
## Profile ## Profile
@@ -15,14 +15,6 @@ Fullstack and DevOps engineer in Tangermünde. The public site documents a curat
- https://antoniolede.de/leistungen - https://antoniolede.de/leistungen
- https://antoniolede.de/en/services - https://antoniolede.de/en/services
- https://antoniolede.de/leistungen/software
- https://antoniolede.de/en/services/software
- https://antoniolede.de/leistungen/hardware-netzwerk
- https://antoniolede.de/en/services/hardware-network
- https://antoniolede.de/leistungen/cluster
- https://antoniolede.de/en/services/clusters
- https://antoniolede.de/leistungen/ai-integration
- https://antoniolede.de/en/services/ai-integration
## Projects ## Projects
@@ -32,10 +24,14 @@ Fullstack and DevOps engineer in Tangermünde. The public site documents a curat
- https://antoniolede.de/en/projects#innofocus - https://antoniolede.de/en/projects#innofocus
- https://antoniolede.de/projekte#roesterei - https://antoniolede.de/projekte#roesterei
- https://antoniolede.de/en/projects#roesterei - https://antoniolede.de/en/projects#roesterei
- https://antoniolede.de/projekte#myspa - https://antoniolede.de/projekte#bannier
- https://antoniolede.de/en/projects#myspa - https://antoniolede.de/en/projects#bannier
- https://antoniolede.de/projekte#elbwaerme
- https://antoniolede.de/en/projects#elbwaerme
- https://antoniolede.de/projekte#hdi - https://antoniolede.de/projekte#hdi
- https://antoniolede.de/en/projects#hdi - https://antoniolede.de/en/projects#hdi
- https://antoniolede.de/projekte#devdays
- https://antoniolede.de/en/projects#devdays
## Contact, stack and legal ## Contact, stack and legal

View File

@@ -18,30 +18,6 @@
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services" /> <xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen" /> <xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen" />
</url> </url>
<url>
<loc>https://antoniolede.de/leistungen/software</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen/software" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services/software" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen/software" />
</url>
<url>
<loc>https://antoniolede.de/leistungen/hardware-netzwerk</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen/hardware-netzwerk" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services/hardware-network" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen/hardware-netzwerk" />
</url>
<url>
<loc>https://antoniolede.de/leistungen/cluster</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen/cluster" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services/clusters" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen/cluster" />
</url>
<url>
<loc>https://antoniolede.de/leistungen/ai-integration</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen/ai-integration" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services/ai-integration" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen/ai-integration" />
</url>
<url> <url>
<loc>https://antoniolede.de/projekte</loc> <loc>https://antoniolede.de/projekte</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/projekte" /> <xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/projekte" />
@@ -96,30 +72,6 @@
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services" /> <xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen" /> <xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen" />
</url> </url>
<url>
<loc>https://antoniolede.de/en/services/software</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen/software" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services/software" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen/software" />
</url>
<url>
<loc>https://antoniolede.de/en/services/hardware-network</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen/hardware-netzwerk" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services/hardware-network" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen/hardware-netzwerk" />
</url>
<url>
<loc>https://antoniolede.de/en/services/clusters</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen/cluster" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services/clusters" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen/cluster" />
</url>
<url>
<loc>https://antoniolede.de/en/services/ai-integration</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen/ai-integration" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services/ai-integration" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen/ai-integration" />
</url>
<url> <url>
<loc>https://antoniolede.de/en/projects</loc> <loc>https://antoniolede.de/en/projects</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/projekte" /> <xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/projekte" />

View File

@@ -13,18 +13,6 @@ const PAGE_LOADERS: Record<RouteId, LazyPage> = {
home: () => import('./features/home/home').then((module) => module.HomePage), home: () => import('./features/home/home').then((module) => module.HomePage),
pitch: () => import('./features/pitch/pitch').then((module) => module.PitchPage), pitch: () => import('./features/pitch/pitch').then((module) => module.PitchPage),
services: () => import('./features/services/services').then((module) => module.ServicesPage), services: () => import('./features/services/services').then((module) => module.ServicesPage),
servicesSoftware: () =>
import('./features/services/software/software').then((module) => module.ServicesSoftwarePage),
servicesHardwareNetwork: () =>
import('./features/services/hardware-network/hardware-network').then(
(module) => module.ServicesHardwareNetworkPage,
),
servicesClusters: () =>
import('./features/services/clusters/clusters').then((module) => module.ServicesClustersPage),
servicesAi: () =>
import('./features/services/ai-integration/ai-integration').then(
(module) => module.ServicesAiPage,
),
projects: () => import('./features/projects/projects').then((module) => module.ProjectsPage), projects: () => import('./features/projects/projects').then((module) => module.ProjectsPage),
stack: () => import('./features/stack/stack').then((module) => module.StackPage), stack: () => import('./features/stack/stack').then((module) => module.StackPage),
about: () => import('./features/about/about').then((module) => module.AboutPage), about: () => import('./features/about/about').then((module) => module.AboutPage),

View File

@@ -14,7 +14,6 @@ export interface ResolvedNavItem {
readonly routeId: RouteId; readonly routeId: RouteId;
readonly label: string; readonly label: string;
readonly link: unknown[]; readonly link: unknown[];
readonly children?: readonly ResolvedNavItem[];
} }
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -56,7 +55,6 @@ export class NavigationService {
routeId: item.routeId, routeId: item.routeId,
label: item.label[locale], label: item.label[locale],
link: routeCommands(item.routeId, locale), link: routeCommands(item.routeId, locale),
children: item.children ? this.resolveItems(item.children, locale) : undefined,
})); }));
} }

View File

@@ -4,7 +4,6 @@ import { type RouteId } from '../routing/route-ids';
export interface NavItem { export interface NavItem {
readonly routeId: RouteId; readonly routeId: RouteId;
readonly label: Record<AppLocale, string>; readonly label: Record<AppLocale, string>;
readonly children?: readonly NavItem[];
} }
export const PRIMARY_NAV: readonly NavItem[] = [ export const PRIMARY_NAV: readonly NavItem[] = [
@@ -15,24 +14,6 @@ export const PRIMARY_NAV: readonly NavItem[] = [
{ {
routeId: 'services', routeId: 'services',
label: { de: 'Leistungen', en: 'Services' }, label: { de: 'Leistungen', en: 'Services' },
children: [
{
routeId: 'servicesSoftware',
label: { de: 'Software', en: 'Software' },
},
{
routeId: 'servicesHardwareNetwork',
label: { de: 'Hardware und Netzwerk', en: 'Hardware and network' },
},
{
routeId: 'servicesClusters',
label: { de: 'Cluster', en: 'Clusters' },
},
{
routeId: 'servicesAi',
label: { de: 'KI-Integration', en: 'AI integration' },
},
],
}, },
{ {
routeId: 'projects', routeId: 'projects',

View File

@@ -2,10 +2,6 @@ export type RouteId =
| 'home' | 'home'
| 'pitch' | 'pitch'
| 'services' | 'services'
| 'servicesSoftware'
| 'servicesHardwareNetwork'
| 'servicesClusters'
| 'servicesAi'
| 'projects' | 'projects'
| 'stack' | 'stack'
| 'about' | 'about'
@@ -18,10 +14,6 @@ export const ROUTE_IDS: readonly RouteId[] = [
'home', 'home',
'pitch', 'pitch',
'services', 'services',
'servicesSoftware',
'servicesHardwareNetwork',
'servicesClusters',
'servicesAi',
'projects', 'projects',
'stack', 'stack',
'about', 'about',

View File

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

View File

@@ -11,10 +11,6 @@ export const ROUTE_SEGMENTS: Record<AppLocale, Record<RouteId, string>> = {
home: '', home: '',
pitch: 'pitch', pitch: 'pitch',
services: 'leistungen', services: 'leistungen',
servicesSoftware: 'leistungen/software',
servicesHardwareNetwork: 'leistungen/hardware-netzwerk',
servicesClusters: 'leistungen/cluster',
servicesAi: 'leistungen/ai-integration',
projects: 'projekte', projects: 'projekte',
stack: 'stack', stack: 'stack',
about: 'ueber-mich', about: 'ueber-mich',
@@ -27,10 +23,6 @@ export const ROUTE_SEGMENTS: Record<AppLocale, Record<RouteId, string>> = {
home: '', home: '',
pitch: 'pitch', pitch: 'pitch',
services: 'services', services: 'services',
servicesSoftware: 'services/software',
servicesHardwareNetwork: 'services/hardware-network',
servicesClusters: 'services/clusters',
servicesAi: 'services/ai-integration',
projects: 'projects', projects: 'projects',
stack: 'stack', stack: 'stack',
about: 'about', about: 'about',

View File

@@ -6,13 +6,7 @@ import { canonicalUrl } from './route-metadata';
const SCHEMA_CONTEXT = 'https://schema.org'; const SCHEMA_CONTEXT = 'https://schema.org';
const SERVICE_ROUTE_IDS: readonly RouteId[] = [ const SERVICE_ROUTE_IDS: readonly RouteId[] = ['services'];
'services',
'servicesSoftware',
'servicesHardwareNetwork',
'servicesClusters',
'servicesAi',
];
export type JsonLdValue = export type JsonLdValue =
| string | string
@@ -56,23 +50,23 @@ function professionalServiceNode(
}; };
} }
function serviceNode( function serviceNodes(
routeId: RouteId,
locale: AppLocale, locale: AppLocale,
content: Record<AppLocale, SiteContent>, content: Record<AppLocale, SiteContent>,
): JsonLdValue { ): readonly JsonLdValue[] {
const page = content[locale].pages[routeId]; const page = content[locale].services;
const servicesUrl = canonicalUrl('services', locale);
return { return page.serviceSections.map((section) => ({
'@type': 'Service', '@type': 'Service',
name: page.hero.headline, name: section.title,
description: page.description, description: section.lead,
url: canonicalUrl(routeId, locale), url: `${servicesUrl}#${section.id}`,
provider: { provider: {
'@type': 'Person', '@type': 'Person',
name: SITE_CONFIG.personName, name: SITE_CONFIG.personName,
}, },
}; }));
} }
function projectWorks( function projectWorks(
@@ -128,7 +122,7 @@ export function buildJsonLdGraph(
if (routeId === 'home') { if (routeId === 'home') {
graph = [personNode(locale, content), professionalServiceNode(locale, content)]; graph = [personNode(locale, content), professionalServiceNode(locale, content)];
} else if (SERVICE_ROUTE_IDS.includes(routeId)) { } else if (SERVICE_ROUTE_IDS.includes(routeId)) {
graph = [serviceNode(routeId, locale, content)]; graph = serviceNodes(locale, content);
} else if (routeId === 'projects') { } else if (routeId === 'projects') {
graph = projectWorks(locale, content); graph = projectWorks(locale, content);
} else { } else {

View File

@@ -3,15 +3,16 @@
<app-page-hero [hero]="copy.hero" [ctas]="copy.ctas" /> <app-page-hero [hero]="copy.hero" [ctas]="copy.ctas" />
@for (section of copy.sections; track section.id) { @for (section of copy.sections; track section.id) {
<app-content-section [section]="section" /> <app-content-section [section]="section" />
@if (section.id === 'scope') { @if (section.id === 'areas') {
<section class="stack" [attr.aria-labelledby]="'home-service-areas'"> <section class="stack" [attr.aria-labelledby]="'home-service-areas'">
<h2 id="home-service-areas">{{ copy.serviceAreasHeading }}</h2> <h2 id="home-service-areas">{{ copy.serviceAreasHeading }}</h2>
<ul class="home-service-areas"> <ul class="home-service-areas">
@for (area of copy.serviceAreas; track area.id) { @for (area of copy.serviceAreas; track area.id) {
<li> <li>
<a <a
class="home-service-card glass-surface stack" class="home-service-card interactive-lift stack"
[routerLink]="areaLink(area.routeId)" [routerLink]="areaLink(area)"
[fragment]="area.fragment"
> >
<h3>{{ area.title }}</h3> <h3>{{ area.title }}</h3>
<p>{{ area.body }}</p> <p>{{ area.body }}</p>

View File

@@ -1,7 +1,7 @@
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { ContentService } from '../../core/content/content.service'; import { ContentService } from '../../core/content/content.service';
import { type RouteId } from '../../core/routing/route-ids'; import { type HomeServiceAreaCopy } from '../../core/content/content.contracts';
import { NavigationService } from '../../core/navigation/navigation.service'; import { NavigationService } from '../../core/navigation/navigation.service';
import { CaseCard } from '../../shared/case-card/case-card'; import { CaseCard } from '../../shared/case-card/case-card';
import { ContentSection } from '../../shared/content-section/content-section'; import { ContentSection } from '../../shared/content-section/content-section';
@@ -28,7 +28,7 @@ export class HomePage {
return ids.map((id) => this.content.caseStudy(id)()); return ids.map((id) => this.content.caseStudy(id)());
}); });
protected areaLink(routeId: RouteId): unknown[] { protected areaLink(area: HomeServiceAreaCopy): unknown[] {
return this.navigation.link(routeId); return this.navigation.link(area.routeId);
} }
} }

View File

@@ -1 +0,0 @@
<app-service-page-view routeId="servicesAi" />

View File

@@ -1,10 +0,0 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ServicePageView } from '../service-page-view/service-page-view';
@Component({
selector: 'app-services-ai-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ServicePageView],
templateUrl: './ai-integration.html',
})
export class ServicesAiPage {}

View File

@@ -1 +0,0 @@
<app-service-page-view routeId="servicesClusters" />

View File

@@ -1,10 +0,0 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ServicePageView } from '../service-page-view/service-page-view';
@Component({
selector: 'app-services-clusters-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ServicePageView],
templateUrl: './clusters.html',
})
export class ServicesClustersPage {}

View File

@@ -1 +0,0 @@
<app-service-page-view routeId="servicesHardwareNetwork" />

View File

@@ -1,10 +0,0 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ServicePageView } from '../service-page-view/service-page-view';
@Component({
selector: 'app-services-hardware-network-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ServicePageView],
templateUrl: './hardware-network.html',
})
export class ServicesHardwareNetworkPage {}

View File

@@ -0,0 +1,59 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
import { routes } from '../../app.routes';
import { SITE_CONTENT } from '../../core/content/content.token';
import { SITE_CONTENT_DATA } from '../../core/content/site-content';
import { APP_LOCALES } from '../../core/i18n/locale';
import { routePath } from '../../core/routing/route-paths';
describe('service offering groups', () => {
it('renders delivered work and offers under their own localized labels', async () => {
TestBed.configureTestingModule({
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
});
const harness = await RouterTestingHarness.create();
for (const locale of APP_LOCALES) {
const page = SITE_CONTENT_DATA[locale].services;
await harness.navigateByUrl(routePath('services', locale));
const root = harness.routeNativeElement as HTMLElement;
expect(root).toBeTruthy();
for (const section of page.serviceSections) {
const delivered = section.offerings.filter((o) => o.status === 'delivered');
const offers = section.offerings.filter((o) => o.status === 'offer');
expect(delivered.length, `${locale}.${section.id} delivered`).toBeGreaterThan(0);
expect(offers.length, `${locale}.${section.id} offers`).toBeGreaterThan(0);
const deliveredGroup = root.querySelector(
`[aria-labelledby="offerings-delivered-${section.id}"]`,
);
const offerGroup = root.querySelector(`[aria-labelledby="offerings-offer-${section.id}"]`);
expect(deliveredGroup, `${locale}.${section.id}`).toBeTruthy();
expect(offerGroup, `${locale}.${section.id}`).toBeTruthy();
expect(deliveredGroup?.textContent).toContain(page.deliveredOfferingsHeading);
expect(offerGroup?.textContent).toContain(page.offerOfferingsHeading);
for (const offering of delivered) {
expect(deliveredGroup?.textContent).toContain(offering.title);
expect(offerGroup?.textContent).not.toContain(offering.title);
const note = page.offeringCaseBackedLabel.replace(
'{case}',
SITE_CONTENT_DATA[locale].cases[offering.referenceCaseId!].client,
);
expect(deliveredGroup?.textContent).toContain(note);
}
for (const offering of offers) {
expect(offerGroup?.textContent).toContain(offering.title);
expect(deliveredGroup?.textContent).not.toContain(offering.title);
}
expect(deliveredGroup?.querySelector('[data-offering-status="offer"]')).toBeNull();
expect(offerGroup?.querySelector('[data-offering-status="delivered"]')).toBeNull();
}
}
});
});

View File

@@ -1,57 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
import { routes } from '../../../app.routes';
import { SITE_CONTENT } from '../../../core/content/content.token';
import { SITE_CONTENT_DATA } from '../../../core/content/site-content';
import { APP_LOCALES } from '../../../core/i18n/locale';
import { routePath } from '../../../core/routing/route-paths';
describe('AI service offering groups', () => {
it('renders delivered work and offers under their own localized labels', async () => {
TestBed.configureTestingModule({
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
});
const harness = await RouterTestingHarness.create();
for (const locale of APP_LOCALES) {
const copy = SITE_CONTENT_DATA[locale].services.servicesAi;
const delivered = copy.offerings.filter((offering) => offering.status === 'delivered');
const offers = copy.offerings.filter((offering) => offering.status === 'offer');
expect(delivered.length).toBeGreaterThan(0);
expect(offers.length).toBeGreaterThan(0);
await harness.navigateByUrl(routePath('servicesAi', locale));
const root = harness.routeNativeElement as HTMLElement;
expect(root).toBeTruthy();
const deliveredGroup = root.querySelector(
`[aria-labelledby="offerings-delivered-servicesAi"]`,
);
const offerGroup = root.querySelector(`[aria-labelledby="offerings-offer-servicesAi"]`);
expect(deliveredGroup).toBeTruthy();
expect(offerGroup).toBeTruthy();
expect(deliveredGroup?.textContent).toContain(copy.deliveredOfferingsHeading);
expect(offerGroup?.textContent).toContain(copy.offerOfferingsHeading);
for (const offering of delivered) {
expect(deliveredGroup?.textContent).toContain(offering.title);
expect(offerGroup?.textContent).not.toContain(offering.title);
const note = copy.offeringCaseBackedLabel.replace(
'{case}',
SITE_CONTENT_DATA[locale].cases[offering.referenceCaseId!].client,
);
expect(deliveredGroup?.textContent).toContain(note);
}
for (const offering of offers) {
expect(offerGroup?.textContent).toContain(offering.title);
expect(deliveredGroup?.textContent).not.toContain(offering.title);
}
expect(deliveredGroup?.querySelector('[data-offering-status="offer"]')).toBeNull();
expect(offerGroup?.querySelector('[data-offering-status="delivered"]')).toBeNull();
}
});
});

View File

@@ -1,47 +0,0 @@
@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 === 'sequence') {
<app-process-steps [steps]="steps()" [labelledBy]="sequenceHeadingId()" />
}
@if (section.id === 'scope' && deliveredOfferings().length > 0) {
<section class="stack offerings-group" [attr.aria-labelledby]="deliveredHeadingId()">
<h3 [id]="deliveredHeadingId()">{{ copy.deliveredOfferingsHeading }}</h3>
<ul class="stack offerings">
@for (offering of deliveredOfferings(); track offering.id) {
<li class="glass-surface" [attr.data-offering-status]="offering.status">
<h4>{{ offering.title }}</h4>
<p>{{ offering.body }}</p>
@if (caseBackedNote(offering); as note) {
<p class="case-backed">{{ note }}</p>
}
</li>
}
</ul>
</section>
}
@if (section.id === 'offer-boundary' && offerOfferings().length > 0) {
<section class="stack offerings-group" [attr.aria-labelledby]="offerHeadingId()">
<h3 [id]="offerHeadingId()">{{ copy.offerOfferingsHeading }}</h3>
<ul class="stack offerings">
@for (offering of offerOfferings(); track offering.id) {
<li class="glass-surface" [attr.data-offering-status]="offering.status">
<h4>{{ offering.title }}</h4>
<p>{{ offering.body }}</p>
</li>
}
</ul>
</section>
}
}
<section class="stack" [attr.aria-labelledby]="'reference-' + copy.routeId">
<h2 [id]="'reference-' + copy.routeId">{{ copy.sequence.referenceHeadline }}</h2>
<p>{{ copy.sequence.referenceNote }}</p>
<app-case-card [caseStudy]="referenceCase()" />
</section>
<app-content-section [section]="copy.sequence.inquiry" />
<app-cta-row [ctas]="copy.ctas" />
</div>
}

View File

@@ -1,32 +0,0 @@
@use '../../../shared/page-shell';
.offerings {
list-style: none;
margin: 0;
padding: 0;
gap: var(--space-4);
}
.offerings li {
padding: var(--space-5);
border-radius: var(--radius-md);
}
.offerings-group h3,
.offerings h4 {
margin: 0 0 var(--space-2);
font-size: var(--text-lg);
}
.offerings h4 {
font-size: var(--text-md);
}
.offerings p {
margin: 0;
color: var(--color-text-muted);
}
.offerings .case-backed {
color: var(--color-text);
}

View File

@@ -1,66 +0,0 @@
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
import {
type OfferingCopy,
type ProcessStepCopy,
type ServicePageId,
type ServiceSequenceCopy,
} from '../../../core/content/content.contracts';
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 { PageHero } from '../../../shared/page-hero/page-hero';
import { ProcessSteps } from '../../../shared/process-steps/process-steps';
function sequenceSteps(sequence: ServiceSequenceCopy): readonly ProcessStepCopy[] {
return [sequence.situation, sequence.diagnosis, sequence.implementation, sequence.operation].map(
(section) => ({
id: section.id,
title: section.headline,
body: (section.body ?? []).join(' '),
}),
);
}
@Component({
selector: 'app-service-page-view',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PageHero, ContentSection, ProcessSteps, CaseCard, CtaRow],
templateUrl: './service-page-view.html',
styleUrl: './service-page-view.scss',
})
export class ServicePageView {
readonly routeId = input.required<ServicePageId>();
private readonly content = inject(ContentService);
protected readonly page = computed(() => this.content.service(this.routeId())());
protected readonly steps = computed(() => sequenceSteps(this.page().sequence));
protected readonly referenceCase = computed(() =>
this.content.caseStudy(this.page().sequence.referenceCaseId)(),
);
protected readonly sequenceHeadingId = computed(() => {
const section = this.page().sections.find((item) => item.id === 'sequence');
return section ? `section-${section.id}` : null;
});
protected readonly deliveredOfferings = computed(() =>
this.page().offerings.filter((offering) => offering.status === 'delivered'),
);
protected readonly offerOfferings = computed(() =>
this.page().offerings.filter((offering) => offering.status === 'offer'),
);
protected readonly deliveredHeadingId = computed(() => `offerings-delivered-${this.routeId()}`);
protected readonly offerHeadingId = computed(() => `offerings-offer-${this.routeId()}`);
protected caseBackedNote(offering: OfferingCopy): string | null {
const caseId = offering.referenceCaseId;
if (!caseId) {
return null;
}
return this.page().offeringCaseBackedLabel.replace(
'{case}',
this.content.caseStudy(caseId)().client,
);
}
}

View File

@@ -1,13 +1,133 @@
@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 copy.sections; track section.id) { @for (section of copy.sections; track section.id) {
<app-content-section [section]="section" /> <app-content-section [section]="section" />
} }
<app-systems-map <app-systems-map
[heading]="copy.systemsMap.heading" [heading]="copy.systemsMap.heading"
[intro]="copy.systemsMap.intro" [intro]="copy.systemsMap.intro"
[headingLevel]="2" [headingLevel]="2"
/> />
<section class="stack service-areas" [attr.aria-labelledby]="'service-areas-heading'">
<h2 id="service-areas-heading">{{ copy.sectionsHeading }}</h2>
@for (section of copy.serviceSections; track section.id) {
<article class="service-section stack" [attr.aria-labelledby]="section.id">
<header class="service-section-header stack">
<h2 [id]="section.id">{{ section.title }}</h2>
<p class="service-section-lead">{{ section.lead }}</p>
</header>
<section
class="stack service-part"
[id]="section.situation.id"
[attr.aria-labelledby]="section.situation.id + '-heading'"
>
<h3 [id]="section.situation.id + '-heading'">{{ section.situation.headline }}</h3>
@for (paragraph of section.situation.body ?? []; track $index) {
<p>{{ paragraph }}</p>
}
</section>
<section
class="stack service-part"
[id]="section.diagnosis.id"
[attr.aria-labelledby]="section.diagnosis.id + '-heading'"
>
<h3 [id]="section.diagnosis.id + '-heading'">{{ section.diagnosis.headline }}</h3>
@for (paragraph of section.diagnosis.body ?? []; track $index) {
<p>{{ paragraph }}</p>
}
</section>
<section
class="stack service-part"
[id]="section.implementation.id"
[attr.aria-labelledby]="section.implementation.id + '-heading'"
>
<h3 [id]="section.implementation.id + '-heading'">
{{ section.implementation.headline }}
</h3>
@for (paragraph of section.implementation.body ?? []; track $index) {
<p>{{ paragraph }}</p>
}
</section>
<section
class="stack service-part"
[id]="section.operation.id"
[attr.aria-labelledby]="section.operation.id + '-heading'"
>
<h3 [id]="section.operation.id + '-heading'">{{ section.operation.headline }}</h3>
@for (paragraph of section.operation.body ?? []; track $index) {
<p>{{ paragraph }}</p>
}
</section>
<section
class="stack offerings-group"
[attr.aria-labelledby]="deliveredHeadingId(section.id)"
>
<h3 [id]="deliveredHeadingId(section.id)">{{ copy.deliveredOfferingsHeading }}</h3>
<ul class="stack offerings">
@for (offering of deliveredOfferings(section); track offering.id) {
<li [attr.data-offering-status]="offering.status">
<h4>{{ offering.title }}</h4>
<p>{{ offering.body }}</p>
@if (caseBackedNote(offering); as note) {
<p class="case-backed">{{ note }}</p>
}
</li>
}
</ul>
</section>
<section
class="stack offerings-group"
[attr.aria-labelledby]="offerHeadingId(section.id)"
>
<h3 [id]="offerHeadingId(section.id)">{{ copy.offerOfferingsHeading }}</h3>
<ul class="stack offerings">
@for (offering of offerOfferings(section); track offering.id) {
<li [attr.data-offering-status]="offering.status">
<h4>{{ offering.title }}</h4>
<p>{{ offering.body }}</p>
</li>
}
</ul>
</section>
<section
class="stack service-part"
[id]="section.evidence.id"
[attr.aria-labelledby]="section.evidence.id + '-heading'"
>
<h3 [id]="section.evidence.id + '-heading'">{{ section.evidence.heading }}</h3>
<p>{{ section.evidence.note }}</p>
<div class="evidence-cases stack">
@for (study of evidenceCases(section); track study.id) {
<app-case-card [caseStudy]="study" [idPrefix]="'evidence-' + section.id" />
}
</div>
</section>
<section
class="stack service-part"
[id]="section.inquiry.id"
[attr.aria-labelledby]="section.inquiry.id + '-heading'"
>
<h3 [id]="section.inquiry.id + '-heading'">{{ section.inquiry.headline }}</h3>
@for (paragraph of section.inquiry.body ?? []; track $index) {
<p>{{ paragraph }}</p>
}
<app-cta-row [ctas]="copy.ctas.slice(-2)" />
</section>
</article>
}
</section>
</div> </div>
} }

View File

@@ -0,0 +1,66 @@
@use '../../shared/page-shell';
.service-areas {
gap: var(--space-10);
}
.service-section {
padding-block: var(--space-6);
border-inline-start: 3px solid var(--color-accent);
padding-inline-start: var(--space-5);
scroll-margin-top: var(--space-8);
}
.service-section-header h2,
.service-part h3,
.offerings-group h3,
.offerings h4 {
margin: 0;
}
.service-section-header h2 {
font-size: var(--text-2xl);
}
.service-section-lead,
.service-part p,
.offerings p {
margin: 0;
max-width: 40rem;
color: var(--color-text-muted);
}
.service-part,
.offerings-group {
scroll-margin-top: var(--space-8);
}
.offerings {
list-style: none;
margin: 0;
padding: 0;
gap: var(--space-4);
}
.offerings li {
padding-block: var(--space-3);
border-block-end: 1px solid var(--surface-glass-border);
}
.offerings li:last-child {
border-block-end: 0;
}
.offerings h4 {
font-size: var(--text-md);
margin-block-end: var(--space-2);
}
.offerings .case-backed {
color: var(--color-text);
font-size: var(--text-sm);
}
.evidence-cases {
gap: var(--space-3);
}

View File

@@ -1,16 +1,55 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { type OfferingCopy, type ServiceSectionCopy } from '../../core/content/content.contracts';
import { ContentService } from '../../core/content/content.service'; import { ContentService } from '../../core/content/content.service';
import { CaseCard } from '../../shared/case-card/case-card';
import { ContentSection } from '../../shared/content-section/content-section'; import { ContentSection } from '../../shared/content-section/content-section';
import { CtaRow } from '../../shared/cta-row/cta-row';
import { PageHero } from '../../shared/page-hero/page-hero'; import { PageHero } from '../../shared/page-hero/page-hero';
import { SystemsMap } from '../../shared/systems-map/systems-map'; import { SystemsMap } from '../../shared/systems-map/systems-map';
@Component({ @Component({
selector: 'app-services-page', selector: 'app-services-page',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PageHero, ContentSection, SystemsMap], imports: [PageHero, ContentSection, SystemsMap, CaseCard, CtaRow],
templateUrl: './services.html', templateUrl: './services.html',
styleUrl: '../../shared/page-shell.scss', styleUrl: './services.scss',
}) })
export class ServicesPage { export class ServicesPage {
protected readonly page = inject(ContentService).servicesOverview(); private readonly content = inject(ContentService);
protected readonly page = this.content.services();
protected deliveredOfferings(section: ServiceSectionCopy): readonly OfferingCopy[] {
return section.offerings.filter((offering) => offering.status === 'delivered');
}
protected offerOfferings(section: ServiceSectionCopy): readonly OfferingCopy[] {
return section.offerings.filter((offering) => offering.status === 'offer');
}
protected deliveredHeadingId(sectionId: string): string {
return `offerings-delivered-${sectionId}`;
}
protected offerHeadingId(sectionId: string): string {
return `offerings-offer-${sectionId}`;
}
protected caseBackedNote(offering: OfferingCopy): string | null {
const caseId = offering.referenceCaseId;
if (!caseId) {
return null;
}
const study = this.content.caseStudy(caseId)();
if (!study) {
return null;
}
return this.page().offeringCaseBackedLabel.replace('{case}', study.client);
}
protected evidenceCases(section: ServiceSectionCopy) {
return section.evidence.caseIds.map((id) => this.content.caseStudy(id)());
}
} }

View File

@@ -1 +0,0 @@
<app-service-page-view routeId="servicesSoftware" />

View File

@@ -1,10 +0,0 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ServicePageView } from '../service-page-view/service-page-view';
@Component({
selector: 'app-services-software-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ServicePageView],
templateUrl: './software.html',
})
export class ServicesSoftwarePage {}

View File

@@ -0,0 +1,18 @@
export const LEGACY_SERVICE_REDIRECTS: Readonly<Record<string, string>> = {
'/leistungen/software': '/leistungen#software-data',
'/leistungen/hardware-netzwerk': '/leistungen#infrastructure-network',
'/leistungen/cluster': '/leistungen#platform-operations',
'/leistungen/ai-integration': '/leistungen#ai-workflows',
'/en/services/software': '/en/services#software-data',
'/en/services/hardware-network': '/en/services#infrastructure-network',
'/en/services/clusters': '/en/services#platform-operations',
'/en/services/ai-integration': '/en/services#ai-workflows',
};
export function normalizeRequestPath(pathname: string): string {
if (pathname.length > 1 && pathname.endsWith('/')) {
return pathname.slice(0, -1);
}
return pathname;
}

View File

@@ -6,12 +6,23 @@ import {
} from '@angular/ssr/node'; } from '@angular/ssr/node';
import express from 'express'; import express from 'express';
import { join } from 'node:path'; import { join } from 'node:path';
import { LEGACY_SERVICE_REDIRECTS, normalizeRequestPath } from './legacy-service-redirects';
const browserDistFolder = join(import.meta.dirname, '../browser'); const browserDistFolder = join(import.meta.dirname, '../browser');
const app = express(); const app = express();
const angularApp = new AngularNodeAppEngine(); const angularApp = new AngularNodeAppEngine();
app.use((req, res, next) => {
const location = LEGACY_SERVICE_REDIRECTS[normalizeRequestPath(req.path)];
if (!location) {
next();
return;
}
res.status(308).set('Location', location).end();
});
/** /**
* Example Express Rest API endpoints can be defined here. * Example Express Rest API endpoints can be defined here.
* Uncomment and define endpoints as necessary. * Uncomment and define endpoints as necessary.