Files
landing-app/tools/mock-api.mjs
pigui b5681a12ac Give each call its own page, and a form to answer it
A call now has its address — description, what the volunteer gets, the
commitment added up, level, closing date, project, technologies — and
the application form right there. The landing's call card leads to it
instead of scrolling to the general form, and shows the level in the
visitor's language rather than the API's `mid` or `any`.

The form is the landing's, with a `callSlug` that hides the kind
selector and posts to the new /api/calls/{slug}/apply handler: the API
refuses a `kind` on an application, which is always an offer of time.
Both handlers share one check, core/submission.ts, now tested on its
own.

A call that is filled or closed keeps its page — a link to it may
still be out there — but shows why there is no form, and where to
write instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:02:06 +02:00

183 lines
12 KiB
JavaScript

/**
* A stand-in for the Pireon public API, with enough content to see every
* section of the landing at once.
*
* The real API answers `{"data": []}` for every collection until the panel has
* something published, so the page renders as a hero and a contact form and
* nothing else — correct, but impossible to review. Point the app at this
* instead:
*
* npm run mock # this server, on :4599
* npm run start:mock # the app against it
*
* `mock-api-down.mjs` is the other half: every endpoint answering 503, which is
* how the maintenance banner gets exercised.
*/
import { createServer } from 'node:http';
const tech = (id, name, slug, category, color, ring) => ({
id, name, slug, category, color, ring, projects_count: 3, members_count: 2,
});
const technologies = [
tech(1, 'Angular', 'angular', 'framework', '#dd0031', 'adopt'),
tech(2, 'TypeScript', 'typescript', 'language', '#3178c6', 'adopt'),
tech(3, 'Laravel', 'laravel', 'framework', '#ff2d20', 'trial'),
tech(4, 'Postgres', 'postgres', 'datastore', '#336791', 'assess'),
tech(5, 'Kubernetes', 'kubernetes', 'infrastructure', '#326ce5', 'hold'),
];
const project = (id, title, slug, status, featured) => ({
id, locale: 'es', title, slug, status,
tagline: 'Una herramienta pequeña que resuelve un problema concreto y aburrido.',
is_featured: featured, needs_help: id % 2 === 0,
gallery: [], services: [], testimonials: [], talks: [],
demo_url: 'https://example.com', updated_at: '2026-01-01T00:00:00Z',
technologies: technologies.slice(0, 3), members: [], calls: [],
repositories: [{ id, provider: 'gitea', full_name: `pireon/${slug}`, url: `https://git.example/${slug}`, languages: {}, topics: [], stars: 42 * id, forks: 3, contributors_count: 4, open_issues_count: 2, is_archived: false, is_dormant: false, good_first_issues: [] }],
alternates: {}, path: `/proyectos/${slug}`,
});
const member = (id, name, slug, founder) => ({
id, name, slug, headline: 'Backend y cosas que nadie quiere tocar',
links: { web: 'https://example.com' }, github_username: slug,
is_founder: founder, is_current: id !== 4, technologies: [], projects: [],
alternates: {}, path: `/equipo/${slug}`,
});
const page = (id, title, slug, featured) => ({
id, locale: 'es', title, slug, status: 'published', is_featured: featured,
excerpt: 'Una página publicada desde el panel, con su propio título, extracto y cuerpo.',
body: '<h2>Un apartado</h2><p>Texto de la página, tal como sale del editor del panel.</p><ul><li>Una lista</li><li>con dos elementos</li></ul>',
meta_title: `${title} · Pireon`, meta_description: 'Descripción para buscadores.',
published_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
category: { id: 1, name: 'El colectivo', slug: 'colectivo', color: '#4f46e5', position: 1 },
alternates: { es: `/${slug}`, en: `/en/${slug}-en`, ca: `/ca/${slug}-ca` }, path: `/${slug}`,
});
const pages = [page(1, 'Sobre nosotros', 'sobre-nosotros', true), page(2, 'Contacto', 'contacto', false)];
const data = {
'/public/v1/pages': pages,
...Object.fromEntries(pages.map((p) => [`/public/v1/pages/${p.slug}`, p])),
'/public/v1/site': {
locales: [
{ code: 'es', name: 'Español', flag: '🇪🇸', is_default: true },
{ code: 'en', name: 'English', flag: '🇬🇧', is_default: false },
{ code: 'ca', name: 'Català', flag: '🏳️', is_default: false },
],
default_locale: 'es', content_version: 12, submissions_enabled: true,
stats: { projects: 9, active_projects: 4, members: 7, open_calls: 2, stars: 1280, good_first_issues: 5 },
},
'/public/v1/services': [
{ id: 1, locale: 'es', title: 'Producto a medida', slug: 'producto', tagline: 'Del boceto a producción sin capas que nadie pidió.', icon: '🛠', is_featured: true, position: 1, projects: [], technologies: [], testimonials: [], alternates: {} },
{ id: 2, locale: 'es', title: 'Rescate de sistemas', slug: 'rescate', tagline: 'Código heredado que hay que entender antes de tocar.', icon: '🧭', is_featured: false, position: 2, projects: [], technologies: [], testimonials: [], alternates: {} },
{ id: 3, locale: 'es', title: 'Formación', slug: 'formacion', tagline: 'Enseñamos lo que usamos, con el repositorio abierto.', icon: '📚', is_featured: false, position: 3, projects: [], technologies: [], testimonials: [], alternates: {} },
],
'/public/v1/projects': [project(1, 'Catálogo abierto', 'catalogo', 'active', true), project(2, 'Migrador de datos', 'migrador', 'completed', false), project(3, 'Panel de métricas', 'panel', 'paused', false)],
'/public/v1/clients': [
{ id: 1, name: 'Ajuntament de Girona', slug: 'girona', sector: 'Público', url: 'https://example.com', projects_count: 2 },
{ id: 2, name: 'Cooperativa Verd', slug: 'verd', sector: 'Cooperativa', projects_count: 1 },
{ id: 3, name: 'Fundació Oberta', slug: 'oberta', projects_count: 3 },
],
// Four, so the carousel actually has a second page and shows its controls.
'/public/v1/testimonials': [
{ id: 1, quote: 'Entregaron antes de tiempo y nos dejaron la documentación que nunca habíamos tenido.', original_locale: 'es', is_original: true, author: { name: 'Marta Ruiz', role: 'Directora técnica' }, client: { id: 1, name: 'Ajuntament de Girona', slug: 'girona', projects_count: 2 } },
{ id: 2, quote: 'Explican las decisiones. Eso vale más que el código.', original_locale: 'es', is_original: true, author: { name: 'Joan Pi', role: 'CTO' } },
{ id: 3, quote: 'Heredamos un sistema que nadie entendía. Lo documentaron, lo simplificaron y nos enseñaron a mantenerlo nosotros.', original_locale: 'es', is_original: true, author: { name: 'Nuria Sanz', role: 'Responsable de sistemas' }, client: { id: 2, name: 'Cooperativa Verd', slug: 'verd', projects_count: 1 } },
{ 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/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': [
{ 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: 2, locale: 'es', title: 'Infra y despliegues', slug: 'infra', description: 'Automatizar lo que ahora hacemos a mano una vez por semana.', status: 'open', hours_per_week: 2, seniority: 'mid', project: project(3, 'Panel de métricas', 'panel', 'paused', false), technologies: technologies.slice(4), alternates: {} },
],
'/public/v1/good-first-issues': [
{ number: 12, title: 'Añadir mensajes de error traducidos al formulario', url: 'https://git.example/issues/12', labels: ['good first issue'], is_good_first_issue: true, is_help_wanted: false, is_available: true, comments: 3, repository: { full_name: 'pireon/catalogo', primary_language: 'TypeScript' } },
{ number: 31, title: 'Documentar el proceso de despliegue', url: 'https://git.example/issues/31', labels: ['help wanted'], is_good_first_issue: false, is_help_wanted: true, is_available: true, comments: 0, repository: { full_name: 'pireon/panel', primary_language: 'PHP' } },
],
'/public/v1/talks': [
{ id: 1, title: 'Servir HTML otra vez', language: 'es', summary: 'Por qué volvimos a renderizar en el servidor y qué nos costó.', event: 'Codemotion', city: 'Madrid', held_at: '2026-05-12T00:00:00Z', video_url: 'https://example.com/video' },
{ id: 2, title: 'Una API para un frontend que no controlas', language: 'es', event: 'DevFest', city: 'Barcelona', held_at: '2026-03-02T00:00:00Z', slides_url: 'https://example.com/slides' },
],
};
// Detail endpoints: what a project's or a member's own page asks for. Richer
// than the listings — gallery, prose, team — because that is what the detail
// endpoints add.
const prose = (topic) =>
`<p>${topic} empezó como una necesidad concreta de una asociación y acabó siendo una herramienta que usan varias.</p>` +
'<ul><li>Pensado para que lo mantenga gente que no programa.</li><li>Con pruebas y documentación desde el primer día.</li></ul>';
const team = data['/public/v1/members'].slice(0, 3).map((m, i) => ({
...m,
role: ['Responsable', 'Frontend', 'Backend'][i],
}));
for (const listed of data['/public/v1/projects']) {
data[`/public/v1/projects/${listed.slug}`] = {
...listed,
description: prose(listed.title),
decisions: '<p>Elegimos renderizar en el servidor porque la mayoría de visitas llegan desde móviles modestos.</p>',
postmortem: '<p>Subestimamos la importación de datos antiguos: nos llevó el doble de lo previsto.</p>',
meta_title: `${listed.title} · Pireon`,
meta_description: listed.tagline,
started_at: '2025-03-01T00:00:00Z',
ended_at: listed.status === 'completed' ? '2026-06-01T00:00:00Z' : null,
client: data['/public/v1/clients'][0],
members: team,
lead: team[0],
calls: data['/public/v1/calls'].slice(0, listed.id % 2 ? 1 : 0).map((c) => ({ ...c, path: `/colabora/${c.slug}` })),
alternates: { es: `/proyectos/${listed.slug}`, en: `/en/projects/${listed.slug}`, ca: `/ca/projectes/${listed.slug}` },
};
}
// Calls link to their own page, and one of them is closed so the page can
// show what a visitor sees when a link to a filled call is still out there.
for (const call of data['/public/v1/calls']) {
call.path = `/colabora/${call.slug}`;
call.alternates = { es: call.path, en: `/en/contribute/${call.slug}`, ca: `/ca/col-labora/${call.slug}` };
data[`/public/v1/calls/${call.slug}`] = {
...call,
status: call.id === 2 ? 'filled' : 'open',
what_you_get: 'Revisiones de código con alguien del colectivo.\nUn proyecto real en tu portfolio.',
total_hours: call.hours_per_week * (call.duration_weeks ?? 4),
project: { ...call.project, path: `/proyectos/${call.project.slug}` },
};
}
for (const listed of data['/public/v1/members']) {
data[`/public/v1/members/${listed.slug}`] = {
...listed,
bio: 'Programa desde hace años en proyectos de todo tipo.\nAhora dedica unas horas a la semana al colectivo, sobre todo a revisar código y acompañar a quien empieza.',
joined_at: '2024-09-01T00:00:00Z',
technologies: technologies.slice(0, 4),
projects: data['/public/v1/projects'].slice(0, 2).map((p) => ({ ...p, path: `/proyectos/${p.slug}` })),
alternates: { es: `/equipo/${listed.slug}`, en: `/en/team/${listed.slug}`, ca: `/ca/equip/${listed.slug}` },
};
}
createServer((req, res) => {
const path = new URL(req.url, 'http://x').pathname.replace(/^\/api/, '');
const payload = data[path];
res.setHeader('content-type', 'application/json');
// The two write endpoints: accept whatever arrives, as the real API does
// for a valid submission.
if (req.method === 'POST') {
res.statusCode = 201;
res.end(JSON.stringify({ data: { id: 1, kind: 'contribute', status: 'new' } }));
return;
}
if (payload === undefined) {
res.statusCode = 404;
res.end(JSON.stringify({ message: 'not found' }));
return;
}
res.end(JSON.stringify({ data: payload }));
}).listen(4599, () => console.log('Mock API on http://localhost:4599/api'));