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:
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
@@ -15,14 +15,6 @@ Fullstack and DevOps engineer in Tangermünde. The public site documents a curat
|
||||
|
||||
- https://antoniolede.de/leistungen
|
||||
- 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
|
||||
|
||||
@@ -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/projekte#roesterei
|
||||
- https://antoniolede.de/en/projects#roesterei
|
||||
- https://antoniolede.de/projekte#myspa
|
||||
- https://antoniolede.de/en/projects#myspa
|
||||
- https://antoniolede.de/projekte#bannier
|
||||
- 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/en/projects#hdi
|
||||
- https://antoniolede.de/projekte#devdays
|
||||
- https://antoniolede.de/en/projects#devdays
|
||||
|
||||
## Contact, stack and legal
|
||||
|
||||
|
||||
@@ -18,30 +18,6 @@
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen" />
|
||||
</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>
|
||||
<loc>https://antoniolede.de/projekte</loc>
|
||||
<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="x-default" href="https://antoniolede.de/leistungen" />
|
||||
</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>
|
||||
<loc>https://antoniolede.de/en/projects</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/projekte" />
|
||||
|
||||
@@ -13,18 +13,6 @@ 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),
|
||||
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),
|
||||
stack: () => import('./features/stack/stack').then((module) => module.StackPage),
|
||||
about: () => import('./features/about/about').then((module) => module.AboutPage),
|
||||
|
||||
@@ -14,7 +14,6 @@ export interface ResolvedNavItem {
|
||||
readonly routeId: RouteId;
|
||||
readonly label: string;
|
||||
readonly link: unknown[];
|
||||
readonly children?: readonly ResolvedNavItem[];
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -56,7 +55,6 @@ export class NavigationService {
|
||||
routeId: item.routeId,
|
||||
label: item.label[locale],
|
||||
link: routeCommands(item.routeId, locale),
|
||||
children: item.children ? this.resolveItems(item.children, locale) : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { type RouteId } from '../routing/route-ids';
|
||||
export interface NavItem {
|
||||
readonly routeId: RouteId;
|
||||
readonly label: Record<AppLocale, string>;
|
||||
readonly children?: readonly NavItem[];
|
||||
}
|
||||
|
||||
export const PRIMARY_NAV: readonly NavItem[] = [
|
||||
@@ -15,24 +14,6 @@ export const PRIMARY_NAV: readonly NavItem[] = [
|
||||
{
|
||||
routeId: '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',
|
||||
|
||||
@@ -2,10 +2,6 @@ export type RouteId =
|
||||
| 'home'
|
||||
| 'pitch'
|
||||
| 'services'
|
||||
| 'servicesSoftware'
|
||||
| 'servicesHardwareNetwork'
|
||||
| 'servicesClusters'
|
||||
| 'servicesAi'
|
||||
| 'projects'
|
||||
| 'stack'
|
||||
| 'about'
|
||||
@@ -18,10 +14,6 @@ export const ROUTE_IDS: readonly RouteId[] = [
|
||||
'home',
|
||||
'pitch',
|
||||
'services',
|
||||
'servicesSoftware',
|
||||
'servicesHardwareNetwork',
|
||||
'servicesClusters',
|
||||
'servicesAi',
|
||||
'projects',
|
||||
'stack',
|
||||
'about',
|
||||
|
||||
@@ -27,12 +27,12 @@ describe('route paths', () => {
|
||||
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.en.pitch).toBe('pitch');
|
||||
expect(routePath('pitch', 'de')).toBe('/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', () => {
|
||||
|
||||
@@ -11,10 +11,6 @@ export const ROUTE_SEGMENTS: Record<AppLocale, Record<RouteId, string>> = {
|
||||
home: '',
|
||||
pitch: 'pitch',
|
||||
services: 'leistungen',
|
||||
servicesSoftware: 'leistungen/software',
|
||||
servicesHardwareNetwork: 'leistungen/hardware-netzwerk',
|
||||
servicesClusters: 'leistungen/cluster',
|
||||
servicesAi: 'leistungen/ai-integration',
|
||||
projects: 'projekte',
|
||||
stack: 'stack',
|
||||
about: 'ueber-mich',
|
||||
@@ -27,10 +23,6 @@ export const ROUTE_SEGMENTS: Record<AppLocale, Record<RouteId, string>> = {
|
||||
home: '',
|
||||
pitch: 'pitch',
|
||||
services: 'services',
|
||||
servicesSoftware: 'services/software',
|
||||
servicesHardwareNetwork: 'services/hardware-network',
|
||||
servicesClusters: 'services/clusters',
|
||||
servicesAi: 'services/ai-integration',
|
||||
projects: 'projects',
|
||||
stack: 'stack',
|
||||
about: 'about',
|
||||
|
||||
@@ -6,13 +6,7 @@ import { canonicalUrl } from './route-metadata';
|
||||
|
||||
const SCHEMA_CONTEXT = 'https://schema.org';
|
||||
|
||||
const SERVICE_ROUTE_IDS: readonly RouteId[] = [
|
||||
'services',
|
||||
'servicesSoftware',
|
||||
'servicesHardwareNetwork',
|
||||
'servicesClusters',
|
||||
'servicesAi',
|
||||
];
|
||||
const SERVICE_ROUTE_IDS: readonly RouteId[] = ['services'];
|
||||
|
||||
export type JsonLdValue =
|
||||
| string
|
||||
@@ -56,23 +50,23 @@ function professionalServiceNode(
|
||||
};
|
||||
}
|
||||
|
||||
function serviceNode(
|
||||
routeId: RouteId,
|
||||
function serviceNodes(
|
||||
locale: AppLocale,
|
||||
content: Record<AppLocale, SiteContent>,
|
||||
): JsonLdValue {
|
||||
const page = content[locale].pages[routeId];
|
||||
): readonly JsonLdValue[] {
|
||||
const page = content[locale].services;
|
||||
const servicesUrl = canonicalUrl('services', locale);
|
||||
|
||||
return {
|
||||
return page.serviceSections.map((section) => ({
|
||||
'@type': 'Service',
|
||||
name: page.hero.headline,
|
||||
description: page.description,
|
||||
url: canonicalUrl(routeId, locale),
|
||||
name: section.title,
|
||||
description: section.lead,
|
||||
url: `${servicesUrl}#${section.id}`,
|
||||
provider: {
|
||||
'@type': 'Person',
|
||||
name: SITE_CONFIG.personName,
|
||||
},
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
function projectWorks(
|
||||
@@ -128,7 +122,7 @@ export function buildJsonLdGraph(
|
||||
if (routeId === 'home') {
|
||||
graph = [personNode(locale, content), professionalServiceNode(locale, content)];
|
||||
} else if (SERVICE_ROUTE_IDS.includes(routeId)) {
|
||||
graph = [serviceNode(routeId, locale, content)];
|
||||
graph = serviceNodes(locale, content);
|
||||
} else if (routeId === 'projects') {
|
||||
graph = projectWorks(locale, content);
|
||||
} else {
|
||||
|
||||
@@ -3,15 +3,16 @@
|
||||
<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 === 'scope') {
|
||||
@if (section.id === 'areas') {
|
||||
<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)"
|
||||
class="home-service-card interactive-lift stack"
|
||||
[routerLink]="areaLink(area)"
|
||||
[fragment]="area.fragment"
|
||||
>
|
||||
<h3>{{ area.title }}</h3>
|
||||
<p>{{ area.body }}</p>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { type HomeServiceAreaCopy } from '../../core/content/content.contracts';
|
||||
import { NavigationService } from '../../core/navigation/navigation.service';
|
||||
import { CaseCard } from '../../shared/case-card/case-card';
|
||||
import { ContentSection } from '../../shared/content-section/content-section';
|
||||
@@ -28,7 +28,7 @@ export class HomePage {
|
||||
return ids.map((id) => this.content.caseStudy(id)());
|
||||
});
|
||||
|
||||
protected areaLink(routeId: RouteId): unknown[] {
|
||||
return this.navigation.link(routeId);
|
||||
protected areaLink(area: HomeServiceAreaCopy): unknown[] {
|
||||
return this.navigation.link(area.routeId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<app-service-page-view routeId="servicesAi" />
|
||||
@@ -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 {}
|
||||
@@ -1 +0,0 @@
|
||||
<app-service-page-view routeId="servicesClusters" />
|
||||
@@ -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 {}
|
||||
@@ -1 +0,0 @@
|
||||
<app-service-page-view routeId="servicesHardwareNetwork" />
|
||||
@@ -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 {}
|
||||
59
src/app/features/services/service-offerings.spec.ts
Normal file
59
src/app/features/services/service-offerings.spec.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,133 @@
|
||||
@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" />
|
||||
}
|
||||
|
||||
<app-systems-map
|
||||
[heading]="copy.systemsMap.heading"
|
||||
[intro]="copy.systemsMap.intro"
|
||||
[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>
|
||||
}
|
||||
|
||||
66
src/app/features/services/services.scss
Normal file
66
src/app/features/services/services.scss
Normal 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);
|
||||
}
|
||||
@@ -1,16 +1,55 @@
|
||||
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 { 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 { SystemsMap } from '../../shared/systems-map/systems-map';
|
||||
|
||||
@Component({
|
||||
selector: 'app-services-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PageHero, ContentSection, SystemsMap],
|
||||
imports: [PageHero, ContentSection, SystemsMap, CaseCard, CtaRow],
|
||||
templateUrl: './services.html',
|
||||
styleUrl: '../../shared/page-shell.scss',
|
||||
styleUrl: './services.scss',
|
||||
})
|
||||
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)());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<app-service-page-view routeId="servicesSoftware" />
|
||||
@@ -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 {}
|
||||
18
src/legacy-service-redirects.ts
Normal file
18
src/legacy-service-redirects.ts
Normal 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;
|
||||
}
|
||||
@@ -6,12 +6,23 @@ import {
|
||||
} from '@angular/ssr/node';
|
||||
import express from 'express';
|
||||
import { join } from 'node:path';
|
||||
import { LEGACY_SERVICE_REDIRECTS, normalizeRequestPath } from './legacy-service-redirects';
|
||||
|
||||
const browserDistFolder = join(import.meta.dirname, '../browser');
|
||||
|
||||
const app = express();
|
||||
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.
|
||||
* Uncomment and define endpoints as necessary.
|
||||
|
||||
Reference in New Issue
Block a user