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:
@@ -223,6 +223,7 @@ named in that language:
|
|||||||
/proyectos/catalogo /en/projects/catalogo /ca/projectes/catalogo
|
/proyectos/catalogo /en/projects/catalogo /ca/projectes/catalogo
|
||||||
/equipo/ada /en/team/ada /ca/equip/ada
|
/equipo/ada /en/team/ada /ca/equip/ada
|
||||||
/colabora/frontend /en/contribute/frontend /ca/col-labora/frontend
|
/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 —
|
Besides records there are **listings**, pages this site composes itself —
|
||||||
|
|||||||
@@ -233,6 +233,9 @@ export const pireonApi = {
|
|||||||
member: (lang: Locale, slug: string) =>
|
member: (lang: Locale, slug: string) =>
|
||||||
get<PublicMember>(`/members/${encodeURIComponent(slug)}`, { lang }),
|
get<PublicMember>(`/members/${encodeURIComponent(slug)}`, { lang }),
|
||||||
|
|
||||||
|
service: (lang: Locale, slug: string) =>
|
||||||
|
get<PublicService>(`/services/${encodeURIComponent(slug)}`, { lang }),
|
||||||
|
|
||||||
call: (lang: Locale, slug: string) =>
|
call: (lang: Locale, slug: string) =>
|
||||||
get<PublicCall>(`/calls/${encodeURIComponent(slug)}`, { lang }),
|
get<PublicCall>(`/calls/${encodeURIComponent(slug)}`, { lang }),
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -114,6 +114,12 @@ export interface Copy {
|
|||||||
links: string;
|
links: string;
|
||||||
since: string;
|
since: string;
|
||||||
};
|
};
|
||||||
|
service: {
|
||||||
|
eyebrow: string;
|
||||||
|
projects: string;
|
||||||
|
testimonials: string;
|
||||||
|
technologies: string;
|
||||||
|
};
|
||||||
radar: {
|
radar: {
|
||||||
eyebrow: string;
|
eyebrow: string;
|
||||||
lead: string;
|
lead: string;
|
||||||
@@ -261,6 +267,12 @@ const es: Copy = {
|
|||||||
links: 'Enlaces',
|
links: 'Enlaces',
|
||||||
since: 'En el colectivo desde',
|
since: 'En el colectivo desde',
|
||||||
},
|
},
|
||||||
|
service: {
|
||||||
|
eyebrow: 'Servicio',
|
||||||
|
projects: 'Trabajo relacionado',
|
||||||
|
testimonials: 'Qué dicen',
|
||||||
|
technologies: 'Con qué lo hacemos',
|
||||||
|
},
|
||||||
radar: {
|
radar: {
|
||||||
eyebrow: 'Radar tecnológico',
|
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.',
|
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',
|
links: 'Links',
|
||||||
since: 'In the collective since',
|
since: 'In the collective since',
|
||||||
},
|
},
|
||||||
|
service: {
|
||||||
|
eyebrow: 'Service',
|
||||||
|
projects: 'Related work',
|
||||||
|
testimonials: 'What people say',
|
||||||
|
technologies: 'What we use',
|
||||||
|
},
|
||||||
radar: {
|
radar: {
|
||||||
eyebrow: 'Tech 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.',
|
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',
|
links: 'Enllaços',
|
||||||
since: 'Al col·lectiu des de',
|
since: 'Al col·lectiu des de',
|
||||||
},
|
},
|
||||||
|
service: {
|
||||||
|
eyebrow: 'Servei',
|
||||||
|
projects: 'Feina relacionada',
|
||||||
|
testimonials: 'Què en diuen',
|
||||||
|
technologies: 'Amb què ho fem',
|
||||||
|
},
|
||||||
radar: {
|
radar: {
|
||||||
eyebrow: 'Radar tecnològic',
|
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.",
|
lead: "Les eines amb què treballem i què en pensem de cadascuna: què fem servir sense dubtar, què estem provant i què preferim evitar.",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type {
|
|||||||
PublicMember,
|
PublicMember,
|
||||||
PublicPage,
|
PublicPage,
|
||||||
PublicProject,
|
PublicProject,
|
||||||
|
PublicService,
|
||||||
PublicTechnology,
|
PublicTechnology,
|
||||||
} from './api.types';
|
} from './api.types';
|
||||||
import { resolveRoute, type RouteMatch } from './routes';
|
import { resolveRoute, type RouteMatch } from './routes';
|
||||||
@@ -28,6 +29,7 @@ export type RoutedRecord =
|
|||||||
| { type: 'project'; record: PublicProject }
|
| { type: 'project'; record: PublicProject }
|
||||||
| { type: 'member'; record: PublicMember }
|
| { type: 'member'; record: PublicMember }
|
||||||
| { type: 'call'; record: PublicCall }
|
| { type: 'call'; record: PublicCall }
|
||||||
|
| { type: 'service'; record: PublicService }
|
||||||
| { type: 'radar'; record: PublicTechnology[] };
|
| { type: 'radar'; record: PublicTechnology[] };
|
||||||
|
|
||||||
export interface RoutedPageData {
|
export interface RoutedPageData {
|
||||||
@@ -81,6 +83,10 @@ async function fetchRecord(route: RouteMatch): Promise<RoutedRecord | null> {
|
|||||||
const { data } = await pireonApi.call(locale, slug);
|
const { data } = await pireonApi.call(locale, slug);
|
||||||
return data ? { type: 'call', record: data } : null;
|
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': {
|
case 'radar': {
|
||||||
// An empty radar is still the radar: the page says so rather than 404.
|
// An empty radar is still the radar: the page says so rather than 404.
|
||||||
const { data, error } = await pireonApi.radar(locale);
|
const { data, error } = await pireonApi.radar(locale);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { copyFor, type Copy } from '../core/copy';
|
|||||||
import { listingAlternates, listingPath } from '../core/routes';
|
import { listingAlternates, listingPath } from '../core/routes';
|
||||||
import type { Locale } from '../core/api';
|
import type { Locale } from '../core/api';
|
||||||
import { RadarPage } from './radar-page';
|
import { RadarPage } from './radar-page';
|
||||||
|
import { ServiceDetail } from './service-detail';
|
||||||
import { CallDetail } from './call-detail';
|
import { CallDetail } from './call-detail';
|
||||||
import type { RoutedPageData, RoutedRecord } from '../core/routed-page';
|
import type { RoutedPageData, RoutedRecord } from '../core/routed-page';
|
||||||
import { ContentPage } from './content-page';
|
import { ContentPage } from './content-page';
|
||||||
@@ -30,6 +31,7 @@ import { ProjectDetail } from './project-detail';
|
|||||||
PageShell,
|
PageShell,
|
||||||
ProjectDetail,
|
ProjectDetail,
|
||||||
RadarPage,
|
RadarPage,
|
||||||
|
ServiceDetail,
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<app-page-shell [data]="data()" [seo]="seo()">
|
<app-page-shell [data]="data()" [seo]="seo()">
|
||||||
@@ -57,6 +59,13 @@ import { ProjectDetail } from './project-detail';
|
|||||||
[locale]="data().locale"
|
[locale]="data().locale"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
@case ('service') {
|
||||||
|
<app-service-detail
|
||||||
|
[service]="found.record"
|
||||||
|
[copy]="copy()"
|
||||||
|
[locale]="data().locale"
|
||||||
|
/>
|
||||||
|
}
|
||||||
@case ('radar') {
|
@case ('radar') {
|
||||||
<app-radar-page [technologies]="found.record" [copy]="copy()" />
|
<app-radar-page [technologies]="found.record" [copy]="copy()" />
|
||||||
}
|
}
|
||||||
@@ -126,6 +135,17 @@ export function seoFor(
|
|||||||
type: 'profile',
|
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':
|
case 'radar':
|
||||||
return {
|
return {
|
||||||
title: `Pireon — ${copy.sections.radar.title}`,
|
title: `Pireon — ${copy.sections.radar.title}`,
|
||||||
|
|||||||
@@ -44,7 +44,13 @@ import type { PublicService } from '../core/api.types';
|
|||||||
</pi-card-top-content>
|
</pi-card-top-content>
|
||||||
|
|
||||||
<pi-card-center-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) {
|
@if (service().tagline; as tagline) {
|
||||||
<p s pi-color color="grey">{{ tagline }}</p>
|
<p s pi-color color="grey">{{ tagline }}</p>
|
||||||
}
|
}
|
||||||
|
|||||||
31
landing/src/app/ui/service-detail.scss
Normal file
31
landing/src/app/ui/service-detail.scss
Normal 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;
|
||||||
|
}
|
||||||
128
landing/src/app/ui/service-detail.ts
Normal file
128
landing/src/app/ui/service-detail.ts
Normal 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'),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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']) {
|
for (const listed of data['/public/v1/members']) {
|
||||||
data[`/public/v1/members/${listed.slug}`] = {
|
data[`/public/v1/members/${listed.slug}`] = {
|
||||||
...listed,
|
...listed,
|
||||||
|
|||||||
Reference in New Issue
Block a user