From daacc2c5d6a6b0587642153e8b1d4933de5f9870 Mon Sep 17 00:00:00 2001 From: pigui Date: Tue, 29 Sep 2026 15:10:19 +0200 Subject: [PATCH] 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 --- README.md | 1 + landing/src/app/core/api.ts | 3 + landing/src/app/core/copy.ts | 24 +++++ landing/src/app/core/routed-page.ts | 6 ++ landing/src/app/ui/routed-page.ts | 20 ++++ landing/src/app/ui/service-card.ts | 8 +- landing/src/app/ui/service-detail.scss | 31 ++++++ landing/src/app/ui/service-detail.ts | 128 +++++++++++++++++++++++++ tools/mock-api.mjs | 13 +++ 9 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 landing/src/app/ui/service-detail.scss create mode 100644 landing/src/app/ui/service-detail.ts diff --git a/README.md b/README.md index 516441c..e17e3f6 100644 --- a/README.md +++ b/README.md @@ -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 — diff --git a/landing/src/app/core/api.ts b/landing/src/app/core/api.ts index f4da02e..5d2cc00 100644 --- a/landing/src/app/core/api.ts +++ b/landing/src/app/core/api.ts @@ -233,6 +233,9 @@ export const pireonApi = { member: (lang: Locale, slug: string) => get(`/members/${encodeURIComponent(slug)}`, { lang }), + service: (lang: Locale, slug: string) => + get(`/services/${encodeURIComponent(slug)}`, { lang }), + call: (lang: Locale, slug: string) => get(`/calls/${encodeURIComponent(slug)}`, { lang }), } as const; diff --git a/landing/src/app/core/copy.ts b/landing/src/app/core/copy.ts index c1d99d0..32dc3f2 100644 --- a/landing/src/app/core/copy.ts +++ b/landing/src/app/core/copy.ts @@ -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.", diff --git a/landing/src/app/core/routed-page.ts b/landing/src/app/core/routed-page.ts index e4a04bd..84e1067 100644 --- a/landing/src/app/core/routed-page.ts +++ b/landing/src/app/core/routed-page.ts @@ -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 { 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); diff --git a/landing/src/app/ui/routed-page.ts b/landing/src/app/ui/routed-page.ts index e580909..a9b7d36 100644 --- a/landing/src/app/ui/routed-page.ts +++ b/landing/src/app/ui/routed-page.ts @@ -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: ` @@ -57,6 +59,13 @@ import { ProjectDetail } from './project-detail'; [locale]="data().locale" /> } + @case ('service') { + + } @case ('radar') { } @@ -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}`, diff --git a/landing/src/app/ui/service-card.ts b/landing/src/app/ui/service-card.ts index 59c63f6..5b129bd 100644 --- a/landing/src/app/ui/service-card.ts +++ b/landing/src/app/ui/service-card.ts @@ -44,7 +44,13 @@ import type { PublicService } from '../core/api.types'; -

{{ service().title }}

+

+ @if (service().path; as path) { + {{ service().title }} + } @else { + {{ service().title }} + } +

@if (service().tagline; as tagline) {

{{ tagline }}

} diff --git a/landing/src/app/ui/service-detail.scss b/landing/src/app/ui/service-detail.scss new file mode 100644 index 0000000..56f9ff8 --- /dev/null +++ b/landing/src/app/ui/service-detail.scss @@ -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; +} diff --git a/landing/src/app/ui/service-detail.ts b/landing/src/app/ui/service-detail.ts new file mode 100644 index 0000000..f3f162f --- /dev/null +++ b/landing/src/app/ui/service-detail.ts @@ -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: ` +
+
+ +

{{ copy().service.eyebrow }}

+

{{ service().title }}

+ + @if (service().tagline; as tagline) { +

+ {{ tagline }} +

+ } +
+
+ +
+
+ @if (service().description; as description) { +

{{ description }}

+ } + + @if ((service().projects ?? []).length > 0) { +
+

{{ copy().service.projects }}

+
+ @for (project of service().projects; track project.id) { + + } +
+
+ } + + @if ((service().testimonials ?? []).length > 0) { +
+

{{ copy().service.testimonials }}

+
+ @for ( + testimonial of service().testimonials; + track testimonial.id + ) { + + } +
+
+ } +
+ + +
+ `, +}) +export class ServiceDetail { + readonly service = input.required(); + readonly copy = input.required(); + readonly locale = input.required(); + + /** The panel lets anyone type anything here, emoji included. */ + protected readonly icon = computed(() => + resolveIcon(this.service().icon, 'support'), + ); +} diff --git a/tools/mock-api.mjs b/tools/mock-api.mjs index 31dc850..068ecfa 100644 --- a/tools/mock-api.mjs +++ b/tools/mock-api.mjs @@ -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,