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,