Give each service its own page

A service now has its address, like a project: icon, tagline, what the
collective does and how, the projects it was delivered on, what the
people it was for said, and the technologies behind it. The landing's
service cards link to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pigui
2026-09-29 15:10:19 +02:00
parent 06eeefc144
commit daacc2c5d6
9 changed files with 233 additions and 1 deletions

View File

@@ -223,6 +223,7 @@ named in that language:
/proyectos/catalogo /en/projects/catalogo /ca/projectes/catalogo
/equipo/ada /en/team/ada /ca/equip/ada
/colabora/frontend /en/contribute/frontend /ca/col-labora/frontend
/servicios/formacion /en/services/formacion /ca/serveis/formacion
```
Besides records there are **listings**, pages this site composes itself —

View File

@@ -233,6 +233,9 @@ export const pireonApi = {
member: (lang: Locale, slug: string) =>
get<PublicMember>(`/members/${encodeURIComponent(slug)}`, { lang }),
service: (lang: Locale, slug: string) =>
get<PublicService>(`/services/${encodeURIComponent(slug)}`, { lang }),
call: (lang: Locale, slug: string) =>
get<PublicCall>(`/calls/${encodeURIComponent(slug)}`, { lang }),
} as const;

View File

@@ -114,6 +114,12 @@ export interface Copy {
links: string;
since: string;
};
service: {
eyebrow: string;
projects: string;
testimonials: string;
technologies: string;
};
radar: {
eyebrow: string;
lead: string;
@@ -261,6 +267,12 @@ const es: Copy = {
links: 'Enlaces',
since: 'En el colectivo desde',
},
service: {
eyebrow: 'Servicio',
projects: 'Trabajo relacionado',
testimonials: 'Qué dicen',
technologies: 'Con qué lo hacemos',
},
radar: {
eyebrow: 'Radar tecnológico',
lead: 'Las herramientas con las que trabajamos y lo que opinamos de cada una: qué usamos sin dudar, qué estamos probando y qué preferimos evitar.',
@@ -421,6 +433,12 @@ const en: Copy = {
links: 'Links',
since: 'In the collective since',
},
service: {
eyebrow: 'Service',
projects: 'Related work',
testimonials: 'What people say',
technologies: 'What we use',
},
radar: {
eyebrow: 'Tech radar',
lead: 'The tools we work with and what we think of each: what we use without a second thought, what we are trying out and what we would rather avoid.',
@@ -581,6 +599,12 @@ const ca: Copy = {
links: 'Enllaços',
since: 'Al col·lectiu des de',
},
service: {
eyebrow: 'Servei',
projects: 'Feina relacionada',
testimonials: 'Què en diuen',
technologies: 'Amb què ho fem',
},
radar: {
eyebrow: 'Radar tecnològic',
lead: "Les eines amb què treballem i què en pensem de cadascuna: què fem servir sense dubtar, què estem provant i què preferim evitar.",

View File

@@ -18,6 +18,7 @@ import type {
PublicMember,
PublicPage,
PublicProject,
PublicService,
PublicTechnology,
} from './api.types';
import { resolveRoute, type RouteMatch } from './routes';
@@ -28,6 +29,7 @@ export type RoutedRecord =
| { type: 'project'; record: PublicProject }
| { type: 'member'; record: PublicMember }
| { type: 'call'; record: PublicCall }
| { type: 'service'; record: PublicService }
| { type: 'radar'; record: PublicTechnology[] };
export interface RoutedPageData {
@@ -81,6 +83,10 @@ async function fetchRecord(route: RouteMatch): Promise<RoutedRecord | null> {
const { data } = await pireonApi.call(locale, slug);
return data ? { type: 'call', record: data } : null;
}
case 'service': {
const { data } = await pireonApi.service(locale, slug);
return data ? { type: 'service', record: data } : null;
}
case 'radar': {
// An empty radar is still the radar: the page says so rather than 404.
const { data, error } = await pireonApi.radar(locale);

View File

@@ -9,6 +9,7 @@ import { copyFor, type Copy } from '../core/copy';
import { listingAlternates, listingPath } from '../core/routes';
import type { Locale } from '../core/api';
import { RadarPage } from './radar-page';
import { ServiceDetail } from './service-detail';
import { CallDetail } from './call-detail';
import type { RoutedPageData, RoutedRecord } from '../core/routed-page';
import { ContentPage } from './content-page';
@@ -30,6 +31,7 @@ import { ProjectDetail } from './project-detail';
PageShell,
ProjectDetail,
RadarPage,
ServiceDetail,
],
template: `
<app-page-shell [data]="data()" [seo]="seo()">
@@ -57,6 +59,13 @@ import { ProjectDetail } from './project-detail';
[locale]="data().locale"
/>
}
@case ('service') {
<app-service-detail
[service]="found.record"
[copy]="copy()"
[locale]="data().locale"
/>
}
@case ('radar') {
<app-radar-page [technologies]="found.record" [copy]="copy()" />
}
@@ -126,6 +135,17 @@ export function seoFor(
type: 'profile',
};
}
case 'service': {
const service = found.record;
return {
title: service.meta_title || `Pireon — ${service.title}`,
description: service.meta_description || service.tagline || '',
path: service.path,
alternates: service.alternates ?? {},
type: 'article',
};
}
case 'radar':
return {
title: `Pireon — ${copy.sections.radar.title}`,

View File

@@ -44,7 +44,13 @@ import type { PublicService } from '../core/api.types';
</pi-card-top-content>
<pi-card-center-content>
<h3 bold class="landing-card__title">{{ service().title }}</h3>
<h3 bold class="landing-card__title">
@if (service().path; as path) {
<a class="landing-card__title-link" [href]="path">{{ service().title }}</a>
} @else {
{{ service().title }}
}
</h3>
@if (service().tagline; as tagline) {
<p s pi-color color="grey">{{ tagline }}</p>
}

View File

@@ -0,0 +1,31 @@
@use './detail';
:host {
display: block;
}
.service-icon {
display: block;
margin-bottom: 1.25rem;
}
// A textarea's line breaks are the writer's paragraphs.
.service-text {
margin: 0;
font-size: 1.0625rem;
line-height: 1.7;
white-space: pre-line;
}
.service-grid {
display: grid;
gap: 1.25rem;
@media (min-width: 640px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.service-technologies {
margin-top: 0;
}

View File

@@ -0,0 +1,128 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import {
PiChipComponent,
PiIconComponent,
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
} from '@pi/ui';
import type { Locale } from '../core/api';
import type { PublicService } from '../core/api.types';
import type { Copy } from '../core/copy';
import { resolveIcon } from '../core/icons';
import { ContactCta } from './contact-cta';
import { ProjectCard } from './project-card';
import { TestimonialCard } from './testimonial-card';
/**
* A service on its own address, and the published work that backs it: the
* projects it was delivered on and what the people it was for said.
*
* The description comes from a textarea, so it is printed as text with its
* line breaks kept.
*/
@Component({
selector: 'app-service-detail',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ContactCta,
PiChipComponent,
PiIconComponent,
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
ProjectCard,
TestimonialCard,
],
styleUrl: './service-detail.scss',
template: `
<header class="content-hero">
<div class="landing-shell">
<pi-icon
class="service-icon"
[icon]="icon()"
size="x-large"
color="primary"
/>
<p class="landing-eyebrow">{{ copy().service.eyebrow }}</p>
<h1 bold class="content-hero__title">{{ service().title }}</h1>
@if (service().tagline; as tagline) {
<p l pi-color color="grey" class="content-hero__lead">
{{ tagline }}
</p>
}
</div>
</header>
<div class="landing-shell content-page__layout">
<article class="content-page__main">
@if (service().description; as description) {
<p class="service-text">{{ description }}</p>
}
@if ((service().projects ?? []).length > 0) {
<section class="content-page__section">
<h2>{{ copy().service.projects }}</h2>
<div class="service-grid">
@for (project of service().projects; track project.id) {
<app-project-card [project]="project" [copy]="copy()" />
}
</div>
</section>
}
@if ((service().testimonials ?? []).length > 0) {
<section class="content-page__section">
<h2>{{ copy().service.testimonials }}</h2>
<div class="service-grid">
@for (
testimonial of service().testimonials;
track testimonial.id
) {
<app-testimonial-card [testimonial]="testimonial" />
}
</div>
</section>
}
</article>
<aside class="content-page__aside">
@if ((service().technologies ?? []).length > 0) {
<div class="landing-card content-page__panel">
<h2>{{ copy().service.technologies }}</h2>
<ul class="content-hero__chips service-technologies">
@for (technology of service().technologies; track technology.id) {
<li>
<pi-chip
size="small"
appearance="outline"
[label]="technology.name"
/>
</li>
}
</ul>
</div>
}
<app-contact-cta [copy]="copy()" [locale]="locale()" />
</aside>
</div>
`,
})
export class ServiceDetail {
readonly service = input.required<PublicService>();
readonly copy = input.required<Copy>();
readonly locale = input.required<Locale>();
/** The panel lets anyone type anything here, emoji included. */
protected readonly icon = computed(() =>
resolveIcon(this.service().icon, 'support'),
);
}

View File

@@ -147,6 +147,19 @@ for (const call of data['/public/v1/calls']) {
};
}
for (const service of data['/public/v1/services']) {
service.path = `/servicios/${service.slug}`;
data[`/public/v1/services/${service.slug}`] = {
...service,
description: 'Empezamos escuchando: qué hace hoy la organización y qué le sobra.\nDespués construimos lo mínimo que resuelve el problema, y lo dejamos documentado para que lo pueda mantener otra persona.',
meta_description: service.tagline,
projects: data['/public/v1/projects'].slice(0, 2).map((p) => ({ ...p, path: `/proyectos/${p.slug}` })),
technologies: technologies.slice(0, 4),
testimonials: data['/public/v1/testimonials'].slice(0, 2),
alternates: { es: service.path, en: `/en/services/${service.slug}`, ca: `/ca/serveis/${service.slug}` },
};
}
for (const listed of data['/public/v1/members']) {
data[`/public/v1/members/${listed.slug}`] = {
...listed,