diff --git a/README.md b/README.md index 6e03b72..516441c 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,12 @@ named in that language: /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` 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 diff --git a/landing-e2e/src/landing.spec.ts b/landing-e2e/src/landing.spec.ts index f921803..037a737 100644 --- a/landing-e2e/src/landing.spec.ts +++ b/landing-e2e/src/landing.spec.ts @@ -50,11 +50,20 @@ test.describe('landing', () => { expect(response?.status()).toBe(404); 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 }) => { - for (const path of ['/proyectos/no-existe', '/en/team/nobody', '/ca/projectes/res']) { + test('answers 404 for a record the API does not have, in any section', async ({ + page, + }) => { + for (const path of [ + '/proyectos/no-existe', + '/en/team/nobody', + '/ca/projectes/res', + ]) { const response = await page.goto(path); 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 // address the API ever hands out. const response = await page.goto('/projects/anything'); @@ -70,6 +81,23 @@ test.describe('landing', () => { 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 ({ page, }) => { diff --git a/landing/src/app/core/copy.ts b/landing/src/app/core/copy.ts index 10bf299..c1d99d0 100644 --- a/landing/src/app/core/copy.ts +++ b/landing/src/app/core/copy.ts @@ -114,6 +114,19 @@ export interface Copy { links: 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: { eyebrow: string; seniority: Record<'any' | 'junior' | 'mid' | 'senior', string>; @@ -248,6 +261,28 @@ const es: Copy = { links: 'Enlaces', 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: { eyebrow: 'Convocatoria', seniority: { any: 'Cualquier nivel', junior: 'Junior', mid: 'Intermedio', senior: 'Senior' }, @@ -386,6 +421,28 @@ const en: Copy = { links: 'Links', 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: { eyebrow: 'Open call', seniority: { any: 'Any level', junior: 'Junior', mid: 'Mid-level', senior: 'Senior' }, @@ -524,6 +581,28 @@ const ca: Copy = { links: 'Enllaços', 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: { eyebrow: 'Convocatòria', seniority: { any: 'Qualsevol nivell', junior: 'Júnior', mid: 'Intermedi', senior: 'Sènior' }, diff --git a/landing/src/app/core/routed-page.ts b/landing/src/app/core/routed-page.ts index 5a60e92..e4a04bd 100644 --- a/landing/src/app/core/routed-page.ts +++ b/landing/src/app/core/routed-page.ts @@ -13,7 +13,13 @@ import { type MaintenanceNotice, type SitePayload, } 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'; /** 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: 'project'; record: PublicProject } | { type: 'member'; record: PublicMember } - | { type: 'call'; record: PublicCall }; + | { type: 'call'; record: PublicCall } + | { type: 'radar'; record: PublicTechnology[] }; export interface RoutedPageData { locale: Locale; @@ -74,6 +81,11 @@ async function fetchRecord(route: RouteMatch): Promise { const { data } = await pireonApi.call(locale, slug); 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: // 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. diff --git a/landing/src/app/core/routes.spec.ts b/landing/src/app/core/routes.spec.ts index 80c124e..79da41a 100644 --- a/landing/src/app/core/routes.spec.ts +++ b/landing/src/app/core/routes.spec.ts @@ -1,7 +1,13 @@ import { describe, expect, it } from 'vitest'; import { DEFAULT_LOCALE, SUPPORTED_LOCALES } from './api'; -import { homeHref, resolveRoute, SECTIONS } from './routes'; +import { + homeHref, + listingAlternates, + listingPath, + resolveRoute, + SECTIONS, +} from './routes'; describe('resolveRoute', () => { 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', () => { it('answers on ?lang= for every language but the default', () => { expect(homeHref(DEFAULT_LOCALE)).toBe('/'); diff --git a/landing/src/app/core/routes.ts b/landing/src/app/core/routes.ts index 123e17d..6103778 100644 --- a/landing/src/app/core/routes.ts +++ b/landing/src/app/core/routes.ts @@ -15,6 +15,9 @@ import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type Locale } from './api'; 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 * `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' }, }; +/** + * 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> = { + radar: { es: 'radar', en: 'radar', ca: 'radar' }, +}; + export interface RouteMatch { - type: RecordType; + type: RecordType | ListingType; locale: Locale; + /** Empty for a listing. */ slug: string; } @@ -51,7 +63,13 @@ export function resolveRoute(pathname: string): RouteMatch | null { const rest = prefixed ? segments.slice(1) : segments; 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) { @@ -65,6 +83,20 @@ export function resolveRoute(pathname: string): RouteMatch | 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 { + 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. */ export function homeHref(locale: Locale): string { return locale === DEFAULT_LOCALE ? '/' : `/?lang=${locale}`; diff --git a/landing/src/app/pages/(home).page.ts b/landing/src/app/pages/(home).page.ts index 605c104..789730a 100644 --- a/landing/src/app/pages/(home).page.ts +++ b/landing/src/app/pages/(home).page.ts @@ -18,6 +18,7 @@ import { } from '@pi/ui'; import { pageNavLinks } from '../core/routed-page'; +import { listingPath } from '../core/routes'; import { copyFor } from '../core/copy'; import { CallCard } from '../ui/call-card'; import { ClientLogo } from '../ui/client-logo'; @@ -185,6 +186,9 @@ export const routeMeta: RouteMeta = { } +

