Give every technology a page of its own
A technology's name appeared in nine places -- the chips on project,
call, service and member cards and detail pages, the landing's ring
cards, the radar -- and not one was clickable. Now each leads to
`/tecnologias/:slug`, with the logo, what it is, where the collective
places it on the radar and a link to its own site.
Two constraints from the API shaped this, both verified against it:
Its address is the site's own. Technologies carry no `path` and no
`alternates`, and `/slugs` cannot list them -- its type vocabulary is a
closed enum. `routes.ts` opens by stating the API decides every record's
address; this is the first type where that is not true, so `SECTIONS`
gains the one entry and the comment says why. The sitemap composes these
URLs locally for the same reason, and an unreachable technologies call
there is not fatal the way `/slugs` is: it costs a handful of URLs
rather than misrepresenting the site.
There is no detail endpoint. `GET /technologies/{slug}` answers "route
not found" -- the route does not exist. `pireonApi.technology` filters
the collection instead, passing `in_use: false` because the default
returns only technologies attached to a project, which today is none.
An outage and an unknown slug stay distinguishable so the first is not
reported as a 404.
`website` is the only link the payload has: the project's own home page,
where its documentation lives. There is no separate docs field, so the
label says what the link is.
The chip markup was identical in all nine places, so it becomes one
`app-technology-chip` rather than nine anchors. `project-card` and
`radar-page` gain a `locale` input, which is all they lacked to address
it.
Verified: lint, 54 unit tests, build and 11 e2e. Against the mock, the
three addresses answer 200 and an unknown slug 404; the switcher moves
between them; canonical and hreflang are right; the sitemap lists all
three per technology.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -108,6 +108,15 @@ test.describe('landing', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('answers 404 for a technology it does not know', async ({ page }) => {
|
||||||
|
// Shaped like a technology address, so the middleware lets it through and
|
||||||
|
// the status comes from the page once the collection had no such slug.
|
||||||
|
const response = await page.goto('/tecnologias/no-such-technology');
|
||||||
|
|
||||||
|
expect(response?.status()).toBe(404);
|
||||||
|
await expect(page.locator('h1')).toContainText('no existe');
|
||||||
|
});
|
||||||
|
|
||||||
test('refuses an incomplete contact submission without calling the API', async ({
|
test('refuses an incomplete contact submission without calling the API', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -238,6 +238,39 @@ export const pireonApi = {
|
|||||||
technologies: (lang: Locale, query?: GetOptions['query']) =>
|
technologies: (lang: Locale, query?: GetOptions['query']) =>
|
||||||
list<PublicTechnology>('/technologies', { lang, query }),
|
list<PublicTechnology>('/technologies', { lang, query }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One technology, by slug.
|
||||||
|
*
|
||||||
|
* The public API has no `/technologies/{slug}` — that route does not exist,
|
||||||
|
* so asking for one answers "route not found" rather than "no such record".
|
||||||
|
* This filters the collection instead: a couple of dozen records in a single
|
||||||
|
* request the route's `s-maxage` already caches.
|
||||||
|
*
|
||||||
|
* `in_use: false` because the parameter defaults to true, which returns only
|
||||||
|
* technologies attached to a project — none of them, today.
|
||||||
|
*
|
||||||
|
* A failed call and an unknown slug have to stay distinguishable: the first
|
||||||
|
* is an outage, the second is a 404. So the collection's `error` is passed
|
||||||
|
* through untouched and only a successful, complete answer can conclude that
|
||||||
|
* a technology does not exist.
|
||||||
|
*/
|
||||||
|
technology: async (
|
||||||
|
lang: Locale,
|
||||||
|
slug: string
|
||||||
|
): Promise<ApiResult<PublicTechnology>> => {
|
||||||
|
const all = await list<PublicTechnology>('/technologies', {
|
||||||
|
lang,
|
||||||
|
query: { in_use: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...all,
|
||||||
|
data: all.error
|
||||||
|
? null
|
||||||
|
: (all.data?.find((technology) => technology.slug === slug) ?? null),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
goodFirstIssues: (query?: GetOptions['query']) =>
|
goodFirstIssues: (query?: GetOptions['query']) =>
|
||||||
list<PublicIssue>('/good-first-issues', { query }),
|
list<PublicIssue>('/good-first-issues', { query }),
|
||||||
|
|
||||||
|
|||||||
@@ -143,6 +143,14 @@ export interface Copy {
|
|||||||
testimonials: string;
|
testimonials: string;
|
||||||
technologies: string;
|
technologies: string;
|
||||||
};
|
};
|
||||||
|
technology: {
|
||||||
|
eyebrow: string;
|
||||||
|
ringTitle: string;
|
||||||
|
website: string;
|
||||||
|
usedIn: string;
|
||||||
|
membersUsing: string;
|
||||||
|
back: string;
|
||||||
|
};
|
||||||
radar: {
|
radar: {
|
||||||
eyebrow: string;
|
eyebrow: string;
|
||||||
lead: string;
|
lead: string;
|
||||||
@@ -325,6 +333,14 @@ const es: Copy = {
|
|||||||
testimonials: 'Qué dicen',
|
testimonials: 'Qué dicen',
|
||||||
technologies: 'Con qué lo hacemos',
|
technologies: 'Con qué lo hacemos',
|
||||||
},
|
},
|
||||||
|
technology: {
|
||||||
|
eyebrow: 'Tecnología',
|
||||||
|
ringTitle: 'En el radar',
|
||||||
|
website: 'Web oficial',
|
||||||
|
usedIn: 'proyectos la usan',
|
||||||
|
membersUsing: 'personas la usan',
|
||||||
|
back: 'Ver el radar completo',
|
||||||
|
},
|
||||||
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.',
|
||||||
@@ -520,6 +536,14 @@ const en: Copy = {
|
|||||||
testimonials: 'What people say',
|
testimonials: 'What people say',
|
||||||
technologies: 'What we use',
|
technologies: 'What we use',
|
||||||
},
|
},
|
||||||
|
technology: {
|
||||||
|
eyebrow: 'Technology',
|
||||||
|
ringTitle: 'On the radar',
|
||||||
|
website: 'Official site',
|
||||||
|
usedIn: 'projects use it',
|
||||||
|
membersUsing: 'people use it',
|
||||||
|
back: 'See the whole radar',
|
||||||
|
},
|
||||||
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.',
|
||||||
@@ -715,6 +739,14 @@ const ca: Copy = {
|
|||||||
testimonials: 'Què en diuen',
|
testimonials: 'Què en diuen',
|
||||||
technologies: 'Amb què ho fem',
|
technologies: 'Amb què ho fem',
|
||||||
},
|
},
|
||||||
|
technology: {
|
||||||
|
eyebrow: 'Tecnologia',
|
||||||
|
ringTitle: 'Al radar',
|
||||||
|
website: 'Web oficial',
|
||||||
|
usedIn: 'projectes la fan servir',
|
||||||
|
membersUsing: 'persones la fan servir',
|
||||||
|
back: 'Veure el radar complet',
|
||||||
|
},
|
||||||
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.",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export type RoutedRecord =
|
|||||||
| { type: 'member'; record: PublicMember }
|
| { type: 'member'; record: PublicMember }
|
||||||
| { type: 'call'; record: PublicCall }
|
| { type: 'call'; record: PublicCall }
|
||||||
| { type: 'service'; record: PublicService }
|
| { type: 'service'; record: PublicService }
|
||||||
|
| { type: 'technology'; record: PublicTechnology }
|
||||||
| { type: 'radar'; record: PublicTechnology[] }
|
| { type: 'radar'; record: PublicTechnology[] }
|
||||||
| { type: 'blog'; record: PublicPage[] }
|
| { type: 'blog'; record: PublicPage[] }
|
||||||
| {
|
| {
|
||||||
@@ -94,6 +95,10 @@ async function fetchRecord(route: RouteMatch): Promise<RoutedRecord | null> {
|
|||||||
const { data } = await pireonApi.service(locale, slug);
|
const { data } = await pireonApi.service(locale, slug);
|
||||||
return data ? { type: 'service', record: data } : null;
|
return data ? { type: 'service', record: data } : null;
|
||||||
}
|
}
|
||||||
|
case 'technology': {
|
||||||
|
const { data } = await pireonApi.technology(locale, slug);
|
||||||
|
return data ? { type: 'technology', record: data } : null;
|
||||||
|
}
|
||||||
case 'blog': {
|
case 'blog': {
|
||||||
const { data, error } = await pireonApi.pages(locale, {
|
const { data, error } = await pireonApi.pages(locale, {
|
||||||
category: BLOG_CATEGORY[locale],
|
category: BLOG_CATEGORY[locale],
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
listingPath,
|
listingPath,
|
||||||
resolveRoute,
|
resolveRoute,
|
||||||
SECTIONS,
|
SECTIONS,
|
||||||
|
technologyAlternates,
|
||||||
|
technologyPath,
|
||||||
} from './routes';
|
} from './routes';
|
||||||
|
|
||||||
describe('resolveRoute', () => {
|
describe('resolveRoute', () => {
|
||||||
@@ -121,6 +123,48 @@ describe('listings', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('technologies', () => {
|
||||||
|
it('reads one under its section, in each language', () => {
|
||||||
|
expect(resolveRoute('/tecnologias/angular')).toEqual({
|
||||||
|
type: 'technology',
|
||||||
|
locale: 'es',
|
||||||
|
slug: 'angular',
|
||||||
|
});
|
||||||
|
expect(resolveRoute('/en/technologies/angular')).toEqual({
|
||||||
|
type: 'technology',
|
||||||
|
locale: 'en',
|
||||||
|
slug: 'angular',
|
||||||
|
});
|
||||||
|
expect(resolveRoute('/ca/tecnologies/angular')).toEqual({
|
||||||
|
type: 'technology',
|
||||||
|
locale: 'ca',
|
||||||
|
slug: 'angular',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds the address resolveRoute reads back', () => {
|
||||||
|
for (const locale of SUPPORTED_LOCALES) {
|
||||||
|
const path = technologyPath('angular', locale);
|
||||||
|
|
||||||
|
expect(resolveRoute(path)).toEqual({
|
||||||
|
type: 'technology',
|
||||||
|
locale,
|
||||||
|
slug: 'angular',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// The slug is a proper noun the API does not translate, so the three
|
||||||
|
// addresses differ only by the prefix.
|
||||||
|
it('keeps the same slug in every language', () => {
|
||||||
|
expect(technologyAlternates('angular')).toEqual({
|
||||||
|
es: '/tecnologias/angular',
|
||||||
|
en: '/en/technologies/angular',
|
||||||
|
ca: '/ca/tecnologies/angular',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
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('/');
|
||||||
|
|||||||
@@ -13,7 +13,13 @@
|
|||||||
*/
|
*/
|
||||||
import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type Locale } from './api';
|
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'
|
||||||
|
| 'technology';
|
||||||
|
|
||||||
/** Pages this site composes itself rather than one record's: no slug. */
|
/** Pages this site composes itself rather than one record's: no slug. */
|
||||||
export type ListingType = 'radar' | 'contribute' | 'blog';
|
export type ListingType = 'radar' | 'contribute' | 'blog';
|
||||||
@@ -31,6 +37,11 @@ export const SECTIONS: Record<
|
|||||||
member: { es: 'equipo', en: 'team', ca: 'equip' },
|
member: { es: 'equipo', en: 'team', ca: 'equip' },
|
||||||
call: { es: 'colabora', en: 'contribute', ca: 'col-labora' },
|
call: { es: 'colabora', en: 'contribute', ca: 'col-labora' },
|
||||||
service: { es: 'servicios', en: 'services', ca: 'serveis' },
|
service: { es: 'servicios', en: 'services', ca: 'serveis' },
|
||||||
|
// The one section the API does not know about. It gives technologies no
|
||||||
|
// `path` and no `alternates`, and `/slugs` cannot carry them — its type
|
||||||
|
// vocabulary is a closed enum. So this address is the site's own. Should the
|
||||||
|
// API ever publish one, this is where the two have to be reconciled.
|
||||||
|
technology: { es: 'tecnologias', en: 'technologies', ca: 'tecnologies' },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -100,6 +111,26 @@ export function listingAlternates(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A technology's address in one language, as `resolveRoute` reads it back.
|
||||||
|
*
|
||||||
|
* Unlike every other record, the slug is the same in all three: a technology's
|
||||||
|
* name is a proper noun, so the API does not translate it and the addresses
|
||||||
|
* differ only by the prefix.
|
||||||
|
*/
|
||||||
|
export function technologyPath(slug: string, locale: Locale): string {
|
||||||
|
const prefix = locale === DEFAULT_LOCALE ? '' : `/${locale}`;
|
||||||
|
|
||||||
|
return `${prefix}/${SECTIONS.technology[locale]}/${slug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The same technology in every language, for `hreflang`. */
|
||||||
|
export function technologyAlternates(slug: string): Record<string, string> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
SUPPORTED_LOCALES.map((locale) => [locale, technologyPath(slug, 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}`;
|
||||||
|
|||||||
@@ -14,7 +14,16 @@ const xml = buildSitemap('https://pireon.dev', {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
}, [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Angular',
|
||||||
|
slug: 'angular',
|
||||||
|
category: 'framework',
|
||||||
|
projects_count: 0,
|
||||||
|
members_count: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
describe('buildSitemap', () => {
|
describe('buildSitemap', () => {
|
||||||
it('lists every language of a record as its own url', () => {
|
it('lists every language of a record as its own url', () => {
|
||||||
@@ -41,6 +50,21 @@ describe('buildSitemap', () => {
|
|||||||
expect(xml).toContain('<loc>https://pireon.dev/ca/col-labora</loc>');
|
expect(xml).toContain('<loc>https://pireon.dev/ca/col-labora</loc>');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The API gives technologies no address and `/slugs` cannot carry them, so
|
||||||
|
// these are composed here and have to be in the sitemap all the same.
|
||||||
|
it('includes the technology pages this site composes', () => {
|
||||||
|
expect(xml).toContain('<loc>https://pireon.dev/tecnologias/angular</loc>');
|
||||||
|
expect(xml).toContain(
|
||||||
|
'<loc>https://pireon.dev/en/technologies/angular</loc>',
|
||||||
|
);
|
||||||
|
expect(xml).toContain(
|
||||||
|
'<loc>https://pireon.dev/ca/tecnologies/angular</loc>',
|
||||||
|
);
|
||||||
|
expect(xml).toContain(
|
||||||
|
'hreflang="ca" href="https://pireon.dev/ca/tecnologies/angular"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('is a well-formed document', () => {
|
it('is a well-formed document', () => {
|
||||||
const document = new DOMParser().parseFromString(xml, 'application/xml');
|
const document = new DOMParser().parseFromString(xml, 'application/xml');
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,12 @@
|
|||||||
* is not fetched first.
|
* is not fetched first.
|
||||||
*/
|
*/
|
||||||
import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type SlugEntry } from './api';
|
import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type SlugEntry } from './api';
|
||||||
|
import type { PublicTechnology } from './api.types';
|
||||||
import {
|
import {
|
||||||
homeHref,
|
homeHref,
|
||||||
listingAlternates,
|
listingAlternates,
|
||||||
LISTINGS,
|
LISTINGS,
|
||||||
|
technologyAlternates,
|
||||||
type ListingType,
|
type ListingType,
|
||||||
} from './routes';
|
} from './routes';
|
||||||
|
|
||||||
@@ -22,6 +24,7 @@ interface SitemapEntry {
|
|||||||
export function buildSitemap(
|
export function buildSitemap(
|
||||||
origin: string,
|
origin: string,
|
||||||
slugs: Record<string, SlugEntry[]>,
|
slugs: Record<string, SlugEntry[]>,
|
||||||
|
technologies: PublicTechnology[] = [],
|
||||||
): string {
|
): string {
|
||||||
const entries: SitemapEntry[] = [
|
const entries: SitemapEntry[] = [
|
||||||
// The landing answers on `?lang=`, not a prefix.
|
// The landing answers on `?lang=`, not a prefix.
|
||||||
@@ -34,6 +37,12 @@ export function buildSitemap(
|
|||||||
alternates: listingAlternates(listing),
|
alternates: listingAlternates(listing),
|
||||||
})),
|
})),
|
||||||
...Object.values(slugs).flat(),
|
...Object.values(slugs).flat(),
|
||||||
|
// Technologies are absent from `/slugs` — the API gives them no address,
|
||||||
|
// so this site composes theirs. No `<lastmod>`: the payload has no
|
||||||
|
// `updated_at` to report.
|
||||||
|
...technologies.map((technology) => ({
|
||||||
|
alternates: technologyAlternates(technology.slug),
|
||||||
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
const urls = entries.flatMap((entry) =>
|
const urls = entries.flatMap((entry) =>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { listingPath } from '../core/routes';
|
|||||||
import { copyFor } from '../core/copy';
|
import { copyFor } from '../core/copy';
|
||||||
import { absoluteUrl, setShareImage, setStructuredData } from '../core/head';
|
import { absoluteUrl, setShareImage, setStructuredData } from '../core/head';
|
||||||
import { originFromHeaders } from '../core/origin';
|
import { originFromHeaders } from '../core/origin';
|
||||||
|
import { TechnologyChip } from '../ui/technology-chip';
|
||||||
import { CallCard } from '../ui/call-card';
|
import { CallCard } from '../ui/call-card';
|
||||||
import { ClientLogo } from '../ui/client-logo';
|
import { ClientLogo } from '../ui/client-logo';
|
||||||
import { ContactForm } from '../ui/contact-form';
|
import { ContactForm } from '../ui/contact-form';
|
||||||
@@ -72,6 +73,7 @@ export const routeMeta: RouteMeta = {
|
|||||||
SiteHeader,
|
SiteHeader,
|
||||||
TalkCard,
|
TalkCard,
|
||||||
TestimonialCarousel,
|
TestimonialCarousel,
|
||||||
|
TechnologyChip,
|
||||||
],
|
],
|
||||||
styleUrl: './home.page.scss',
|
styleUrl: './home.page.scss',
|
||||||
template: `
|
template: `
|
||||||
@@ -123,7 +125,11 @@ export const routeMeta: RouteMeta = {
|
|||||||
>
|
>
|
||||||
<div class="landing-grid">
|
<div class="landing-grid">
|
||||||
@for (project of data().projects; track project.id) {
|
@for (project of data().projects; track project.id) {
|
||||||
<app-project-card [project]="project" [copy]="copy()" />
|
<app-project-card
|
||||||
|
[project]="project"
|
||||||
|
[copy]="copy()"
|
||||||
|
[locale]="data().locale"
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</app-section>
|
</app-section>
|
||||||
@@ -176,11 +182,10 @@ export const routeMeta: RouteMeta = {
|
|||||||
<ul class="landing-card__chips">
|
<ul class="landing-card__chips">
|
||||||
@for (technology of group.technologies; track technology.id) {
|
@for (technology of group.technologies; track technology.id) {
|
||||||
<li>
|
<li>
|
||||||
<pi-chip
|
<app-technology-chip
|
||||||
size="small"
|
[technology]="technology"
|
||||||
appearance="outline"
|
[locale]="data().locale"
|
||||||
[label]="technology.name"
|
/>
|
||||||
/>
|
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import type { Copy } from '../core/copy';
|
import type { Copy } from '../core/copy';
|
||||||
import type { Locale } from '../core/api';
|
import type { Locale } from '../core/api';
|
||||||
import type { PublicCall } from '../core/api.types';
|
import type { PublicCall } from '../core/api.types';
|
||||||
|
import { TechnologyChip } from './technology-chip';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-call-card',
|
selector: 'app-call-card',
|
||||||
@@ -30,6 +31,7 @@ import type { PublicCall } from '../core/api.types';
|
|||||||
PiTextXSmallDirective,
|
PiTextXSmallDirective,
|
||||||
PiTextColorDirective,
|
PiTextColorDirective,
|
||||||
PiTextWeightBoldDirective,
|
PiTextWeightBoldDirective,
|
||||||
|
TechnologyChip,
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<pi-card
|
<pi-card
|
||||||
@@ -74,7 +76,10 @@ import type { PublicCall } from '../core/api.types';
|
|||||||
<ul class="landing-card__chips">
|
<ul class="landing-card__chips">
|
||||||
@for (technology of call().technologies; track technology.id) {
|
@for (technology of call().technologies; track technology.id) {
|
||||||
<li>
|
<li>
|
||||||
<pi-chip size="small" appearance="outline" [label]="technology.name" />
|
<app-technology-chip
|
||||||
|
[technology]="technology"
|
||||||
|
[locale]="locale()"
|
||||||
|
/>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type { PublicCall } from '../core/api.types';
|
|||||||
import type { Copy } from '../core/copy';
|
import type { Copy } from '../core/copy';
|
||||||
import { homeHref } from '../core/routes';
|
import { homeHref } from '../core/routes';
|
||||||
import { ContactForm } from './contact-form';
|
import { ContactForm } from './contact-form';
|
||||||
|
import { TechnologyChip } from './technology-chip';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A call for help on its own address, with the form to answer it.
|
* A call for help on its own address, with the form to answer it.
|
||||||
@@ -36,6 +37,7 @@ import { ContactForm } from './contact-form';
|
|||||||
PiTextColorDirective,
|
PiTextColorDirective,
|
||||||
PiTextLargeDirective,
|
PiTextLargeDirective,
|
||||||
PiTextWeightBoldDirective,
|
PiTextWeightBoldDirective,
|
||||||
|
TechnologyChip,
|
||||||
],
|
],
|
||||||
styleUrl: './call-detail.scss',
|
styleUrl: './call-detail.scss',
|
||||||
template: `
|
template: `
|
||||||
@@ -82,10 +84,9 @@ import { ContactForm } from './contact-form';
|
|||||||
}
|
}
|
||||||
@for (technology of call().technologies ?? []; track technology.id) {
|
@for (technology of call().technologies ?? []; track technology.id) {
|
||||||
<li>
|
<li>
|
||||||
<pi-chip
|
<app-technology-chip
|
||||||
size="small"
|
[technology]="technology"
|
||||||
appearance="outline"
|
[locale]="locale()"
|
||||||
[label]="technology.name"
|
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type { Copy } from '../core/copy';
|
|||||||
import { ContactCta } from './contact-cta';
|
import { ContactCta } from './contact-cta';
|
||||||
import { memberLinks } from './member-card';
|
import { memberLinks } from './member-card';
|
||||||
import { ProjectCard } from './project-card';
|
import { ProjectCard } from './project-card';
|
||||||
|
import { TechnologyChip } from './technology-chip';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Someone in the collective, on their own address: who they are, what they
|
* Someone in the collective, on their own address: who they are, what they
|
||||||
@@ -37,6 +38,7 @@ import { ProjectCard } from './project-card';
|
|||||||
PiTextLargeDirective,
|
PiTextLargeDirective,
|
||||||
PiTextWeightBoldDirective,
|
PiTextWeightBoldDirective,
|
||||||
ProjectCard,
|
ProjectCard,
|
||||||
|
TechnologyChip,
|
||||||
],
|
],
|
||||||
styleUrl: './member-detail.scss',
|
styleUrl: './member-detail.scss',
|
||||||
template: `
|
template: `
|
||||||
@@ -100,7 +102,11 @@ import { ProjectCard } from './project-card';
|
|||||||
<h2>{{ copy().member.projects }}</h2>
|
<h2>{{ copy().member.projects }}</h2>
|
||||||
<div class="member-projects">
|
<div class="member-projects">
|
||||||
@for (project of member().projects; track project.id) {
|
@for (project of member().projects; track project.id) {
|
||||||
<app-project-card [project]="project" [copy]="copy()" />
|
<app-project-card
|
||||||
|
[project]="project"
|
||||||
|
[copy]="copy()"
|
||||||
|
[locale]="locale()"
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -146,11 +152,10 @@ import { ProjectCard } from './project-card';
|
|||||||
<ul class="content-hero__chips member-technologies">
|
<ul class="content-hero__chips member-technologies">
|
||||||
@for (technology of member().technologies; track technology.id) {
|
@for (technology of member().technologies; track technology.id) {
|
||||||
<li>
|
<li>
|
||||||
<pi-chip
|
<app-technology-chip
|
||||||
size="small"
|
[technology]="technology"
|
||||||
appearance="outline"
|
[locale]="locale()"
|
||||||
[label]="technology.name"
|
/>
|
||||||
/>
|
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import {
|
|||||||
PiTextWeightBoldDirective,
|
PiTextWeightBoldDirective,
|
||||||
} from '@pi/ui';
|
} from '@pi/ui';
|
||||||
|
|
||||||
|
import type { Locale } from '../core/api';
|
||||||
import type { Copy } from '../core/copy';
|
import type { Copy } from '../core/copy';
|
||||||
import type { PublicProject } from '../core/api.types';
|
import type { PublicProject } from '../core/api.types';
|
||||||
|
import { TechnologyChip } from './technology-chip';
|
||||||
import { ApiImage } from './api-image';
|
import { ApiImage } from './api-image';
|
||||||
|
|
||||||
/** The API's five project states onto the status chip's five tones. */
|
/** The API's five project states onto the status chip's five tones. */
|
||||||
@@ -43,6 +45,7 @@ export const PROJECT_STATUS_TONES: Record<
|
|||||||
PiTextSmallDirective,
|
PiTextSmallDirective,
|
||||||
PiTextColorDirective,
|
PiTextColorDirective,
|
||||||
PiTextWeightBoldDirective,
|
PiTextWeightBoldDirective,
|
||||||
|
TechnologyChip,
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<pi-card
|
<pi-card
|
||||||
@@ -96,7 +99,10 @@ export const PROJECT_STATUS_TONES: Record<
|
|||||||
<ul class="landing-card__chips">
|
<ul class="landing-card__chips">
|
||||||
@for (technology of technologies(); track technology.id) {
|
@for (technology of technologies(); track technology.id) {
|
||||||
<li>
|
<li>
|
||||||
<pi-chip size="small" appearance="outline" [label]="technology.name" />
|
<app-technology-chip
|
||||||
|
[technology]="technology"
|
||||||
|
[locale]="locale()"
|
||||||
|
/>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -131,6 +137,8 @@ export const PROJECT_STATUS_TONES: Record<
|
|||||||
export class ProjectCard {
|
export class ProjectCard {
|
||||||
readonly project = input.required<PublicProject>();
|
readonly project = input.required<PublicProject>();
|
||||||
readonly copy = input.required<Copy>();
|
readonly copy = input.required<Copy>();
|
||||||
|
/** Only to address the technology chips, which link to their own pages. */
|
||||||
|
readonly locale = input.required<Locale>();
|
||||||
|
|
||||||
/** Enough to read the stack at a glance; the rest is noise on a card. */
|
/** Enough to read the stack at a glance; the rest is noise on a card. */
|
||||||
protected readonly technologies = computed(() =>
|
protected readonly technologies = computed(() =>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type { Copy } from '../core/copy';
|
|||||||
import { ApiImage } from './api-image';
|
import { ApiImage } from './api-image';
|
||||||
import { ContactCta } from './contact-cta';
|
import { ContactCta } from './contact-cta';
|
||||||
import { PROJECT_STATUS_TONES } from './project-card';
|
import { PROJECT_STATUS_TONES } from './project-card';
|
||||||
|
import { TechnologyChip } from './technology-chip';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A project on its own address: what it is, why it was built the way it was,
|
* A project on its own address: what it is, why it was built the way it was,
|
||||||
@@ -41,6 +42,7 @@ import { PROJECT_STATUS_TONES } from './project-card';
|
|||||||
PiTextColorDirective,
|
PiTextColorDirective,
|
||||||
PiTextLargeDirective,
|
PiTextLargeDirective,
|
||||||
PiTextWeightBoldDirective,
|
PiTextWeightBoldDirective,
|
||||||
|
TechnologyChip,
|
||||||
],
|
],
|
||||||
styleUrl: './project-detail.scss',
|
styleUrl: './project-detail.scss',
|
||||||
template: `
|
template: `
|
||||||
@@ -79,10 +81,9 @@ import { PROJECT_STATUS_TONES } from './project-card';
|
|||||||
track technology.id
|
track technology.id
|
||||||
) {
|
) {
|
||||||
<li>
|
<li>
|
||||||
<pi-chip
|
<app-technology-chip
|
||||||
size="small"
|
[technology]="technology"
|
||||||
appearance="outline"
|
[locale]="locale()"
|
||||||
[label]="technology.name"
|
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,3 +144,19 @@
|
|||||||
color: var(--pi-color-primary);
|
color: var(--pi-color-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The card's logo and name lead to the technology's own page.
|
||||||
|
.radar-card__top {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
|
||||||
|
&:hover .radar-card__name {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--pi-color-primary);
|
||||||
|
outline-offset: 4px;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ import {
|
|||||||
} from '@pi/ui';
|
} from '@pi/ui';
|
||||||
|
|
||||||
import type { PublicTechnology } from '../core/api.types';
|
import type { PublicTechnology } from '../core/api.types';
|
||||||
|
import type { Locale } from '../core/api';
|
||||||
import type { Copy } from '../core/copy';
|
import type { Copy } from '../core/copy';
|
||||||
|
import { technologyPath } from '../core/routes';
|
||||||
import { ApiImage } from './api-image';
|
import { ApiImage } from './api-image';
|
||||||
|
|
||||||
type Ring = NonNullable<PublicTechnology['ring']>;
|
type Ring = NonNullable<PublicTechnology['ring']>;
|
||||||
@@ -81,7 +83,7 @@ const RINGS: Ring[] = ['adopt', 'trial', 'assess', 'hold'];
|
|||||||
<ul class="radar-grid">
|
<ul class="radar-grid">
|
||||||
@for (technology of group.technologies; track technology.id) {
|
@for (technology of group.technologies; track technology.id) {
|
||||||
<li class="landing-card radar-card">
|
<li class="landing-card radar-card">
|
||||||
<div class="radar-card__top">
|
<a class="radar-card__top" [href]="href(technology)">
|
||||||
<span
|
<span
|
||||||
class="radar-logo"
|
class="radar-logo"
|
||||||
[style.--brand]="technology.color || null"
|
[style.--brand]="technology.color || null"
|
||||||
@@ -104,7 +106,7 @@ const RINGS: Ring[] = ['adopt', 'trial', 'assess', 'hold'];
|
|||||||
{{ copy().radar.categories[technology.category] }}
|
{{ copy().radar.categories[technology.category] }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
|
|
||||||
@if (technology.description; as description) {
|
@if (technology.description; as description) {
|
||||||
<p class="radar-card__text">{{ description }}</p>
|
<p class="radar-card__text">{{ description }}</p>
|
||||||
@@ -148,6 +150,13 @@ const RINGS: Ring[] = ['adopt', 'trial', 'assess', 'hold'];
|
|||||||
export class RadarPage {
|
export class RadarPage {
|
||||||
readonly technologies = input.required<PublicTechnology[]>();
|
readonly technologies = input.required<PublicTechnology[]>();
|
||||||
readonly copy = input.required<Copy>();
|
readonly copy = input.required<Copy>();
|
||||||
|
/** Only to address each technology's own page. */
|
||||||
|
readonly locale = input.required<Locale>();
|
||||||
|
|
||||||
|
/** Each card leads to the technology it names. */
|
||||||
|
protected href(technology: PublicTechnology): string {
|
||||||
|
return technologyPath(technology.slug, this.locale());
|
||||||
|
}
|
||||||
|
|
||||||
/** The rings that have anything in them, in the radar's own order. */
|
/** The rings that have anything in them, in the radar's own order. */
|
||||||
protected readonly rings = computed(() =>
|
protected readonly rings = computed(() =>
|
||||||
|
|||||||
@@ -6,12 +6,18 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
|
||||||
import { copyFor, type Copy } from '../core/copy';
|
import { copyFor, type Copy } from '../core/copy';
|
||||||
import { listingAlternates, listingPath } from '../core/routes';
|
import {
|
||||||
|
listingAlternates,
|
||||||
|
listingPath,
|
||||||
|
technologyAlternates,
|
||||||
|
technologyPath,
|
||||||
|
} from '../core/routes';
|
||||||
import { memberLinks } from './member-card';
|
import { memberLinks } from './member-card';
|
||||||
import { isBlogPost } from '../core/blog';
|
import { isBlogPost } from '../core/blog';
|
||||||
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 { ServiceDetail } from './service-detail';
|
||||||
|
import { TechnologyDetail } from './technology-detail';
|
||||||
import { BlogPage } from './blog-page';
|
import { BlogPage } from './blog-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';
|
||||||
@@ -38,6 +44,7 @@ import { ProjectDetail } from './project-detail';
|
|||||||
ProjectDetail,
|
ProjectDetail,
|
||||||
RadarPage,
|
RadarPage,
|
||||||
ServiceDetail,
|
ServiceDetail,
|
||||||
|
TechnologyDetail,
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<app-page-shell [data]="data()" [seo]="seo()">
|
<app-page-shell [data]="data()" [seo]="seo()">
|
||||||
@@ -88,7 +95,18 @@ import { ProjectDetail } from './project-detail';
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@case ('radar') {
|
@case ('radar') {
|
||||||
<app-radar-page [technologies]="found.record" [copy]="copy()" />
|
<app-radar-page
|
||||||
|
[technologies]="found.record"
|
||||||
|
[copy]="copy()"
|
||||||
|
[locale]="data().locale"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
@case ('technology') {
|
||||||
|
<app-technology-detail
|
||||||
|
[technology]="found.record"
|
||||||
|
[copy]="copy()"
|
||||||
|
[locale]="data().locale"
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
@case ('member') {
|
@case ('member') {
|
||||||
<app-member-detail
|
<app-member-detail
|
||||||
@@ -223,6 +241,26 @@ export function seoFor(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case 'technology': {
|
||||||
|
const technology = found.record;
|
||||||
|
|
||||||
|
// The only record whose address this site composes rather than reads
|
||||||
|
// off the payload: the API gives technologies no `path`.
|
||||||
|
return {
|
||||||
|
title: `Pireon — ${technology.name}`,
|
||||||
|
description: technology.description || copy.radar.lead,
|
||||||
|
path: technologyPath(technology.slug, locale),
|
||||||
|
alternates: technologyAlternates(technology.slug),
|
||||||
|
type: 'article',
|
||||||
|
jsonLd: {
|
||||||
|
'@type': 'SoftwareApplication',
|
||||||
|
name: technology.name,
|
||||||
|
description: technology.description,
|
||||||
|
applicationCategory: copy.radar.categories[technology.category],
|
||||||
|
url: technology.website,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
case 'blog':
|
case 'blog':
|
||||||
return {
|
return {
|
||||||
title: `Pireon — ${copy.blog.title}`,
|
title: `Pireon — ${copy.blog.title}`,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
input,
|
input,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
PiChipComponent,
|
|
||||||
PiIconComponent,
|
PiIconComponent,
|
||||||
PiTextColorDirective,
|
PiTextColorDirective,
|
||||||
PiTextLargeDirective,
|
PiTextLargeDirective,
|
||||||
@@ -19,6 +18,7 @@ import { resolveIcon } from '../core/icons';
|
|||||||
import { ContactCta } from './contact-cta';
|
import { ContactCta } from './contact-cta';
|
||||||
import { ProjectCard } from './project-card';
|
import { ProjectCard } from './project-card';
|
||||||
import { TestimonialCard } from './testimonial-card';
|
import { TestimonialCard } from './testimonial-card';
|
||||||
|
import { TechnologyChip } from './technology-chip';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A service on its own address, and the published work that backs it: the
|
* A service on its own address, and the published work that backs it: the
|
||||||
@@ -32,13 +32,13 @@ import { TestimonialCard } from './testimonial-card';
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [
|
imports: [
|
||||||
ContactCta,
|
ContactCta,
|
||||||
PiChipComponent,
|
PiIconComponent,
|
||||||
PiIconComponent,
|
|
||||||
PiTextColorDirective,
|
PiTextColorDirective,
|
||||||
PiTextLargeDirective,
|
PiTextLargeDirective,
|
||||||
PiTextWeightBoldDirective,
|
PiTextWeightBoldDirective,
|
||||||
ProjectCard,
|
ProjectCard,
|
||||||
TestimonialCard,
|
TestimonialCard,
|
||||||
|
TechnologyChip,
|
||||||
],
|
],
|
||||||
styleUrl: './service-detail.scss',
|
styleUrl: './service-detail.scss',
|
||||||
template: `
|
template: `
|
||||||
@@ -72,7 +72,11 @@ import { TestimonialCard } from './testimonial-card';
|
|||||||
<h2>{{ copy().service.projects }}</h2>
|
<h2>{{ copy().service.projects }}</h2>
|
||||||
<div class="service-grid">
|
<div class="service-grid">
|
||||||
@for (project of service().projects; track project.id) {
|
@for (project of service().projects; track project.id) {
|
||||||
<app-project-card [project]="project" [copy]="copy()" />
|
<app-project-card
|
||||||
|
[project]="project"
|
||||||
|
[copy]="copy()"
|
||||||
|
[locale]="locale()"
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -100,11 +104,10 @@ import { TestimonialCard } from './testimonial-card';
|
|||||||
<ul class="content-hero__chips service-technologies">
|
<ul class="content-hero__chips service-technologies">
|
||||||
@for (technology of service().technologies; track technology.id) {
|
@for (technology of service().technologies; track technology.id) {
|
||||||
<li>
|
<li>
|
||||||
<pi-chip
|
<app-technology-chip
|
||||||
size="small"
|
[technology]="technology"
|
||||||
appearance="outline"
|
[locale]="locale()"
|
||||||
[label]="technology.name"
|
/>
|
||||||
/>
|
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
22
landing/src/app/ui/technology-chip.scss
Normal file
22
landing/src/app/ui/technology-chip.scss
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
:host {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.technology-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: var(--pi-theme-chip-radius, 0.25rem);
|
||||||
|
|
||||||
|
// The chip carries the visible border, so the focus ring goes on the anchor
|
||||||
|
// around it rather than being lost inside the component.
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--pi-color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover ::ng-deep .pi-chip,
|
||||||
|
&:hover ::ng-deep span {
|
||||||
|
border-color: var(--pi-color-primary);
|
||||||
|
color: var(--pi-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
landing/src/app/ui/technology-chip.ts
Normal file
33
landing/src/app/ui/technology-chip.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
||||||
|
import { PiChipComponent } from '@pi/ui';
|
||||||
|
|
||||||
|
import type { Locale } from '../core/api';
|
||||||
|
import type { PublicTechnology } from '../core/api.types';
|
||||||
|
import { technologyPath } from '../core/routes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A technology's name, leading to its page.
|
||||||
|
*
|
||||||
|
* The same chip was written out in nine places — every card and every detail
|
||||||
|
* page that names a technology — so the link lives here rather than nine
|
||||||
|
* times. `pi-chip` renders a `<span>`, so the anchor goes around it.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-technology-chip',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [PiChipComponent],
|
||||||
|
styleUrl: './technology-chip.scss',
|
||||||
|
template: `
|
||||||
|
<a class="technology-chip" [href]="href()">
|
||||||
|
<pi-chip size="small" appearance="outline" [label]="technology().name" />
|
||||||
|
</a>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class TechnologyChip {
|
||||||
|
readonly technology = input.required<PublicTechnology>();
|
||||||
|
readonly locale = input.required<Locale>();
|
||||||
|
|
||||||
|
protected readonly href = computed(() =>
|
||||||
|
technologyPath(this.technology().slug, this.locale()),
|
||||||
|
);
|
||||||
|
}
|
||||||
53
landing/src/app/ui/technology-detail.scss
Normal file
53
landing/src/app/ui/technology-detail.scss
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
@use './detail';
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.technology-hero {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same light tile the radar uses, for the same reason: a brand's mark is
|
||||||
|
// not ours to recolour, and a black one would disappear in dark mode.
|
||||||
|
.technology-logo {
|
||||||
|
display: grid;
|
||||||
|
flex: none;
|
||||||
|
place-items: center;
|
||||||
|
width: 4.5rem;
|
||||||
|
height: 4.5rem;
|
||||||
|
border-radius: 1.125rem;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px
|
||||||
|
color-mix(in srgb, var(--brand, var(--pi-color-primary)) 22%, transparent),
|
||||||
|
0 8px 20px -10px
|
||||||
|
color-mix(in srgb, var(--brand, var(--pi-color-primary)) 55%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.technology-logo ::ng-deep img {
|
||||||
|
display: block;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.technology-logo__initial {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--brand, var(--pi-color-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.technology-text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.technology-note {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
color: var(--pi-color-grey);
|
||||||
|
}
|
||||||
165
landing/src/app/ui/technology-detail.ts
Normal file
165
landing/src/app/ui/technology-detail.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
input,
|
||||||
|
} from '@angular/core';
|
||||||
|
import {
|
||||||
|
PiActionButtonComponent,
|
||||||
|
PiTextColorDirective,
|
||||||
|
PiTextLargeDirective,
|
||||||
|
PiTextWeightBoldDirective,
|
||||||
|
} from '@pi/ui';
|
||||||
|
|
||||||
|
import type { Locale } from '../core/api';
|
||||||
|
import type { PublicTechnology } from '../core/api.types';
|
||||||
|
import type { Copy } from '../core/copy';
|
||||||
|
import { listingPath } from '../core/routes';
|
||||||
|
import { ApiImage } from './api-image';
|
||||||
|
import { ContactCta } from './contact-cta';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One technology on its own address: what it is, where the collective places
|
||||||
|
* it on the radar, and where its own people document it.
|
||||||
|
*
|
||||||
|
* The logo sits on a light tile in both themes, as it does on the radar. A
|
||||||
|
* brand's own mark is not ours to recolour, and some — Rust's, black — would
|
||||||
|
* otherwise vanish against a dark page.
|
||||||
|
*
|
||||||
|
* `website` is the only link the API carries. It is the project's own home
|
||||||
|
* page, which is where its documentation lives; there is no separate field for
|
||||||
|
* the docs, so the label says what the link actually is.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-technology-detail',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [
|
||||||
|
ApiImage,
|
||||||
|
ContactCta,
|
||||||
|
PiActionButtonComponent,
|
||||||
|
PiTextColorDirective,
|
||||||
|
PiTextLargeDirective,
|
||||||
|
PiTextWeightBoldDirective,
|
||||||
|
],
|
||||||
|
styleUrl: './technology-detail.scss',
|
||||||
|
template: `
|
||||||
|
<header class="content-hero">
|
||||||
|
<div class="landing-shell technology-hero">
|
||||||
|
<span
|
||||||
|
class="technology-logo"
|
||||||
|
[style.--brand]="technology().color || null"
|
||||||
|
>
|
||||||
|
@if (technology().logo; as logo) {
|
||||||
|
<app-api-image
|
||||||
|
[image]="logo"
|
||||||
|
sizes="72px"
|
||||||
|
loading="eager"
|
||||||
|
imgClass="technology-logo__image"
|
||||||
|
/>
|
||||||
|
} @else {
|
||||||
|
<span class="technology-logo__initial" aria-hidden="true">{{
|
||||||
|
technology().name[0]
|
||||||
|
}}</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="landing-eyebrow">{{ category() }}</p>
|
||||||
|
<h1 bold class="content-hero__title">{{ technology().name }}</h1>
|
||||||
|
|
||||||
|
@if (technology().description; as description) {
|
||||||
|
<p l pi-color color="grey" class="content-hero__lead">
|
||||||
|
{{ description }}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (technology().website; as website) {
|
||||||
|
<p class="content-hero__actions">
|
||||||
|
<a [href]="website" target="_blank" rel="noopener noreferrer">
|
||||||
|
<pi-action-button
|
||||||
|
size="large"
|
||||||
|
color="primary"
|
||||||
|
[text]="copy().technology.website"
|
||||||
|
[hasIcon]="true"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="landing-shell content-page__layout">
|
||||||
|
<div>
|
||||||
|
@if (ring(); as ring) {
|
||||||
|
<section class="content-page__section">
|
||||||
|
<h2>{{ copy().technology.ringTitle }}: {{ ring.label }}</h2>
|
||||||
|
<p class="technology-text">{{ ring.lead }}</p>
|
||||||
|
|
||||||
|
@if (technology().radar_note; as note) {
|
||||||
|
<p class="technology-text technology-note">{{ note }}</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a [href]="radarHref()">{{ copy().technology.back }} →</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="content-page__aside">
|
||||||
|
@if (facts().length > 0) {
|
||||||
|
<div class="landing-card content-page__panel">
|
||||||
|
<ul class="content-page__facts">
|
||||||
|
@for (fact of facts(); track fact) {
|
||||||
|
<li>{{ fact }}</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<app-contact-cta [copy]="copy()" [locale]="locale()" />
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class TechnologyDetail {
|
||||||
|
readonly technology = input.required<PublicTechnology>();
|
||||||
|
readonly copy = input.required<Copy>();
|
||||||
|
readonly locale = input.required<Locale>();
|
||||||
|
|
||||||
|
protected readonly category = computed(
|
||||||
|
() => this.copy().radar.categories[this.technology().category],
|
||||||
|
);
|
||||||
|
|
||||||
|
protected readonly radarHref = computed(() =>
|
||||||
|
listingPath('radar', this.locale()),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Null for a technology the collective uses but has not placed. */
|
||||||
|
protected readonly ring = computed(() => {
|
||||||
|
const ring = this.technology().ring;
|
||||||
|
|
||||||
|
return ring
|
||||||
|
? { label: this.copy().rings[ring], lead: this.copy().radar.ringLead[ring] }
|
||||||
|
: null;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Counts the API reports as zero say nothing, so they are not shown. */
|
||||||
|
protected readonly facts = computed(() => {
|
||||||
|
const { projects_count: projects, members_count: members } =
|
||||||
|
this.technology();
|
||||||
|
const copy = this.copy().technology;
|
||||||
|
const facts: string[] = [];
|
||||||
|
|
||||||
|
if (projects > 0) {
|
||||||
|
facts.push(`${projects} ${copy.usedIn}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (members > 0) {
|
||||||
|
facts.push(`${members} ${copy.membersUsing}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return facts;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -13,13 +13,18 @@ import {
|
|||||||
setResponseStatus,
|
setResponseStatus,
|
||||||
} from 'h3';
|
} from 'h3';
|
||||||
|
|
||||||
import { pireonApi } from '../../app/core/api';
|
import { DEFAULT_LOCALE, pireonApi } from '../../app/core/api';
|
||||||
import { originFromHeaders } from '../../app/core/origin';
|
import { originFromHeaders } from '../../app/core/origin';
|
||||||
import { buildSitemap } from '../../app/core/sitemap';
|
import { buildSitemap } from '../../app/core/sitemap';
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const origin = originFromHeaders(getRequestHeaders(event)) ?? '';
|
const origin = originFromHeaders(getRequestHeaders(event)) ?? '';
|
||||||
const { data } = await pireonApi.slugs();
|
// The technologies are fetched alongside, not instead: their addresses are
|
||||||
|
// this site's own and `/slugs` cannot carry them.
|
||||||
|
const [{ data }, technologies] = await Promise.all([
|
||||||
|
pireonApi.slugs(),
|
||||||
|
pireonApi.technologies(DEFAULT_LOCALE, { in_use: false }),
|
||||||
|
]);
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
setResponseStatus(event, 503);
|
setResponseStatus(event, 503);
|
||||||
@@ -30,5 +35,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
setResponseHeader(event, 'Content-Type', 'application/xml; charset=utf-8');
|
setResponseHeader(event, 'Content-Type', 'application/xml; charset=utf-8');
|
||||||
|
|
||||||
return buildSitemap(origin, data);
|
// A failed `/slugs` is a 503 above, because a sitemap missing most of the
|
||||||
|
// site is worse than none. A failed technologies call is not: they are an
|
||||||
|
// extra this site adds, and leaving them out costs a handful of URLs rather
|
||||||
|
// than misrepresenting the whole site.
|
||||||
|
return buildSitemap(origin, data, technologies.data ?? []);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,15 @@ import { createServer } from 'node:http';
|
|||||||
|
|
||||||
const tech = (id, name, slug, category, color, ring) => ({
|
const tech = (id, name, slug, category, color, ring) => ({
|
||||||
id, name, slug, category, color, ring, projects_count: 3, members_count: 2,
|
id, name, slug, category, color, ring, projects_count: 3, members_count: 2,
|
||||||
|
description: `${name} es una de las herramientas con las que trabajamos a diario. Esta descripción viene de la API, como en producción.`,
|
||||||
|
website: `https://example.com/${slug}`,
|
||||||
|
// An inline SVG so the mock needs no file server: the real API serves these
|
||||||
|
// from its own storage.
|
||||||
|
logo: {
|
||||||
|
url: `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="${color}"/><text x="32" y="42" font-family="sans-serif" font-size="30" font-weight="700" fill="#fff" text-anchor="middle">${name[0]}</text></svg>`)}`,
|
||||||
|
alt: name, width: 64, height: 64, aspect_ratio: 1,
|
||||||
|
mime: 'image/svg+xml', placeholder: null, srcset: null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const technologies = [
|
const technologies = [
|
||||||
@@ -100,6 +109,7 @@ const data = {
|
|||||||
{ id: 4, quote: 'Trabajar con el repositorio abierto desde el primer día nos quitó el miedo a depender de nadie.', original_locale: 'es', is_original: true, author: { name: 'Pau Ferrer', role: 'Dirección' }, client: { id: 3, name: 'Fundació Oberta', slug: 'oberta', projects_count: 3 } },
|
{ id: 4, quote: 'Trabajar con el repositorio abierto desde el primer día nos quitó el miedo a depender de nadie.', original_locale: 'es', is_original: true, author: { name: 'Pau Ferrer', role: 'Dirección' }, client: { id: 3, name: 'Fundació Oberta', slug: 'oberta', projects_count: 3 } },
|
||||||
],
|
],
|
||||||
'/public/v1/radar': technologies,
|
'/public/v1/radar': technologies,
|
||||||
|
'/public/v1/technologies': technologies,
|
||||||
'/public/v1/members': [member(1, 'Ada Lovelace', 'ada', true), member(2, 'Linus T.', 'linus', false), member(3, 'Grace Hopper', 'grace', true), member(4, 'Alan Turing', 'alan', false)],
|
'/public/v1/members': [member(1, 'Ada Lovelace', 'ada', true), member(2, 'Linus T.', 'linus', false), member(3, 'Grace Hopper', 'grace', true), member(4, 'Alan Turing', 'alan', false)],
|
||||||
'/public/v1/calls': [
|
'/public/v1/calls': [
|
||||||
{ id: 1, locale: 'es', title: 'Frontend Angular', slug: 'frontend-angular', description: 'Ayúdanos con el catálogo público: componentes, accesibilidad y tests.', status: 'open', hours_per_week: 4, duration_weeks: 8, seniority: 'any', closes_at: '2026-10-01T00:00:00Z', project: project(1, 'Catálogo abierto', 'catalogo', 'active', true), technologies: technologies.slice(0, 2), alternates: {} },
|
{ id: 1, locale: 'es', title: 'Frontend Angular', slug: 'frontend-angular', description: 'Ayúdanos con el catálogo público: componentes, accesibilidad y tests.', status: 'open', hours_per_week: 4, duration_weeks: 8, seniority: 'any', closes_at: '2026-10-01T00:00:00Z', project: project(1, 'Catálogo abierto', 'catalogo', 'active', true), technologies: technologies.slice(0, 2), alternates: {} },
|
||||||
@@ -201,6 +211,12 @@ createServer((req, res) => {
|
|||||||
let payload = data[path];
|
let payload = data[path];
|
||||||
|
|
||||||
// The one filter the frontend relies on: pages by category slug.
|
// The one filter the frontend relies on: pages by category slug.
|
||||||
|
if (path === '/public/v1/technologies') {
|
||||||
|
const inUse = url.searchParams.get('in_use');
|
||||||
|
payload =
|
||||||
|
inUse === 'false' ? payload : payload.filter((t) => t.projects_count > 0);
|
||||||
|
}
|
||||||
|
|
||||||
if (path === '/public/v1/pages' && url.searchParams.get('category')) {
|
if (path === '/public/v1/pages' && url.searchParams.get('category')) {
|
||||||
payload = payload.filter((p) => p.category?.slug === url.searchParams.get('category'));
|
payload = payload.filter((p) => p.category?.slug === url.searchParams.get('category'));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user