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>
188 lines
5.4 KiB
TypeScript
188 lines
5.4 KiB
TypeScript
import {
|
|
ChangeDetectionStrategy,
|
|
Component,
|
|
computed,
|
|
input,
|
|
} from '@angular/core';
|
|
import {
|
|
PiAvatarComponent,
|
|
PiChipComponent,
|
|
PiTextColorDirective,
|
|
PiTextLargeDirective,
|
|
PiTextWeightBoldDirective,
|
|
} from '@pi/ui';
|
|
|
|
import type { Locale } from '../core/api';
|
|
import type { PublicMember } from '../core/api.types';
|
|
import type { Copy } from '../core/copy';
|
|
import { ContactCta } from './contact-cta';
|
|
import { memberLinks } from './member-card';
|
|
import { ProjectCard } from './project-card';
|
|
import { TechnologyChip } from './technology-chip';
|
|
|
|
/**
|
|
* Someone in the collective, on their own address: who they are, what they
|
|
* work with, and what they have worked on.
|
|
*
|
|
* The bio is plain text from a textarea, not HTML, so it is printed as text
|
|
* with its line breaks kept rather than handed to `innerHTML`.
|
|
*/
|
|
@Component({
|
|
selector: 'app-member-detail',
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
imports: [
|
|
ContactCta,
|
|
PiAvatarComponent,
|
|
PiChipComponent,
|
|
PiTextColorDirective,
|
|
PiTextLargeDirective,
|
|
PiTextWeightBoldDirective,
|
|
ProjectCard,
|
|
TechnologyChip,
|
|
],
|
|
styleUrl: './member-detail.scss',
|
|
template: `
|
|
<header class="content-hero">
|
|
<div class="landing-shell member-hero">
|
|
<pi-avatar
|
|
class="member-hero__avatar"
|
|
size="x-large"
|
|
shape="circle"
|
|
color="primary"
|
|
icon="user"
|
|
[image]="member().avatar?.url"
|
|
[alt]="member().avatar?.alt"
|
|
[name]="member().name"
|
|
/>
|
|
|
|
<div>
|
|
<p class="landing-eyebrow">{{ copy().nav.team }}</p>
|
|
<h1 bold class="content-hero__title">{{ member().name }}</h1>
|
|
|
|
@if (member().headline; as headline) {
|
|
<p l pi-color color="grey" class="content-hero__lead">
|
|
{{ headline }}
|
|
</p>
|
|
}
|
|
|
|
@if (member().is_founder || !member().is_current) {
|
|
<ul class="content-hero__chips">
|
|
@if (member().is_founder) {
|
|
<li>
|
|
<pi-chip
|
|
size="small"
|
|
color="primary"
|
|
[label]="copy().labels.founder"
|
|
/>
|
|
</li>
|
|
}
|
|
@if (!member().is_current) {
|
|
<li>
|
|
<pi-chip
|
|
size="small"
|
|
appearance="outline"
|
|
[label]="copy().labels.alumni"
|
|
/>
|
|
</li>
|
|
}
|
|
</ul>
|
|
}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="landing-shell content-page__layout">
|
|
<article class="content-page__main">
|
|
@if (member().bio; as bio) {
|
|
<p class="member-bio">{{ bio }}</p>
|
|
}
|
|
|
|
@if ((member().projects ?? []).length > 0) {
|
|
<section class="content-page__section">
|
|
<h2>{{ copy().member.projects }}</h2>
|
|
<div class="member-projects">
|
|
@for (project of member().projects; track project.id) {
|
|
<app-project-card
|
|
[project]="project"
|
|
[copy]="copy()"
|
|
[locale]="locale()"
|
|
/>
|
|
}
|
|
</div>
|
|
</section>
|
|
}
|
|
</article>
|
|
|
|
<aside class="content-page__aside">
|
|
@if (since() || links().length > 0) {
|
|
<div class="landing-card content-page__panel">
|
|
<dl class="content-page__facts">
|
|
@if (since(); as since) {
|
|
<div>
|
|
<dt>{{ copy().member.since }}</dt>
|
|
<dd>{{ since }}</dd>
|
|
</div>
|
|
}
|
|
@if (links().length > 0) {
|
|
<div>
|
|
<dt>{{ copy().member.links }}</dt>
|
|
<dd>
|
|
<ul class="member-links">
|
|
@for (link of links(); track link.url) {
|
|
<li>
|
|
<a
|
|
[href]="link.url"
|
|
target="_blank"
|
|
rel="noopener me"
|
|
>{{ link.label }}</a
|
|
>
|
|
</li>
|
|
}
|
|
</ul>
|
|
</dd>
|
|
</div>
|
|
}
|
|
</dl>
|
|
</div>
|
|
}
|
|
|
|
@if ((member().technologies ?? []).length > 0) {
|
|
<div class="landing-card content-page__panel">
|
|
<h2>{{ copy().member.technologies }}</h2>
|
|
<ul class="content-hero__chips member-technologies">
|
|
@for (technology of member().technologies; track technology.id) {
|
|
<li>
|
|
<app-technology-chip
|
|
[technology]="technology"
|
|
[locale]="locale()"
|
|
/>
|
|
</li>
|
|
}
|
|
</ul>
|
|
</div>
|
|
}
|
|
|
|
<app-contact-cta [copy]="copy()" [locale]="locale()" />
|
|
</aside>
|
|
</div>
|
|
`,
|
|
})
|
|
export class MemberDetail {
|
|
readonly member = input.required<PublicMember>();
|
|
readonly copy = input.required<Copy>();
|
|
readonly locale = input.required<Locale>();
|
|
|
|
protected readonly links = computed(() => memberLinks(this.member()));
|
|
|
|
protected readonly since = computed(() => {
|
|
const joined = this.member().joined_at;
|
|
|
|
return joined
|
|
? new Intl.DateTimeFormat(this.locale(), {
|
|
month: 'long',
|
|
year: 'numeric',
|
|
}).format(new Date(joined))
|
|
: null;
|
|
});
|
|
}
|