diff --git a/README.md b/README.md index e17e3f6..e12d6da 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/landing-e2e/src/landing.spec.ts b/landing-e2e/src/landing.spec.ts index 037a737..bc7f28b 100644 --- a/landing-e2e/src/landing.spec.ts +++ b/landing-e2e/src/landing.spec.ts @@ -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, }) => { diff --git a/landing/src/app/core/copy.ts b/landing/src/app/core/copy.ts index 32dc3f2..d2bc08d 100644 --- a/landing/src/app/core/copy.ts +++ b/landing/src/app/core/copy.ts @@ -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', diff --git a/landing/src/app/core/routed-page.ts b/landing/src/app/core/routed-page.ts index 84e1067..39cca4d 100644 --- a/landing/src/app/core/routed-page.ts +++ b/landing/src/app/core/routed-page.ts @@ -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 { 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); diff --git a/landing/src/app/core/routes.spec.ts b/landing/src/app/core/routes.spec.ts index 79da41a..396b39b 100644 --- a/landing/src/app/core/routes.spec.ts +++ b/landing/src/app/core/routes.spec.ts @@ -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', () => { diff --git a/landing/src/app/core/routes.ts b/landing/src/app/core/routes.ts index 6103778..be5942e 100644 --- a/landing/src/app/core/routes.ts +++ b/landing/src/app/core/routes.ts @@ -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> = { 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 { diff --git a/landing/src/app/pages/(home).page.ts b/landing/src/app/pages/(home).page.ts index 789730a..9aab479 100644 --- a/landing/src/app/pages/(home).page.ts +++ b/landing/src/app/pages/(home).page.ts @@ -240,6 +240,9 @@ export const routeMeta: RouteMeta = { } +

+ {{ copy().contribute.title }} → +

} @@ -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( () => diff --git a/landing/src/app/pages/home.page.scss b/landing/src/app/pages/home.page.scss index d1c720e..1496426 100644 --- a/landing/src/app/pages/home.page.scss +++ b/landing/src/app/pages/home.page.scss @@ -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 { diff --git a/landing/src/app/ui/contribute-page.scss b/landing/src/app/ui/contribute-page.scss new file mode 100644 index 0000000..c6cdb2d --- /dev/null +++ b/landing/src/app/ui/contribute-page.scss @@ -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; +} diff --git a/landing/src/app/ui/contribute-page.ts b/landing/src/app/ui/contribute-page.ts new file mode 100644 index 0000000..5add797 --- /dev/null +++ b/landing/src/app/ui/contribute-page.ts @@ -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: ` +
+
+

{{ copy().contribute.eyebrow }}

+

{{ copy().contribute.title }}

+

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

+
+
+ +
+ @if (calls().length > 0) { +
+
+

{{ copy().contribute.calls }}

+

{{ copy().contribute.callsLead }}

+
+
+ @for (call of calls(); track call.id) { + + } +
+
+ } + +
+
+
+

{{ copy().contribute.issues }}

+

+ {{ copy().contribute.issuesLead }} +

+
+ + @if (issues().length > 0) { +
    + @for (issue of issues(); track issue.url) { +
  • + } +
+ } @else { + + } +
+ + +
+
+ `, +}) +export class ContributePage { + readonly calls = input.required(); + readonly issues = input.required(); + readonly copy = input.required(); + readonly locale = input.required(); +} diff --git a/landing/src/app/ui/routed-page.ts b/landing/src/app/ui/routed-page.ts index a9b7d36..696029b 100644 --- a/landing/src/app/ui/routed-page.ts +++ b/landing/src/app/ui/routed-page.ts @@ -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') { + + } @case ('radar') { } @@ -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}`,