+ {{ copy().radar.seeAll }} → +

} @@ -283,6 +287,8 @@ export default class HomePage { 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. */ protected readonly locales = computed( () => diff --git a/landing/src/app/pages/home.page.scss b/landing/src/app/pages/home.page.scss index e8b995c..d1c720e 100644 --- a/landing/src/app/pages/home.page.scss +++ b/landing/src/app/pages/home.page.scss @@ -73,3 +73,18 @@ 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; + } + } +} diff --git a/landing/src/app/ui/radar-page.scss b/landing/src/app/ui/radar-page.scss new file mode 100644 index 0000000..69211e9 --- /dev/null +++ b/landing/src/app/ui/radar-page.scss @@ -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); + } +} diff --git a/landing/src/app/ui/radar-page.ts b/landing/src/app/ui/radar-page.ts new file mode 100644 index 0000000..6a0e00c --- /dev/null +++ b/landing/src/app/ui/radar-page.ts @@ -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; + +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: ` +
+
+

{{ copy().radar.eyebrow }}

+

+ {{ copy().sections.radar.title }} +

+

+ {{ copy().radar.lead }} +

+ + @if (rings().length > 0) { + + } +
+
+ +
+ @for (group of rings(); track group.ring) { +
+
+

{{ copy().rings[group.ring] }}

+

+ {{ copy().radar.ringLead[group.ring] }} +

+
+ +
    + @for (technology of group.technologies; track technology.id) { +
  • +
    + +
    +

    {{ technology.name }}

    +

    + {{ copy().radar.categories[technology.category] }} +

    +
    +
    + + @if (technology.description; as description) { +

    {{ description }}

    + } + + @if (technology.radar_note; as note) { +

    {{ note }}

    + } + + @if (technology.website || technology.projects_count > 0) { + + } +
  • + } +
+
+ } @empty { + + } +
+ `, +}) +export class RadarPage { + readonly technologies = input.required(); + readonly copy = input.required(); + + /** 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), + ); +} diff --git a/landing/src/app/ui/routed-page.ts b/landing/src/app/ui/routed-page.ts index f934381..e580909 100644 --- a/landing/src/app/ui/routed-page.ts +++ b/landing/src/app/ui/routed-page.ts @@ -5,7 +5,10 @@ import { input, } 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 type { RoutedPageData, RoutedRecord } from '../core/routed-page'; import { ContentPage } from './content-page'; @@ -20,7 +23,14 @@ import { ProjectDetail } from './project-detail'; @Component({ selector: 'app-routed-page', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [CallDetail, ContentPage, MemberDetail, PageShell, ProjectDetail], + imports: [ + CallDetail, + ContentPage, + MemberDetail, + PageShell, + ProjectDetail, + RadarPage, + ], template: ` @if (data().found; as found) { @@ -47,6 +57,9 @@ import { ProjectDetail } from './project-detail'; [locale]="data().locale" /> } + @case ('radar') { + + } @case ('member') { 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. */ -export function seoFor(found: RoutedRecord | null): PageSeo | null { +export function seoFor( + found: RoutedRecord | null, + copy: Copy, + locale: Locale, +): PageSeo | null { if (!found) { return null; } @@ -107,6 +126,14 @@ export function seoFor(found: RoutedRecord | null): PageSeo | null { 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': { const call = found.record; const project = call.project?.title;