Files
landing-app/tools/mock-api.mjs
2026-10-01 11:20:57 +02:00

256 lines
16 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,
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 = [
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}`,
});
// A body with everything the editor can produce, images included, so the
// post's typography can be reviewed without writing one in the panel.
const richBody =
'<p>Volvimos a renderizar en el servidor por tres motivos.</p>' +
'<ul><li><p><strong>Rendimiento</strong> en móviles modestos.</p></li><li><p><strong>SEO</strong> sin trucos.</p></li></ul>' +
'<img src="/logo.png" alt="El logo de Pireon">' +
'<h2>Cómo lo hicimos</h2><p>Con <code>injectLoad</code> y un loader por página:</p>' +
'<pre><code>export const load = async () => fetchPage();</code></pre>' +
'<figure><img src="/logo.png" alt="Otra vez el logo"><figcaption>Una imagen con pie de foto.</figcaption></figure>' +
'<blockquote><p>Lo que no se mide en un móvil de gama baja, no está medido.</p></blockquote>';
const post = (id, title, slug, published_at, tags = []) => ({
...page(id, title, slug, false),
body: richBody,
published_at,
tags,
byline: { name: 'Ada Lovelace', slug: 'ada', headline: 'Backend', avatar: null, path: '/equipo/ada' },
excerpt: 'Lo que hemos aprendido este mes, contado sin prisa y con ejemplos de código.',
category: { id: 9, name: 'Blog', slug: 'blog', position: 3 },
});
const pages = [
page(1, 'Sobre nosotros', 'sobre-nosotros', true),
page(2, 'Contacto', 'contacto', false),
post(3, 'Por qué volvimos a renderizar en el servidor', 'volvimos-al-servidor', '2026-09-20T09:00:00Z', [{ name: 'Angular', slug: 'angular' }, { name: 'Rendimiento', slug: 'rendimiento' }]),
post(4, 'Nuestro primer año como colectivo', 'primer-ano', '2026-06-01T09:00:00Z', [{ name: 'Colectivo', slug: 'colectivo' }]),
];
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/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/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 service of data['/public/v1/services']) {
service.path = `/servicios/${service.slug}`;
data[`/public/v1/services/${service.slug}`] = {
...service,
description: 'Empezamos escuchando: qué hace hoy la organización y qué le sobra.\nDespués construimos lo mínimo que resuelve el problema, y lo dejamos documentado para que lo pueda mantener otra persona.',
meta_description: service.tagline,
projects: data['/public/v1/projects'].slice(0, 2).map((p) => ({ ...p, path: `/proyectos/${p.slug}` })),
technologies: technologies.slice(0, 4),
testimonials: data['/public/v1/testimonials'].slice(0, 2),
alternates: { es: service.path, en: `/en/services/${service.slug}`, ca: `/ca/serveis/${service.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}` },
};
}
// What the sitemap is built from, and one renamed project to exercise the
// redirect middleware with.
data['/public/v1/slugs'] = {
pages: data['/public/v1/pages'].map((p) => ({ id: p.id, updated_at: p.updated_at, alternates: p.alternates })),
projects: data['/public/v1/projects'].map((p) => ({
id: p.id,
updated_at: p.updated_at,
alternates: data[`/public/v1/projects/${p.slug}`].alternates,
})),
};
data['/public/v1/redirects'] = [{ from: '/proyectos/catalogo-viejo', to: '/proyectos/catalogo', status: 301, locale: null }];
createServer((req, res) => {
const url = new URL(req.url, 'http://x');
const path = url.pathname.replace(/^\/api/, '');
let payload = data[path];
// 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')) {
payload = payload.filter((p) => p.category?.slug === url.searchParams.get('category'));
}
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'));