Give the tech radar its own page

/radar (/en/radar, /ca/radar) shows every technology the collective has
placed, by ring: logo, category, what it is, why it sits there, its
website and how many projects use it. The landing's radar summary links
to it.

It is the first listing — a page this site composes rather than one
record's — so resolveRoute learns about those (LISTINGS), and builds
their addresses and hreflang alternates from the same table.

Logos sit on a light tile in both themes. A brand's mark is not ours to
recolour, and Rust's, which is black, would vanish in dark mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pigui
2026-09-29 15:07:19 +02:00
parent b5681a12ac
commit 06eeefc144
11 changed files with 558 additions and 13 deletions

View File

@@ -225,6 +225,12 @@ named in that language:
/colabora/frontend /en/contribute/frontend /ca/col-labora/frontend /colabora/frontend /en/contribute/frontend /ca/col-labora/frontend
``` ```
Besides records there are **listings**, pages this site composes itself —
the tech radar at `/radar`, `/en/radar`, `/ca/radar` (`LISTINGS`). A panel page
with the same slug would be shadowed by one, so they are few and deliberately
named. Radar logos sit on a light tile in both themes: a brand's mark is not
ours to recolour, and some (Rust's, black) would vanish in dark mode.
`core/routes.ts#resolveRoute` is that rule read backwards, and its `SECTIONS` `core/routes.ts#resolveRoute` is that rule read backwards, and its `SECTIONS`
mirror the API's `config/site.php` (`paths`) — a change there has to be made mirror the API's `config/site.php` (`paths`) — a change there has to be made
here too. `/es/…` is not a shape the API produces, so a record has one address here too. `/es/…` is not a shape the API produces, so a record has one address

View File

@@ -50,11 +50,20 @@ test.describe('landing', () => {
expect(response?.status()).toBe(404); expect(response?.status()).toBe(404);
await expect(page.locator('h1')).toContainText('no existe'); await expect(page.locator('h1')).toContainText('no existe');
await expect(page.locator('meta[name="robots"]')).toHaveAttribute('content', 'noindex'); await expect(page.locator('meta[name="robots"]')).toHaveAttribute(
'content',
'noindex',
);
}); });
test('answers 404 for a record the API does not have, in any section', async ({ page }) => { test('answers 404 for a record the API does not have, in any section', async ({
for (const path of ['/proyectos/no-existe', '/en/team/nobody', '/ca/projectes/res']) { page,
}) => {
for (const path of [
'/proyectos/no-existe',
'/en/team/nobody',
'/ca/projectes/res',
]) {
const response = await page.goto(path); const response = await page.goto(path);
expect(response?.status(), path).toBe(404); expect(response?.status(), path).toBe(404);
@@ -62,7 +71,9 @@ test.describe('landing', () => {
} }
}); });
test('answers 404 for a section spelled in another language', async ({ page }) => { test('answers 404 for a section spelled in another language', async ({
page,
}) => {
// `/projects/x` is the English section without the English prefix: not an // `/projects/x` is the English section without the English prefix: not an
// address the API ever hands out. // address the API ever hands out.
const response = await page.goto('/projects/anything'); const response = await page.goto('/projects/anything');
@@ -70,6 +81,23 @@ test.describe('landing', () => {
expect(response?.status()).toBe(404); expect(response?.status()).toBe(404);
}); });
test('serves the radar in every language, and not under /es', async ({
page,
}) => {
for (const [path, heading] of [
['/radar', 'Radar técnico'],
['/en/radar', 'Tech radar'],
['/ca/radar', 'Radar tècnic'],
]) {
const response = await page.goto(path);
expect(response?.status(), path).toBe(200);
await expect(page.locator('h1')).toContainText(heading);
}
expect((await page.goto('/es/radar'))?.status()).toBe(404);
});
test('refuses an incomplete contact submission without calling the API', async ({ test('refuses an incomplete contact submission without calling the API', async ({
page, page,
}) => { }) => {

View File

@@ -114,6 +114,19 @@ export interface Copy {
links: string; links: string;
since: string; since: string;
}; };
radar: {
eyebrow: string;
lead: string;
ringLead: Record<'adopt' | 'trial' | 'assess' | 'hold', string>;
categories: Record<
'language' | 'framework' | 'datastore' | 'infrastructure' | 'tool' | 'practice',
string
>;
website: string;
projects: string;
empty: string;
seeAll: string;
};
call: { call: {
eyebrow: string; eyebrow: string;
seniority: Record<'any' | 'junior' | 'mid' | 'senior', string>; seniority: Record<'any' | 'junior' | 'mid' | 'senior', string>;
@@ -248,6 +261,28 @@ const es: Copy = {
links: 'Enlaces', links: 'Enlaces',
since: 'En el colectivo desde', since: 'En el colectivo desde',
}, },
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.',
ringLead: {
adopt: 'Lo usamos en proyectos reales y lo recomendamos sin reservas.',
trial: 'Lo estamos probando en proyectos donde el riesgo es asumible.',
assess: 'Nos interesa y lo estamos estudiando, sin usarlo aún en serio.',
hold: 'Por ahora preferimos no empezar nada nuevo con esto.',
},
categories: {
language: 'Lenguaje',
framework: 'Framework',
datastore: 'Datos',
infrastructure: 'Infraestructura',
tool: 'Herramienta',
practice: 'Práctica',
},
website: 'Web oficial',
projects: 'proyectos',
empty: 'Todavía no hemos colocado ninguna tecnología en el radar.',
seeAll: 'Ver el radar completo',
},
call: { call: {
eyebrow: 'Convocatoria', eyebrow: 'Convocatoria',
seniority: { any: 'Cualquier nivel', junior: 'Junior', mid: 'Intermedio', senior: 'Senior' }, seniority: { any: 'Cualquier nivel', junior: 'Junior', mid: 'Intermedio', senior: 'Senior' },
@@ -386,6 +421,28 @@ const en: Copy = {
links: 'Links', links: 'Links',
since: 'In the collective since', since: 'In the collective since',
}, },
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.',
ringLead: {
adopt: 'We use it on real projects and recommend it without reservation.',
trial: 'We are trying it out on projects where the risk is acceptable.',
assess: 'It interests us and we are looking into it, without using it seriously yet.',
hold: 'For now we would rather not start anything new with it.',
},
categories: {
language: 'Language',
framework: 'Framework',
datastore: 'Data',
infrastructure: 'Infrastructure',
tool: 'Tool',
practice: 'Practice',
},
website: 'Official website',
projects: 'projects',
empty: 'We have not placed any technology on the radar yet.',
seeAll: 'See the full radar',
},
call: { call: {
eyebrow: 'Open call', eyebrow: 'Open call',
seniority: { any: 'Any level', junior: 'Junior', mid: 'Mid-level', senior: 'Senior' }, seniority: { any: 'Any level', junior: 'Junior', mid: 'Mid-level', senior: 'Senior' },
@@ -524,6 +581,28 @@ const ca: Copy = {
links: 'Enllaços', links: 'Enllaços',
since: 'Al col·lectiu des de', since: 'Al col·lectiu des de',
}, },
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.",
ringLead: {
adopt: 'El fem servir en projectes reals i el recomanem sense reserves.',
trial: "L'estem provant en projectes on el risc és assumible.",
assess: "Ens interessa i l'estem estudiant, sense fer-lo servir encara seriosament.",
hold: 'Ara per ara preferim no començar res de nou amb això.',
},
categories: {
language: 'Llenguatge',
framework: 'Framework',
datastore: 'Dades',
infrastructure: 'Infraestructura',
tool: 'Eina',
practice: 'Pràctica',
},
website: 'Web oficial',
projects: 'projectes',
empty: 'Encara no hem col·locat cap tecnologia al radar.',
seeAll: 'Veure el radar sencer',
},
call: { call: {
eyebrow: 'Convocatòria', eyebrow: 'Convocatòria',
seniority: { any: 'Qualsevol nivell', junior: 'Júnior', mid: 'Intermedi', senior: 'Sènior' }, seniority: { any: 'Qualsevol nivell', junior: 'Júnior', mid: 'Intermedi', senior: 'Sènior' },

View File

@@ -13,7 +13,13 @@ import {
type MaintenanceNotice, type MaintenanceNotice,
type SitePayload, type SitePayload,
} from './api'; } from './api';
import type { PublicCall, PublicMember, PublicPage, PublicProject } from './api.types'; import type {
PublicCall,
PublicMember,
PublicPage,
PublicProject,
PublicTechnology,
} from './api.types';
import { resolveRoute, type RouteMatch } from './routes'; import { resolveRoute, type RouteMatch } from './routes';
/** The record, tagged with its type so the template can switch on it. */ /** The record, tagged with its type so the template can switch on it. */
@@ -21,7 +27,8 @@ export type RoutedRecord =
| { type: 'page'; record: PublicPage } | { type: 'page'; record: PublicPage }
| { 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: 'radar'; record: PublicTechnology[] };
export interface RoutedPageData { export interface RoutedPageData {
locale: Locale; locale: Locale;
@@ -74,6 +81,11 @@ 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 'radar': {
// An empty radar is still the radar: the page says so rather than 404.
const { data, error } = await pireonApi.radar(locale);
return error ? null : { type: 'radar', record: data ?? [] };
}
default: default:
// A type the site has no page for yet: an address the API hands out // A type the site has no page for yet: an address the API hands out
// but nothing here renders, which is a 404 until it does. // but nothing here renders, which is a 404 until it does.

View File

@@ -1,7 +1,13 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { DEFAULT_LOCALE, SUPPORTED_LOCALES } from './api'; import { DEFAULT_LOCALE, SUPPORTED_LOCALES } from './api';
import { homeHref, resolveRoute, SECTIONS } from './routes'; import {
homeHref,
listingAlternates,
listingPath,
resolveRoute,
SECTIONS,
} from './routes';
describe('resolveRoute', () => { describe('resolveRoute', () => {
it('reads a page at the root as the default language', () => { it('reads a page at the root as the default language', () => {
@@ -77,6 +83,33 @@ describe('resolveRoute', () => {
}); });
}); });
describe('listings', () => {
it('reads the radar in every language, before any page of that slug', () => {
expect(resolveRoute('/radar')).toEqual({
type: 'radar',
locale: 'es',
slug: '',
});
expect(resolveRoute('/ca/radar')).toEqual({
type: 'radar',
locale: 'ca',
slug: '',
});
});
it('builds the address resolveRoute reads back', () => {
for (const locale of SUPPORTED_LOCALES) {
expect(resolveRoute(listingPath('radar', locale))?.type).toBe('radar');
}
expect(listingAlternates('radar')).toEqual({
es: '/radar',
en: '/en/radar',
ca: '/ca/radar',
});
});
});
describe('homeHref', () => { describe('homeHref', () => {
it('answers on ?lang= for every language but the default', () => { it('answers on ?lang= for every language but the default', () => {
expect(homeHref(DEFAULT_LOCALE)).toBe('/'); expect(homeHref(DEFAULT_LOCALE)).toBe('/');

View File

@@ -15,6 +15,9 @@ import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type Locale } from './api';
export type RecordType = 'page' | 'project' | 'member' | 'call' | 'service'; export type RecordType = 'page' | 'project' | 'member' | 'call' | 'service';
/** Pages this site composes itself rather than one record's: no slug. */
export type ListingType = 'radar';
/** /**
* The section each type lives under, per language. The API's own * The section each type lives under, per language. The API's own
* `config/site.php` (`paths`) is the source; a change there has to be made here * `config/site.php` (`paths`) is the source; a change there has to be made here
@@ -30,9 +33,18 @@ export const SECTIONS: Record<
service: { es: 'servicios', en: 'services', ca: 'serveis' }, service: { es: 'servicios', en: 'services', ca: 'serveis' },
}; };
/**
* Where each listing lives, per language. A panel page with the same slug
* would be shadowed by it, so these are few and deliberately named.
*/
export const LISTINGS: Record<ListingType, Record<Locale, string>> = {
radar: { es: 'radar', en: 'radar', ca: 'radar' },
};
export interface RouteMatch { export interface RouteMatch {
type: RecordType; type: RecordType | ListingType;
locale: Locale; locale: Locale;
/** Empty for a listing. */
slug: string; slug: string;
} }
@@ -51,7 +63,13 @@ export function resolveRoute(pathname: string): RouteMatch | null {
const rest = prefixed ? segments.slice(1) : segments; const rest = prefixed ? segments.slice(1) : segments;
if (rest.length === 1) { if (rest.length === 1) {
return { type: 'page', locale, slug: rest[0] }; const listing = (Object.keys(LISTINGS) as ListingType[]).find(
(candidate) => LISTINGS[candidate][locale] === rest[0],
);
return listing
? { type: listing, locale, slug: '' }
: { type: 'page', locale, slug: rest[0] };
} }
if (rest.length === 2) { if (rest.length === 2) {
@@ -65,6 +83,20 @@ export function resolveRoute(pathname: string): RouteMatch | null {
return null; return null;
} }
/** A listing's address in one language, as `resolveRoute` reads it back. */
export function listingPath(listing: ListingType, locale: Locale): string {
return `${locale === DEFAULT_LOCALE ? '' : `/${locale}`}/${LISTINGS[listing][locale]}`;
}
/** The same listing in every language, for `hreflang`. */
export function listingAlternates(
listing: ListingType,
): Record<string, string> {
return Object.fromEntries(
SUPPORTED_LOCALES.map((locale) => [locale, listingPath(listing, locale)]),
);
}
/** The landing itself in a given language. It answers on `?lang=`, not a prefix. */ /** The landing itself in a given language. It answers on `?lang=`, not a prefix. */
export function homeHref(locale: Locale): string { export function homeHref(locale: Locale): string {
return locale === DEFAULT_LOCALE ? '/' : `/?lang=${locale}`; return locale === DEFAULT_LOCALE ? '/' : `/?lang=${locale}`;

View File

@@ -18,6 +18,7 @@ import {
} from '@pi/ui'; } from '@pi/ui';
import { pageNavLinks } from '../core/routed-page'; import { pageNavLinks } from '../core/routed-page';
import { listingPath } from '../core/routes';
import { copyFor } from '../core/copy'; import { copyFor } from '../core/copy';
import { CallCard } from '../ui/call-card'; import { CallCard } from '../ui/call-card';
import { ClientLogo } from '../ui/client-logo'; import { ClientLogo } from '../ui/client-logo';
@@ -185,6 +186,9 @@ export const routeMeta: RouteMeta = {
</pi-card> </pi-card>
} }
</div> </div>
<p class="landing-section__more">
<a [href]="radarHref()">{{ copy().radar.seeAll }} →</a>
</p>
</app-section> </app-section>
} }
@@ -283,6 +287,8 @@ export default class HomePage {
protected readonly copy = computed(() => copyFor(this.data().locale)); protected readonly copy = computed(() => copyFor(this.data().locale));
protected readonly radarHref = computed(() => listingPath('radar', this.data().locale));
/** The switcher needs somewhere to go even if `/site` was unreachable. */ /** The switcher needs somewhere to go even if `/site` was unreachable. */
protected readonly locales = computed( protected readonly locales = computed(
() => () =>

View File

@@ -73,3 +73,18 @@
border-radius: 0.5rem; border-radius: 0.5rem;
} }
} }
// "See the full radar" under the section's summary of it.
.landing-section__more {
margin: 2rem 0 0;
a {
color: var(--pi-color-primary);
font-weight: 600;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}

View File

@@ -0,0 +1,146 @@
@use './detail';
:host {
display: block;
}
.radar-jump {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1.75rem;
a {
text-decoration: none;
}
}
.radar-body {
padding-bottom: 6rem;
}
.radar-ring {
// Clear of the sticky header when reached through an anchor.
scroll-margin-top: 5.5rem;
& + & {
margin-top: 4.5rem;
}
}
.radar-ring__head {
max-width: 42rem;
margin-bottom: 1.75rem;
h2 {
margin: 0;
font-size: clamp(1.75rem, 3.5vw, 2.25rem);
font-weight: 700;
letter-spacing: -0.015em;
}
p {
margin: 0.5rem 0 0;
}
}
.radar-grid {
display: grid;
gap: 1.25rem;
margin: 0;
padding: 0;
list-style: none;
@media (min-width: 640px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (min-width: 1024px) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
// `@pi/ui`'s reset spaces every `li + li`; this grid is spaced by `gap`.
> li {
margin: 0;
}
}
.radar-card {
display: flex;
flex-direction: column;
gap: 0.875rem;
padding: 1.5rem;
border-radius: var(--pi-theme-card-radius, 1.5rem);
box-shadow: var(--pi-theme-card-shadow, none);
}
.radar-card__top {
display: flex;
align-items: center;
gap: 1rem;
}
// Light in both themes, on purpose: see the component's comment.
.radar-logo {
display: grid;
flex: none;
place-items: center;
width: 3.5rem;
height: 3.5rem;
border-radius: 0.875rem;
background: #fff;
box-shadow:
0 0 0 1px
color-mix(in srgb, var(--brand, var(--pi-color-primary)) 22%, transparent),
0 6px 16px -8px
color-mix(in srgb, var(--brand, var(--pi-color-primary)) 55%, transparent);
}
.radar-logo ::ng-deep img {
display: block;
width: 2.25rem;
height: 2.25rem;
object-fit: contain;
}
.radar-logo__initial {
font-weight: 700;
font-size: 1.25rem;
color: var(--brand, var(--pi-color-primary));
}
.radar-card__name {
margin: 0;
font-size: 1.125rem;
font-weight: 700;
}
.radar-card__category {
margin: 0.125rem 0 0;
font-size: 0.8125rem;
}
.radar-card__text,
.radar-card__note {
margin: 0;
font-size: 0.9375rem;
line-height: 1.6;
}
.radar-card__note {
padding-left: 0.75rem;
border-left: 2px solid var(--pi-color-accent);
font-style: italic;
}
.radar-card__footer {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin: auto 0 0;
font-size: 0.875rem;
a {
color: var(--pi-color-primary);
}
}

View File

@@ -0,0 +1,161 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import {
PiChipComponent,
PiNoteComponent,
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
} from '@pi/ui';
import type { PublicTechnology } from '../core/api.types';
import type { Copy } from '../core/copy';
import { ApiImage } from './api-image';
type Ring = NonNullable<PublicTechnology['ring']>;
const RINGS: Ring[] = ['adopt', 'trial', 'assess', 'hold'];
/**
* The tech radar on its own address: every technology the collective has
* placed, by ring, with its logo, what it is and why it sits where it does.
*
* Logos sit on a light tile in both themes. A brand's own mark is not ours to
* recolour, and some — Rust's, black — would otherwise vanish in dark mode.
*/
@Component({
selector: 'app-radar-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ApiImage,
PiChipComponent,
PiNoteComponent,
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
],
styleUrl: './radar-page.scss',
template: `
<header class="content-hero">
<div class="landing-shell">
<p class="landing-eyebrow">{{ copy().radar.eyebrow }}</p>
<h1 bold class="content-hero__title">
{{ copy().sections.radar.title }}
</h1>
<p l pi-color color="grey" class="content-hero__lead">
{{ copy().radar.lead }}
</p>
@if (rings().length > 0) {
<nav class="radar-jump" [attr.aria-label]="copy().radar.eyebrow">
@for (group of rings(); track group.ring) {
<a [href]="'#' + group.ring">
<pi-chip
size="small"
appearance="outline"
[label]="
copy().rings[group.ring] + ' · ' + group.technologies.length
"
/>
</a>
}
</nav>
}
</div>
</header>
<div class="landing-shell radar-body">
@for (group of rings(); track group.ring) {
<section class="radar-ring" [id]="group.ring">
<header class="radar-ring__head">
<h2>{{ copy().rings[group.ring] }}</h2>
<p class="content-page__muted">
{{ copy().radar.ringLead[group.ring] }}
</p>
</header>
<ul class="radar-grid">
@for (technology of group.technologies; track technology.id) {
<li class="landing-card radar-card">
<div class="radar-card__top">
<span
class="radar-logo"
[style.--brand]="technology.color || null"
>
@if (technology.logo; as logo) {
<app-api-image
[image]="logo"
sizes="48px"
imgClass="radar-logo__image"
/>
} @else {
<span class="radar-logo__initial" aria-hidden="true">{{
technology.name[0]
}}</span>
}
</span>
<div>
<h3 class="radar-card__name">{{ technology.name }}</h3>
<p class="content-page__muted radar-card__category">
{{ copy().radar.categories[technology.category] }}
</p>
</div>
</div>
@if (technology.description; as description) {
<p class="radar-card__text">{{ description }}</p>
}
@if (technology.radar_note; as note) {
<p class="radar-card__note">{{ note }}</p>
}
@if (technology.website || technology.projects_count > 0) {
<p class="radar-card__footer content-page__muted">
@if (technology.website; as website) {
<a [href]="website" target="_blank" rel="noopener">{{
copy().radar.website
}}</a>
}
@if (technology.projects_count > 0) {
<span
>{{ technology.projects_count }}
{{ copy().radar.projects }}</span
>
}
</p>
}
</li>
}
</ul>
</section>
} @empty {
<pi-note
status="info"
appearance="fill"
gap="xs"
[title]="copy().radar.empty"
[text]="''"
/>
}
</div>
`,
})
export class RadarPage {
readonly technologies = input.required<PublicTechnology[]>();
readonly copy = input.required<Copy>();
/** The rings that have anything in them, in the radar's own order. */
protected readonly rings = computed(() =>
RINGS.map((ring) => ({
ring,
technologies: this.technologies().filter(
(technology) => technology.ring === ring,
),
})).filter((group) => group.technologies.length > 0),
);
}

View File

@@ -5,7 +5,10 @@ import {
input, input,
} from '@angular/core'; } from '@angular/core';
import { copyFor } from '../core/copy'; 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 { 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';
@@ -20,7 +23,14 @@ import { ProjectDetail } from './project-detail';
@Component({ @Component({
selector: 'app-routed-page', selector: 'app-routed-page',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CallDetail, ContentPage, MemberDetail, PageShell, ProjectDetail], imports: [
CallDetail,
ContentPage,
MemberDetail,
PageShell,
ProjectDetail,
RadarPage,
],
template: ` template: `
<app-page-shell [data]="data()" [seo]="seo()"> <app-page-shell [data]="data()" [seo]="seo()">
@if (data().found; as found) { @if (data().found; as found) {
@@ -47,6 +57,9 @@ import { ProjectDetail } from './project-detail';
[locale]="data().locale" [locale]="data().locale"
/> />
} }
@case ('radar') {
<app-radar-page [technologies]="found.record" [copy]="copy()" />
}
@case ('member') { @case ('member') {
<app-member-detail <app-member-detail
[member]="found.record" [member]="found.record"
@@ -64,11 +77,17 @@ export class RoutedPage {
protected readonly copy = computed(() => copyFor(this.data().locale)); protected readonly copy = computed(() => copyFor(this.data().locale));
protected readonly seo = computed(() => seoFor(this.data().found)); protected readonly seo = computed(() =>
seoFor(this.data().found, this.copy(), this.data().locale),
);
} }
/** What each type puts in the document head. Null when nothing was found. */ /** What each type puts in the document head. Null when nothing was found. */
export function seoFor(found: RoutedRecord | null): PageSeo | null { export function seoFor(
found: RoutedRecord | null,
copy: Copy,
locale: Locale,
): PageSeo | null {
if (!found) { if (!found) {
return null; return null;
} }
@@ -107,6 +126,14 @@ export function seoFor(found: RoutedRecord | null): PageSeo | null {
type: 'profile', type: 'profile',
}; };
} }
case 'radar':
return {
title: `Pireon — ${copy.sections.radar.title}`,
description: copy.radar.lead,
path: listingPath('radar', locale),
alternates: listingAlternates('radar'),
type: 'website',
};
case 'call': { case 'call': {
const call = found.record; const call = found.record;
const project = call.project?.title; const project = call.project?.title;