Add a page for every way to help
/colabora (/en/contribute, /ca/col-labora) — the calls' own section, so
it lists what /colabora/{slug} are — brings the open calls, the good
first issues across the collective's repositories, and a five-step
guide from picking a task to having it reviewed. With nothing open and
nothing labelled, the guide and the other ways to help are still there.
The landing's "contribute" section links to it.
Also fixes the landing's issue list, which @pi/ui's `li + li` margin
spaced twice over.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -227,7 +227,10 @@ named in that language:
|
||||
```
|
||||
|
||||
Besides records there are **listings**, pages this site composes itself —
|
||||
the tech radar at `/radar`, `/en/radar`, `/ca/radar` (`LISTINGS`). A panel page
|
||||
the tech radar at `/radar`, `/en/radar`, `/ca/radar`, and the ways to help at
|
||||
`/colabora`, `/en/contribute`, `/ca/col-labora` — the calls' own section, so it
|
||||
lists what `/colabora/{slug}` are, next to good first issues and a guide to a
|
||||
first contribution (`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.
|
||||
|
||||
@@ -98,6 +98,16 @@ test.describe('landing', () => {
|
||||
expect((await page.goto('/es/radar'))?.status()).toBe(404);
|
||||
});
|
||||
|
||||
test("lists the ways to help on the calls' own section", async ({ page }) => {
|
||||
for (const [path, heading] of [
|
||||
['/colabora', 'Tu primera contribución'],
|
||||
['/en/contribute', 'Your first contribution'],
|
||||
]) {
|
||||
expect((await page.goto(path))?.status(), path).toBe(200);
|
||||
await expect(page.locator('h1')).toContainText(heading);
|
||||
}
|
||||
});
|
||||
|
||||
test('refuses an incomplete contact submission without calling the API', async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -114,6 +114,20 @@ export interface Copy {
|
||||
links: string;
|
||||
since: string;
|
||||
};
|
||||
contribute: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
lead: string;
|
||||
calls: string;
|
||||
callsLead: string;
|
||||
issues: string;
|
||||
issuesLead: string;
|
||||
noIssues: string;
|
||||
guide: string;
|
||||
steps: { title: string; text: string }[];
|
||||
otherWays: string;
|
||||
otherWaysLead: string;
|
||||
};
|
||||
service: {
|
||||
eyebrow: string;
|
||||
projects: string;
|
||||
@@ -267,6 +281,26 @@ const es: Copy = {
|
||||
links: 'Enlaces',
|
||||
since: 'En el colectivo desde',
|
||||
},
|
||||
contribute: {
|
||||
eyebrow: 'Colabora',
|
||||
title: 'Tu primera contribución',
|
||||
lead: 'No hace falta ser experta ni tener mucho tiempo. Elige algo pequeño, pregunta lo que necesites y te acompañamos hasta que tu cambio esté publicado.',
|
||||
calls: 'Convocatorias abiertas',
|
||||
callsLead: 'Proyectos que buscan manos ahora mismo, con la dedicación semanal clara.',
|
||||
issues: 'Tareas para empezar',
|
||||
issuesLead: 'Issues de nuestros repositorios pensadas para quien llega por primera vez.',
|
||||
noIssues: 'Ahora mismo no hay tareas etiquetadas para empezar. Escríbenos y te buscamos una.',
|
||||
guide: 'Cómo empezar',
|
||||
steps: [
|
||||
{ title: 'Elige una tarea', text: 'Busca una etiquetada como «good first issue»: son acotadas y están bien explicadas.' },
|
||||
{ title: 'Dilo en la issue', text: 'Deja un comentario para que nadie trabaje en lo mismo. Si tienes dudas, pregúntalas ahí.' },
|
||||
{ title: 'Haz tu cambio', text: 'Crea una rama desde la principal, haz el cambio y comprueba que los tests pasan.' },
|
||||
{ title: 'Abre una pull request', text: 'Explica qué has cambiado y por qué. Enlaza la issue para que se cierre sola.' },
|
||||
{ title: 'Revisión', text: 'Alguien del colectivo revisará tu código contigo. Es una conversación, no un examen.' },
|
||||
],
|
||||
otherWays: 'Otras formas de ayudar',
|
||||
otherWaysLead: 'También necesitamos diseño, documentación, traducciones y gente que pruebe lo que hacemos. Escríbenos y lo hablamos.',
|
||||
},
|
||||
service: {
|
||||
eyebrow: 'Servicio',
|
||||
projects: 'Trabajo relacionado',
|
||||
@@ -433,6 +467,26 @@ const en: Copy = {
|
||||
links: 'Links',
|
||||
since: 'In the collective since',
|
||||
},
|
||||
contribute: {
|
||||
eyebrow: 'Contribute',
|
||||
title: 'Your first contribution',
|
||||
lead: 'You do not need to be an expert or have lots of time. Pick something small, ask whatever you need and we will help you all the way until your change is live.',
|
||||
calls: 'Open calls',
|
||||
callsLead: 'Projects looking for hands right now, with the weekly commitment stated up front.',
|
||||
issues: 'Good first issues',
|
||||
issuesLead: 'Issues in our repositories meant for people contributing for the first time.',
|
||||
noIssues: 'There are no issues labelled for newcomers right now. Write to us and we will find you one.',
|
||||
guide: 'How to get started',
|
||||
steps: [
|
||||
{ title: 'Pick a task', text: 'Look for one labelled "good first issue": they are well scoped and clearly explained.' },
|
||||
{ title: 'Say so on the issue', text: 'Leave a comment so nobody works on the same thing. If you have questions, ask them there.' },
|
||||
{ title: 'Make your change', text: 'Branch off the main branch, make the change and check that the tests pass.' },
|
||||
{ title: 'Open a pull request', text: 'Explain what you changed and why. Link the issue so it closes on its own.' },
|
||||
{ title: 'Review', text: 'Someone from the collective will go through your code with you. It is a conversation, not an exam.' },
|
||||
],
|
||||
otherWays: 'Other ways to help',
|
||||
otherWaysLead: 'We also need design, documentation, translations and people to try what we build. Write to us and we will talk it over.',
|
||||
},
|
||||
service: {
|
||||
eyebrow: 'Service',
|
||||
projects: 'Related work',
|
||||
@@ -599,6 +653,26 @@ const ca: Copy = {
|
||||
links: 'Enllaços',
|
||||
since: 'Al col·lectiu des de',
|
||||
},
|
||||
contribute: {
|
||||
eyebrow: 'Col·labora',
|
||||
title: 'La teva primera contribució',
|
||||
lead: "No cal ser experta ni tenir gaire temps. Tria alguna cosa petita, pregunta el que necessitis i t'acompanyem fins que el teu canvi estigui publicat.",
|
||||
calls: 'Convocatòries obertes',
|
||||
callsLead: 'Projectes que busquen mans ara mateix, amb la dedicació setmanal clara.',
|
||||
issues: 'Tasques per començar',
|
||||
issuesLead: 'Issues dels nostres repositoris pensades per a qui arriba per primera vegada.',
|
||||
noIssues: "Ara mateix no hi ha tasques etiquetades per començar. Escriu-nos i te'n buscarem una.",
|
||||
guide: 'Com començar',
|
||||
steps: [
|
||||
{ title: 'Tria una tasca', text: "Busca'n una etiquetada com a «good first issue»: són acotades i estan ben explicades." },
|
||||
{ title: 'Digues-ho a la issue', text: 'Deixa un comentari perquè ningú treballi en el mateix. Si tens dubtes, pregunta-ho allà.' },
|
||||
{ title: 'Fes el teu canvi', text: 'Crea una branca des de la principal, fes el canvi i comprova que els tests passen.' },
|
||||
{ title: 'Obre una pull request', text: 'Explica què has canviat i per què. Enllaça la issue perquè es tanqui sola.' },
|
||||
{ title: 'Revisió', text: 'Algú del col·lectiu revisarà el teu codi amb tu. És una conversa, no un examen.' },
|
||||
],
|
||||
otherWays: "Altres maneres d'ajudar",
|
||||
otherWaysLead: 'També necessitem disseny, documentació, traduccions i gent que provi el que fem. Escriu-nos i en parlem.',
|
||||
},
|
||||
service: {
|
||||
eyebrow: 'Servei',
|
||||
projects: 'Feina relacionada',
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from './api';
|
||||
import type {
|
||||
PublicCall,
|
||||
PublicIssue,
|
||||
PublicMember,
|
||||
PublicPage,
|
||||
PublicProject,
|
||||
@@ -30,7 +31,11 @@ export type RoutedRecord =
|
||||
| { type: 'member'; record: PublicMember }
|
||||
| { type: 'call'; record: PublicCall }
|
||||
| { type: 'service'; record: PublicService }
|
||||
| { type: 'radar'; record: PublicTechnology[] };
|
||||
| { type: 'radar'; record: PublicTechnology[] }
|
||||
| {
|
||||
type: 'contribute';
|
||||
record: { calls: PublicCall[]; issues: PublicIssue[] };
|
||||
};
|
||||
|
||||
export interface RoutedPageData {
|
||||
locale: Locale;
|
||||
@@ -87,6 +92,17 @@ async function fetchRecord(route: RouteMatch): Promise<RoutedRecord | null> {
|
||||
const { data } = await pireonApi.service(locale, slug);
|
||||
return data ? { type: 'service', record: data } : null;
|
||||
}
|
||||
case 'contribute': {
|
||||
const [calls, issues] = await Promise.all([
|
||||
pireonApi.calls(locale),
|
||||
pireonApi.goodFirstIssues({ limit: 30 }),
|
||||
]);
|
||||
// Nothing open is still an answer: the page says how to help anyway.
|
||||
return {
|
||||
type: 'contribute',
|
||||
record: { calls: calls.data ?? [], issues: issues.data ?? [] },
|
||||
};
|
||||
}
|
||||
case 'radar': {
|
||||
// An empty radar is still the radar: the page says so rather than 404.
|
||||
const { data, error } = await pireonApi.radar(locale);
|
||||
|
||||
@@ -108,6 +108,17 @@ describe('listings', () => {
|
||||
ca: '/ca/radar',
|
||||
});
|
||||
});
|
||||
|
||||
// The calls' section with nothing after it is the list of ways to help.
|
||||
it('reads a call section on its own as the contribute listing', () => {
|
||||
expect(resolveRoute('/colabora')).toEqual({
|
||||
type: 'contribute',
|
||||
locale: 'es',
|
||||
slug: '',
|
||||
});
|
||||
expect(resolveRoute('/en/contribute')?.type).toBe('contribute');
|
||||
expect(resolveRoute('/en/contribute/frontend')?.type).toBe('call');
|
||||
});
|
||||
});
|
||||
|
||||
describe('homeHref', () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ 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';
|
||||
export type ListingType = 'radar' | 'contribute';
|
||||
|
||||
/**
|
||||
* The section each type lives under, per language. The API's own
|
||||
@@ -39,6 +39,8 @@ export const SECTIONS: Record<
|
||||
*/
|
||||
export const LISTINGS: Record<ListingType, Record<Locale, string>> = {
|
||||
radar: { es: 'radar', en: 'radar', ca: 'radar' },
|
||||
// The calls' own section, so /colabora lists what /colabora/{slug} are.
|
||||
contribute: { es: 'colabora', en: 'contribute', ca: 'col-labora' },
|
||||
};
|
||||
|
||||
export interface RouteMatch {
|
||||
|
||||
@@ -240,6 +240,9 @@ export const routeMeta: RouteMeta = {
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
<p class="landing-section__more">
|
||||
<a [href]="contributeHref()">{{ copy().contribute.title }} →</a>
|
||||
</p>
|
||||
</app-section>
|
||||
}
|
||||
|
||||
@@ -289,6 +292,10 @@ export default class HomePage {
|
||||
|
||||
protected readonly radarHref = computed(() => listingPath('radar', this.data().locale));
|
||||
|
||||
protected readonly contributeHref = computed(() =>
|
||||
listingPath('contribute', this.data().locale),
|
||||
);
|
||||
|
||||
/** The switcher needs somewhere to go even if `/site` was unreachable. */
|
||||
protected readonly locales = computed(
|
||||
() =>
|
||||
|
||||
@@ -40,6 +40,11 @@
|
||||
margin-top: 1.25rem;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
// `@pi/ui`'s reset spaces every `li + li`; this list is spaced by `gap`.
|
||||
> li {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.landing-subsection {
|
||||
|
||||
96
landing/src/app/ui/contribute-page.scss
Normal file
96
landing/src/app/ui/contribute-page.scss
Normal file
@@ -0,0 +1,96 @@
|
||||
@use './detail';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.contribute-body {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.contribute-section__head {
|
||||
max-width: 42rem;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.5rem, 3vw, 1.875rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.contribute-grid {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
margin-bottom: 4rem;
|
||||
|
||||
@media (min-width: 640px) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
// The shared layout minus its own side padding: this sits inside the shell.
|
||||
.contribute-layout {
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.contribute-issues,
|
||||
.contribute-steps {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
// `@pi/ui`'s reset spaces every `li + li`; these lists are spaced by `gap`.
|
||||
> li {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.contribute-steps {
|
||||
counter-reset: step;
|
||||
gap: 1rem;
|
||||
|
||||
li {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
padding-left: 2.25rem;
|
||||
counter-increment: step;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.5;
|
||||
|
||||
&::before {
|
||||
content: counter(step);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--pi-color-primary) 14%, transparent);
|
||||
color: var(--pi-color-primary);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.contribute-other {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
125
landing/src/app/ui/contribute-page.ts
Normal file
125
landing/src/app/ui/contribute-page.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import {
|
||||
PiNoteComponent,
|
||||
PiTextColorDirective,
|
||||
PiTextLargeDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
} from '@pi/ui';
|
||||
|
||||
import type { Locale } from '../core/api';
|
||||
import type { PublicCall, PublicIssue } from '../core/api.types';
|
||||
import type { Copy } from '../core/copy';
|
||||
import { CallCard } from './call-card';
|
||||
import { ContactCta } from './contact-cta';
|
||||
import { IssueRow } from './issue-row';
|
||||
|
||||
/**
|
||||
* Every way in, on one address: the calls that are open, the issues labelled
|
||||
* for newcomers across the collective's repositories, and how a first
|
||||
* contribution goes from picking one to seeing it merged.
|
||||
*
|
||||
* The guide is always there. With no call open and no issue labelled, it is
|
||||
* still the answer to "how do I help?", and the contact card says what to do
|
||||
* next.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-contribute-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CallCard,
|
||||
ContactCta,
|
||||
IssueRow,
|
||||
PiNoteComponent,
|
||||
PiTextColorDirective,
|
||||
PiTextLargeDirective,
|
||||
PiTextWeightBoldDirective,
|
||||
],
|
||||
styleUrl: './contribute-page.scss',
|
||||
template: `
|
||||
<header class="content-hero">
|
||||
<div class="landing-shell">
|
||||
<p class="landing-eyebrow">{{ copy().contribute.eyebrow }}</p>
|
||||
<h1 bold class="content-hero__title">{{ copy().contribute.title }}</h1>
|
||||
<p l pi-color color="grey" class="content-hero__lead">
|
||||
{{ copy().contribute.lead }}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="landing-shell contribute-body">
|
||||
@if (calls().length > 0) {
|
||||
<section class="contribute-section">
|
||||
<header class="contribute-section__head">
|
||||
<h2>{{ copy().contribute.calls }}</h2>
|
||||
<p class="content-page__muted">{{ copy().contribute.callsLead }}</p>
|
||||
</header>
|
||||
<div class="contribute-grid">
|
||||
@for (call of calls(); track call.id) {
|
||||
<app-call-card
|
||||
[call]="call"
|
||||
[copy]="copy()"
|
||||
[locale]="locale()"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<div class="content-page__layout contribute-layout">
|
||||
<section class="contribute-section">
|
||||
<header class="contribute-section__head">
|
||||
<h2>{{ copy().contribute.issues }}</h2>
|
||||
<p class="content-page__muted">
|
||||
{{ copy().contribute.issuesLead }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@if (issues().length > 0) {
|
||||
<ul class="contribute-issues">
|
||||
@for (issue of issues(); track issue.url) {
|
||||
<li><app-issue-row [issue]="issue" [copy]="copy()" /></li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<pi-note
|
||||
status="info"
|
||||
appearance="fill"
|
||||
gap="xs"
|
||||
[title]="copy().contribute.noIssues"
|
||||
[text]="''"
|
||||
/>
|
||||
}
|
||||
</section>
|
||||
|
||||
<aside class="content-page__aside">
|
||||
<div class="landing-card content-page__panel">
|
||||
<h2>{{ copy().contribute.guide }}</h2>
|
||||
<ol class="contribute-steps">
|
||||
@for (step of copy().contribute.steps; track step.title) {
|
||||
<li>
|
||||
<strong>{{ step.title }}</strong>
|
||||
<span class="content-page__muted">{{ step.text }}</span>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="landing-card content-page__panel">
|
||||
<h2>{{ copy().contribute.otherWays }}</h2>
|
||||
<p class="contribute-other">
|
||||
{{ copy().contribute.otherWaysLead }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<app-contact-cta [copy]="copy()" [locale]="locale()" />
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ContributePage {
|
||||
readonly calls = input.required<PublicCall[]>();
|
||||
readonly issues = input.required<PublicIssue[]>();
|
||||
readonly copy = input.required<Copy>();
|
||||
readonly locale = input.required<Locale>();
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { ServiceDetail } from './service-detail';
|
||||
import { CallDetail } from './call-detail';
|
||||
import type { RoutedPageData, RoutedRecord } from '../core/routed-page';
|
||||
import { ContentPage } from './content-page';
|
||||
import { ContributePage } from './contribute-page';
|
||||
import { MemberDetail } from './member-detail';
|
||||
import { PageShell, type PageSeo } from './page-shell';
|
||||
import { ProjectDetail } from './project-detail';
|
||||
@@ -27,6 +28,7 @@ import { ProjectDetail } from './project-detail';
|
||||
imports: [
|
||||
CallDetail,
|
||||
ContentPage,
|
||||
ContributePage,
|
||||
MemberDetail,
|
||||
PageShell,
|
||||
ProjectDetail,
|
||||
@@ -66,6 +68,14 @@ import { ProjectDetail } from './project-detail';
|
||||
[locale]="data().locale"
|
||||
/>
|
||||
}
|
||||
@case ('contribute') {
|
||||
<app-contribute-page
|
||||
[calls]="found.record.calls"
|
||||
[issues]="found.record.issues"
|
||||
[copy]="copy()"
|
||||
[locale]="data().locale"
|
||||
/>
|
||||
}
|
||||
@case ('radar') {
|
||||
<app-radar-page [technologies]="found.record" [copy]="copy()" />
|
||||
}
|
||||
@@ -146,6 +156,14 @@ export function seoFor(
|
||||
type: 'article',
|
||||
};
|
||||
}
|
||||
case 'contribute':
|
||||
return {
|
||||
title: `Pireon — ${copy.contribute.title}`,
|
||||
description: copy.contribute.lead,
|
||||
path: listingPath('contribute', locale),
|
||||
alternates: listingAlternates('contribute'),
|
||||
type: 'website',
|
||||
};
|
||||
case 'radar':
|
||||
return {
|
||||
title: `Pireon — ${copy.sections.radar.title}`,
|
||||
|
||||
Reference in New Issue
Block a user