Initial commit

This commit is contained in:
pigui
2026-08-30 21:11:59 +02:00
commit 3f930bb46a
168 changed files with 35708 additions and 0 deletions

38
landing/eslint.config.mjs Normal file
View File

@@ -0,0 +1,38 @@
import nx from "@nx/eslint-plugin";
import baseConfig from "../eslint.config.mjs";
export default [
...nx.configs["flat/angular"],
...nx.configs["flat/angular-template"],
...baseConfig,
{
files: [
"**/*.ts"
],
rules: {
"@angular-eslint/directive-selector": [
"error",
{
type: "attribute",
prefix: "app",
style: "camelCase"
}
],
"@angular-eslint/component-selector": [
"error",
{
type: "element",
prefix: "app",
style: "kebab-case"
}
]
}
},
{
files: [
"**/*.html"
],
// Override or add rules here
rules: {}
}
];

53
landing/index.html Normal file
View File

@@ -0,0 +1,53 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>Pireon — Construimos software en abierto</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0f1116" media="(prefers-color-scheme: dark)" />
<!-- The mark itself, at the two sizes a tab and a bookmark ask for. The
scaffold's `favicon.ico` stays as the last-resort fallback for clients
that request it by convention and ignore these. -->
<link rel="icon" type="image/png" sizes="96x96" href="/logo-96.png" />
<link rel="icon" type="image/png" sizes="256x256" href="/logo.png" />
<link rel="apple-touch-icon" href="/logo.png" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
/>
<!-- The API is on another host and every page fetches it during SSR;
the browser still loads images from it. -->
<link rel="preconnect" href="https://landing-api.pireon.dev" crossorigin />
<link rel="stylesheet" href="/src/styles.scss" />
<script>
// Runs before first paint so a visitor who wants dark never sees a white
// flash: the page is server-rendered, and the server cannot know. The key
// and its values are the ones in `core/appearance.ts`.
(function () {
try {
var stored = localStorage.getItem('landing-appearance');
var dark =
stored === 'dark' ||
(stored !== 'light' &&
matchMedia('(prefers-color-scheme: dark)').matches);
if (dark) document.documentElement.classList.add('dark-mode');
} catch (e) {
// Storage unavailable; the page stays light until Angular decides.
}
})();
</script>
</head>
<!-- `PiThemeService` rewrites this class from its own effect; naming the
theme here too means the server-rendered HTML is already themed instead
of flashing the default one. `alternative` is the library's marketing
shell: gradient fills, hover glow, larger controls. -->
<body class="pi-shell pi-shell__alternative">
<app-root></app-root>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2289
landing/openapi.yaml Normal file

File diff suppressed because it is too large Load Diff

3
landing/package.json Normal file
View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

69
landing/project.json Normal file
View File

@@ -0,0 +1,69 @@
{
"name": "landing",
"$schema": "../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "landing/src",
"tags": [],
"targets": {
"build": {
"executor": "@analogjs/platform:vite",
"defaultConfiguration": "production",
"outputs": [
"{workspaceRoot}/dist/apps/landing"
],
"options": {
"configFile": "landing/vite.config.ts",
"main": "landing/src/main.ts",
"outputPath": "dist/landing/client",
"tsConfig": "landing/tsconfig.app.json"
},
"configurations": {
"development": {
"mode": "development"
},
"production": {
"sourcemap": false,
"mode": "production"
}
}
},
"serve": {
"executor": "@analogjs/platform:vite-dev-server",
"continuous": true,
"defaultConfiguration": "development",
"options": {
"buildTarget": "landing:build",
"port": 4200
},
"configurations": {
"development": {
"buildTarget": "landing:build:development",
"hmr": true
},
"production": {
"buildTarget": "landing:build:production"
}
}
},
"lint": {
"executor": "@nx/eslint:lint"
},
"serve-static": {
"continuous": true,
"executor": "@nx/web:file-server",
"options": {
"buildTarget": "landing:build",
"port": 4200,
"staticFilePath": "dist/landing/browser",
"spa": true
}
},
"test": {
"executor": "@analogjs/vitest-angular:test",
"options": {
"config": "vite.config.ts"
}
}
}
}

0
landing/public/.gitkeep Normal file
View File

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h15M13 6l6 6-6 6"/></svg>

After

Width:  |  Height:  |  Size: 200 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M9 7V5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2M3 12h18"/></svg>

After

Width:  |  Height:  |  Size: 274 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21V8l6-3v16M9 21h12V11l-6-2M13 21v-4h4v4"/><path d="M6 11h.01M6 15h.01"/></svg>

After

Width:  |  Height:  |  Size: 252 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/></svg>

After

Width:  |  Height:  |  Size: 249 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a8 8 0 0 1-11.6 7.1L4 20.5l1.4-5.2A8 8 0 1 1 21 12Z"/></svg>

After

Width:  |  Height:  |  Size: 236 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="m8 12.2 2.7 2.8L16 9.5"/></svg>

After

Width:  |  Height:  |  Size: 232 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m4.5 12.5 5 5 10-11"/></svg>

After

Width:  |  Height:  |  Size: 198 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m14.5 6-6 6 6 6"/></svg>

After

Width:  |  Height:  |  Size: 194 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9.5 6 6 6-6"/></svg>

After

Width:  |  Height:  |  Size: 193 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m9.5 6 6 6-6 6"/></svg>

After

Width:  |  Height:  |  Size: 193 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m6 14.5 6-6 6 6"/></svg>

After

Width:  |  Height:  |  Size: 194 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="m9 9 6 6M15 9l-6 6"/></svg>

After

Width:  |  Height:  |  Size: 228 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6l12 12M18 6 6 18"/></svg>

After

Width:  |  Height:  |  Size: 199 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="6.5" rx="7" ry="3"/><path d="M5 6.5v5c0 1.7 3.1 3 7 3s7-1.3 7-3v-5M5 11.5v5c0 1.7 3.1 3 7 3s7-1.3 7-3v-5"/></svg>

After

Width:  |  Height:  |  Size: 295 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8l-5-5Z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></svg>

After

Width:  |  Height:  |  Size: 277 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3.5 7 7.4 5.3a2 2 0 0 0 2.2 0L20.5 7"/></svg>

After

Width:  |  Height:  |  Size: 265 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5.5M12 16.3h.01"/></svg>

After

Width:  |  Height:  |  Size: 233 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M3 8V5a2 2 0 0 1 2-2h3M16 3h3a2 2 0 0 1 2 2v3M21 16v3a2 2 0 0 1-2 2h-3M8 21H5a2 2 0 0 1-2-2v-3"/></svg>

After

Width:  |  Height:  |  Size: 304 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18Z"/></svg>

After

Width:  |  Height:  |  Size: 258 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20V4M4 20h16"/><path d="m7.5 15 3.5-4 3 2.6L20 7"/></svg>

After

Width:  |  Height:  |  Size: 230 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7.5" height="7.5" rx="1.5"/><rect x="13.5" y="3" width="7.5" height="7.5" rx="1.5"/><rect x="3" y="13.5" width="7.5" height="7.5" rx="1.5"/><rect x="13.5" y="13.5" width="7.5" height="7.5" rx="1.5"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 11v5.5M12 7.7h.01"/></svg>

After

Width:  |  Height:  |  Size: 231 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6M10 21h4"/><path d="M12 3a6 6 0 0 0-3.5 10.9c.6.4 1 1.1 1 1.9v.2h5v-.2c0-.8.4-1.5 1-1.9A6 6 0 0 0 12 3Z"/></svg>

After

Width:  |  Height:  |  Size: 289 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.1.1l2.9-2.9a5 5 0 0 0-7.1-7.1L11 4.9"/><path d="M14 11a5 5 0 0 0-7.1-.1L4 13.8a5 5 0 0 0 7.1 7.1l1.9-1.9"/></svg>

After

Width:  |  Height:  |  Size: 302 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6h13M8 12h13M8 18h13M3.5 6h.01M3.5 12h.01M3.5 18h.01"/></svg>

After

Width:  |  Height:  |  Size: 234 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21s7-5.6 7-11a7 7 0 1 0-14 0c0 5.4 7 11 7 11Z"/><circle cx="12" cy="10" r="2.5"/></svg>

After

Width:  |  Height:  |  Size: 261 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13.4A8 8 0 1 1 10.6 4a6.6 6.6 0 0 0 9.4 9.4Z"/></svg>

After

Width:  |  Height:  |  Size: 227 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8 5.5v13l10-6.5-10-6.5Z"/></svg>

After

Width:  |  Height:  |  Size: 203 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="6.5"/><path d="m16 16 4.5 4.5"/></svg>

After

Width:  |  Height:  |  Size: 226 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="m12 3.6 2.5 5.2 5.7.8-4.1 4 1 5.6-5.1-2.7-5.1 2.7 1-5.6-4.1-4 5.7-.8L12 3.6Z"/></svg>

After

Width:  |  Height:  |  Size: 174 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.3 5.3l1.4 1.4M17.3 17.3l1.4 1.4M18.7 5.3l-1.4 1.4M6.7 17.3l-1.4 1.4"/></svg>

After

Width:  |  Height:  |  Size: 318 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M9.5 9.5 5.6 5.6M14.5 9.5l3.9-3.9M14.5 14.5l3.9 3.9M9.5 14.5l-3.9 3.9"/><circle cx="12" cy="12" r="3.5"/></svg>

After

Width:  |  Height:  |  Size: 312 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5.2l3.2 2"/></svg>

After

Width:  |  Height:  |  Size: 225 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21v-1a6 6 0 0 1 6-6h4a6 6 0 0 1 6 6v1"/></svg>

After

Width:  |  Height:  |  Size: 249 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4h9a2 2 0 0 1 2 2v13a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V8"/><path d="M4 5.5 5.8 7.5 9 3.8M9 11h7M9 15h5"/></svg>

After

Width:  |  Height:  |  Size: 280 B

BIN
landing/public/favicon.ico Normal file

Binary file not shown.

BIN
landing/public/logo-96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

BIN
landing/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -0,0 +1,10 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterOutlet],
template: `<router-outlet />`,
})
export class AppComponent {}

View File

@@ -0,0 +1,10 @@
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { appConfig } from './app.config';
const serverConfig: ApplicationConfig = {
providers: [provideServerRendering()],
};
export const config = mergeApplicationConfig(appConfig, serverConfig);

View File

@@ -0,0 +1,55 @@
import {
ApplicationConfig,
inject,
provideAppInitializer,
provideBrowserGlobalErrorListeners,
} from '@angular/core';
import {
provideHttpClient,
withFetch,
withInterceptors,
} from '@angular/common/http';
import { provideClientHydration } from '@angular/platform-browser';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideFileRouter, requestContextInterceptor } from '@analogjs/router';
import { PiThemeService } from '@pi/ui';
import { provideAppearance } from './core/appearance';
import { provideLandingIcons } from './core/icons';
import { providePiStrings } from './core/pi-strings';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideFileRouter(),
provideClientHydration(),
provideHttpClient(
withFetch(),
// Analog's interceptor is what lets a relative URL resolve during SSR —
// including the ones `MatIconRegistry` uses to fetch the icon SVGs.
withInterceptors([requestContextInterceptor])
),
// Material's form fields and ripples need the animations module. Async so
// the animation engine is not part of the initial chunk.
provideAnimationsAsync(),
// Registered as literals rather than URLs — see `core/icons.ts`.
provideLandingIcons(),
// The library's own strings, provided synchronously — see `pi-strings.ts`
// for why not `providePiTranslate()`.
providePiStrings(),
// `PiThemeService` writes `body.className` from its own effect, and it
// starts on `default`. Without this the page would render server-side in
// the shell named in `index.html` and then visibly swap the moment it
// hydrated. Setting it here means both agree, and the effect stamps the
// same class during SSR.
//
// `alternative` is the library's marketing shell: pill buttons with a
// gradient fill, a tinted field surface, rounded cards with elevation.
// `styles.scss` layers on top of it what the theme contract cannot express
// — there is no card-fill token, only radius and shadow.
provideAppInitializer(() => inject(PiThemeService).setTheme('alternative')),
// Dark mode, with the library's broken persistence routed around — see
// `core/appearance.ts`.
provideAppearance(),
],
};

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { DEFAULT_LOCALE, localeFromUrl, resolveLocale } from './api';
describe('resolveLocale', () => {
it('accepts the locales the API declares', () => {
expect(resolveLocale('ca')).toBe('ca');
expect(resolveLocale('en')).toBe('en');
});
it('falls back to the application default for anything else', () => {
expect(resolveLocale('fr')).toBe(DEFAULT_LOCALE);
expect(resolveLocale(undefined)).toBe(DEFAULT_LOCALE);
expect(resolveLocale(42)).toBe(DEFAULT_LOCALE);
});
});
describe('localeFromUrl', () => {
it('reads ?lang from a path-only URL', () => {
expect(localeFromUrl('/api/_analog/pages/-home-?lang=ca')).toBe('ca');
});
it('ignores an unsupported language rather than passing it on', () => {
expect(localeFromUrl('/?lang=de')).toBe(DEFAULT_LOCALE);
});
it('survives a missing URL', () => {
expect(localeFromUrl(undefined)).toBe(DEFAULT_LOCALE);
});
});

257
landing/src/app/core/api.ts Normal file
View File

@@ -0,0 +1,257 @@
/**
* Server-side client for the Pireon public API.
*
* Only `/public/v1/*` is used: unauthenticated, read-only, and already
* published on the public website. Everything here runs on the server —
* page `load` functions and Nitro routes — so the browser never talks to
* the API directly and CORS never enters the picture.
*/
import type {
PublicApiPayloads,
PublicCall,
PublicClient,
PublicIssue,
PublicMember,
PublicProject,
PublicService,
PublicTalk,
PublicTechnology,
} from './api.types';
/** The `/site` payload, widened to the extra stats the live API answers with. */
export interface SitePayload {
locales: { code: string; name: string; flag: string; is_default: boolean }[];
default_locale: string;
content_version: number;
submissions_enabled: boolean;
stats: Partial<
Record<
| 'projects'
| 'active_projects'
| 'members'
| 'open_calls'
| 'services'
| 'clients'
| 'good_first_issues'
| 'stars',
number
>
>;
}
/** Every `/public/v1` endpoint answers this while the site is in maintenance. */
export interface MaintenanceNotice {
message: string;
maintenance: true;
locale: string;
since: string | null;
until: string | null;
retry_after: number | null;
}
export const SUPPORTED_LOCALES = ['es', 'en', 'ca'] as const;
export type Locale = (typeof SUPPORTED_LOCALES)[number];
export const DEFAULT_LOCALE: Locale = 'es';
export function resolveLocale(value: unknown): Locale {
return SUPPORTED_LOCALES.includes(value as Locale)
? (value as Locale)
: DEFAULT_LOCALE;
}
/**
* The locale a request asks for. Only `?lang=` is honoured: the loader runs
* behind an internal request that carries no `Accept-Language`, and a page
* that varied on a header while being cached with `s-maxage` would serve the
* wrong language to the second visitor anyway.
*/
export function localeFromUrl(url: string | undefined): Locale {
const requested = new URL(url ?? '/', 'http://localhost').searchParams.get(
'lang'
);
return resolveLocale(requested);
}
function apiBase(): string {
const configured =
typeof process !== 'undefined'
? process.env?.['PIREON_API_URL'] ?? process.env?.['NITRO_PIREON_API_URL']
: undefined;
return (configured || 'https://landing-api.pireon.dev/api').replace(/\/+$/, '');
}
/**
* The outcome of one call. Nothing throws: a landing page that loses its
* projects section because the API blinked is still a landing page, so
* every failure is data the template can decide what to do about.
*/
export interface ApiResult<T> {
data: T | null;
maintenance: MaintenanceNotice | null;
error: string | null;
}
interface GetOptions {
lang?: Locale;
query?: Record<string, string | number | boolean | undefined>;
/** Milliseconds before the call is abandoned. Keeps SSR from hanging. */
timeout?: number;
}
function buildUrl(path: string, options: GetOptions): string {
const url = new URL(`${apiBase()}/public/v1${path}`);
if (options.lang) {
url.searchParams.set('lang', options.lang);
}
for (const [key, value] of Object.entries(options.query ?? {})) {
if (value !== undefined && value !== null && value !== '') {
url.searchParams.set(key, String(value));
}
}
return url.toString();
}
async function get<T>(path: string, options: GetOptions = {}): Promise<ApiResult<T>> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), options.timeout ?? 8000);
try {
const response = await fetch(buildUrl(path, options), {
headers: {
Accept: 'application/json',
'Accept-Language': options.lang ?? DEFAULT_LOCALE,
},
signal: controller.signal,
});
if (response.status === 503) {
const body = (await response.json().catch(() => null)) as
| Partial<MaintenanceNotice>
| null;
return {
data: null,
error: null,
maintenance: {
message: body?.message ?? 'El sitio está en mantenimiento.',
maintenance: true,
locale: body?.locale ?? options.lang ?? DEFAULT_LOCALE,
since: body?.since ?? null,
until: body?.until ?? null,
retry_after: Number(response.headers.get('Retry-After')) || null,
},
};
}
if (!response.ok) {
return { data: null, maintenance: null, error: `HTTP ${response.status}` };
}
const body = (await response.json()) as { data: T };
return { data: body.data ?? null, maintenance: null, error: null };
} catch (cause) {
const error = cause instanceof Error ? cause.message : 'Unknown error';
return { data: null, maintenance: null, error };
} finally {
clearTimeout(timer);
}
}
/** Unwraps a collection call, so an outage renders an empty section rather than a crash. */
async function list<T>(path: string, options: GetOptions = {}): Promise<ApiResult<T[]>> {
const result = await get<T[]>(path, options);
return { ...result, data: result.data ?? [] };
}
export const pireonApi = {
site: (lang: Locale) => get<SitePayload>('/site', { lang }),
services: (lang: Locale, query?: GetOptions['query']) =>
list<PublicService>('/services', { lang, query }),
projects: (lang: Locale, query?: GetOptions['query']) =>
list<PublicProject>('/projects', { lang, query }),
members: (lang: Locale, query?: GetOptions['query']) =>
list<PublicMember>('/members', { lang, query }),
calls: (lang: Locale, query?: GetOptions['query']) =>
list<PublicCall>('/calls', { lang, query }),
clients: (lang: Locale, query?: GetOptions['query']) =>
list<PublicClient>('/clients', { lang, query }),
testimonials: (lang: Locale, query?: GetOptions['query']) =>
list<PublicApiPayloads['/public/v1/testimonials'][number]>('/testimonials', {
lang,
query,
}),
talks: (lang: Locale, query?: GetOptions['query']) =>
list<PublicTalk>('/talks', { lang, query }),
radar: (lang: Locale) => list<PublicTechnology>('/radar', { lang }),
technologies: (lang: Locale, query?: GetOptions['query']) =>
list<PublicTechnology>('/technologies', { lang, query }),
goodFirstIssues: (query?: GetOptions['query']) =>
list<PublicIssue>('/good-first-issues', { query }),
} as const;
/** POST bodies the public API accepts. Only two endpoints write. */
export interface SubmissionPayload {
name: string;
email: string;
message: string;
organisation?: string | null;
hours_per_week?: number | null;
links?: Record<string, string>;
consent: boolean;
kind?: 'contribute' | 'request';
[honeypot: string]: unknown;
}
export interface SubmissionReceipt {
data?: { id: number; kind: string; status: string; duplicate?: boolean };
message?: string;
errors?: Record<string, string[]>;
}
export interface SubmissionOutcome {
status: number;
body: SubmissionReceipt;
}
/**
* Forwards a submission. Throttling on the API is per e-mail address plus a
* site-wide ceiling — deliberately not per IP — precisely because an SSR
* frontend like this one sends every message from the same address.
*/
export async function postSubmission(
path: '/contact' | `/calls/${string}/apply`,
payload: SubmissionPayload,
lang: Locale
): Promise<SubmissionOutcome> {
const response = await fetch(buildUrl(path, { lang }), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'Accept-Language': lang,
},
body: JSON.stringify(payload),
});
const body = (await response.json().catch(() => ({}))) as SubmissionReceipt;
return { status: response.status, body };
}

View File

@@ -0,0 +1,401 @@
/**
* Pireon Admin API — the public payloads.
*
* Generated from openapi.yaml by `php artisan api:types`. Do not edit:
* change the specification instead, and the contract test will hold the
* API to it.
*/
/**
* The same record's path in each language that has a translation. Feed
* these straight into `<link rel="alternate" hreflang>`. Languages with
* no translation are absent, so no advertised URL ever 404s.
*/
export type Alternates = Record<string, string>;
/**
* Everything needed to render an image well server-side. A path alone
* was never enough: without `width`/`height` the page reflows as images
* arrive, without `srcset` every visitor downloads the original, and
* without `alt` the page is not accessible.
*
* Degrades on purpose — an upload whose derived versions are not ready
* yet still carries a `url`, with the rest null, so the frontend renders
* a plain `<img>` rather than a gap.
*/
export interface Image {
/**
* Absolute, because the frontend is on another host.
*/
url: string;
/**
* Empty string when the image is decorative — that is what `alt=""`
* means — never absent. Follows the requested language.
*/
alt: string;
width?: number;
height?: number;
aspect_ratio?: number;
mime?: string;
/**
* Tiny blurred data URI to paint while the real image loads.
*/
placeholder?: string;
/**
* Keyed by format, ready to use verbatim — build a `<picture>` with
* AVIF first and WebP behind it. Only widths that actually exist are
* listed, and an image is never scaled up.
*/
srcset?: Record<string, string>;
}
/**
* A call for contributors. The commitment fields are the point: time is
* the scarce resource in a volunteer collective, and stating it plainly
* is what lets someone decide.
*/
export interface PublicCall {
id: number;
locale: string;
title: string;
slug: string;
description?: string;
/**
* The explicit exchange offered for unpaid work.
*/
what_you_get?: string;
status: 'open' | 'filled' | 'closed';
hours_per_week?: number;
duration_weeks?: number;
total_hours?: number;
seniority?: 'any' | 'junior' | 'mid' | 'senior';
closes_at?: string;
project: PublicProject;
technologies: PublicTechnology[];
alternates: Alternates;
path?: string;
}
export interface PublicCategory {
id: number;
name?: string;
slug?: string;
description?: string;
color?: string;
position: number;
pages_count: number;
}
export interface PublicClient {
id: number;
/**
* A proper noun, so it is the same in every language.
*/
name: string;
slug: string;
sector?: string;
url?: string;
/**
* Its alt text is the organisation's name.
*/
logo?: Image;
projects_count: number;
}
export interface PublicIssue {
number: number;
title: string;
url: string;
labels: string[];
is_good_first_issue: boolean;
is_help_wanted: boolean;
/**
* Nobody has claimed it. A claimed issue is not an invitation.
*/
is_available: boolean;
comments: number;
created_at?: string;
repository: { full_name: string; url?: string; primary_language?: string; project?: { title: string; slug: string } };
}
export interface PublicMember {
id: number;
name: string;
slug: string;
headline?: string;
bio?: string;
avatar?: Image;
github_username?: string;
links: Record<string, string>;
is_founder: boolean;
/**
* False for people who have left; their work is kept.
*/
is_current: boolean;
joined_at?: string;
left_at?: string;
/**
* Their role on a project, when nested under one.
*/
role?: string;
technologies: PublicTechnology[];
projects: PublicProject[];
alternates: Alternates;
path: string;
}
export interface PublicPage {
id: number;
locale: string;
title?: string;
slug?: string;
excerpt?: string;
body?: string;
meta_title?: string;
meta_description?: string;
status: string;
is_featured: boolean;
featured_image?: Image;
published_at?: string;
/**
* When the record leaves the site. Enforced by the query itself, so
* it holds whether or not anything is running. Use it to expire a
* cache downstream at the same moment.
*/
unpublish_at?: string;
updated_at: string;
category: PublicCategory;
alternates: Alternates;
path?: string;
}
export interface PublicProject {
id: number;
locale: string;
title: string;
slug: string;
tagline?: string;
/**
* Detail endpoint only.
*/
description?: string;
/**
* Why the technical choices were made. Detail endpoint only.
*/
decisions?: string;
/**
* What went wrong and what was learned. Detail endpoint only.
*/
postmortem?: string;
/**
* What a search engine or a chat preview should show instead of the
* title. Detail endpoint only.
*/
meta_title?: string;
/**
* Detail endpoint only.
*/
meta_description?: string;
status: 'idea' | 'active' | 'paused' | 'completed' | 'archived';
is_featured: boolean;
/**
* Flagged directly, or carrying at least one open call.
*/
needs_help: boolean;
cover_image?: Image;
/**
* Screenshots and before-and-after, in the order they were arranged
* in the panel. **Detail endpoint only** — a listing of twenty
* projects has no use for every screenshot of each one.
*/
gallery: (Image & { caption?: string })[];
demo_url?: string;
/**
* The NGO or association the work serves. Used to be two free-text
* fields, which made the same organisation two different things.
*/
client?: PublicClient;
services: PublicService[];
testimonials: PublicTestimonial[];
talks: PublicTalk[];
started_at?: string;
ended_at?: string;
published_at?: string;
/**
* When the record leaves the site. Enforced by the query itself, so
* it holds whether or not anything is running. Use it to expire a
* cache downstream at the same moment.
*/
unpublish_at?: string;
updated_at: string;
technologies: PublicTechnology[];
members: PublicMember[];
lead: PublicMember;
calls: PublicCall[];
repositories: PublicRepository[];
alternates: Alternates;
path?: string;
}
/**
* Mirrored from the git provider and refreshed hourly.
*/
export interface PublicRepository {
id: number;
provider: 'github' | 'gitlab' | 'gitea';
full_name: string;
url?: string;
description?: string;
primary_language?: string;
/**
* Bytes of code per language.
*/
languages: Record<string, number>;
topics: string[];
license?: string;
stars: number;
forks: number;
contributors_count: number;
open_issues_count: number;
is_archived: boolean;
/**
* Nothing pushed for six months. Lets the frontend avoid presenting
* dead work as live.
*/
is_dormant: boolean;
pushed_at?: string;
synced_at?: string;
good_first_issues: PublicIssue[];
}
export interface PublicService {
id: number;
locale: string;
title: string;
slug: string;
tagline?: string;
/**
* Detail endpoint only.
*/
description?: string;
/**
* What a search engine or a chat preview should show instead of the
* title. Detail endpoint only.
*/
meta_title?: string;
/**
* Detail endpoint only.
*/
meta_description?: string;
/**
* A Heroicons name, e.g. `heroicon-o-code-bracket`.
*/
icon?: string;
is_featured: boolean;
position: number;
/**
* So a cache downstream can tell whether its copy is still the
* current one.
*/
updated_at?: string;
/**
* The published work that backs the offer. Detail endpoint only.
*/
projects: PublicProject[];
technologies: PublicTechnology[];
testimonials: PublicTestimonial[];
alternates: Alternates;
path?: string;
}
export interface PublicTalk {
id: number;
/**
* Not translated: the name of a talk given once, in one language.
*/
title: string;
language: string;
summary?: string;
event: string;
city?: string;
held_at?: string;
video_url?: string;
slides_url?: string;
speaker?: PublicMember;
}
export interface PublicTechnology {
id: number;
/**
* Not translated — technology names are proper nouns.
*/
name: string;
slug: string;
category: 'language' | 'framework' | 'datastore' | 'infrastructure' | 'tool' | 'practice';
color?: string;
icon?: string;
/**
* The technology's own logo. Its `alt` is the technology's name —
* there is nothing to write and nothing to get wrong.
*/
logo?: Image;
website?: string;
/**
* Radar placement. Null for technologies used but not placed.
*/
ring?: 'adopt' | 'trial' | 'assess' | 'hold';
description?: string;
/**
* Why it sits in that ring.
*/
radar_note?: string;
projects_count: number;
members_count: number;
}
export interface PublicTestimonial {
id: number;
quote: string;
/**
* The language it was actually said in.
*/
original_locale: string;
/**
* False when the quote you are reading was translated by us. Say so —
* "translated from Spanish" — rather than implying they phrased it
* that way.
*/
is_original: boolean;
author: { name: string; role?: string; avatar?: Image };
given_at?: string;
client?: PublicClient;
}
/**
* Either a plain string (applies to the active language) or an object
* keyed by locale.
*/
export type TranslatedField = Record<string, string>;
/** What each public endpoint answers with, under `data`. */
export interface PublicApiPayloads {
'/public/v1/site': { locales: ({ code: string; name: string; flag: string; is_default: boolean })[]; default_locale: string; content_version: number; submissions_enabled: boolean; stats: { projects: number; active_projects: number; members: number; open_calls: number; good_first_issues: number; stars: number } };
'/public/v1/slugs': Record<string, ({ id: number; updated_at: string; alternates: Record<string, string> })[]>;
'/public/v1/redirects': ({ from: string; to: string; status: 301 | 302; locale?: string })[];
'/public/v1/projects': PublicProject[];
'/public/v1/projects/{slug}': PublicProject;
'/public/v1/members': PublicMember[];
'/public/v1/members/{slug}': PublicMember;
'/public/v1/calls': PublicCall[];
'/public/v1/calls/{slug}': PublicCall;
'/public/v1/services': PublicService[];
'/public/v1/services/{slug}': PublicService;
'/public/v1/clients': PublicClient[];
'/public/v1/testimonials': PublicTestimonial[];
'/public/v1/talks': PublicTalk[];
'/public/v1/pages': PublicPage[];
'/public/v1/pages/{slug}': PublicPage;
'/public/v1/technologies': PublicTechnology[];
'/public/v1/radar': PublicTechnology[];
'/public/v1/good-first-issues': PublicIssue[];
}

View File

@@ -0,0 +1,92 @@
/**
* Dark mode, owned here rather than by the library.
*
* `PiThemeService` already toggles `html.dark-mode` and persists the choice,
* but its `readStoredDarkMode()` is `!!localStorage.getItem(key)` while
* `persist()` writes the boolean straight through — so turning dark mode *off*
* stores the string `"false"`, which reads back as `true`. Once a visitor had
* ever switched it on, they could never switch it off again.
*
* This service keeps its own key with an explicit value, honours the operating
* system on a first visit, and drives `PiThemeService` so the class the
* library's own effect writes stays in agreement.
*/
import {
computed,
inject,
Injectable,
makeEnvironmentProviders,
PLATFORM_ID,
provideAppInitializer,
type EnvironmentProviders,
} from '@angular/core';
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import { PiThemeService } from '@pi/ui';
/** Read by the inline script in `index.html` too — keep the two in step. */
export const APPEARANCE_KEY = 'landing-appearance';
@Injectable({ providedIn: 'root' })
export class AppearanceService {
private readonly theme = inject(PiThemeService);
private readonly document = inject(DOCUMENT);
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
readonly dark = computed(() => this.theme.darkMode());
/**
* Runs once at startup. The server has no way to know, so it renders light
* and the inline script in `index.html` has already put the class on
* `<html>` before first paint — this only brings the service into line.
*/
restore(): void {
this.theme.setDarkMode(this.preferred());
}
toggle(): void {
this.set(!this.dark());
}
set(dark: boolean): void {
this.theme.setDarkMode(dark);
if (this.isBrowser) {
try {
this.window()?.localStorage.setItem(APPEARANCE_KEY, dark ? 'dark' : 'light');
} catch {
// Private browsing, or storage switched off. The choice simply does
// not outlive the visit.
}
}
}
private preferred(): boolean {
if (!this.isBrowser) {
return false;
}
const view = this.window();
try {
const stored = view?.localStorage.getItem(APPEARANCE_KEY);
if (stored === 'dark' || stored === 'light') {
return stored === 'dark';
}
} catch {
// Fall through to the system preference.
}
return view?.matchMedia('(prefers-color-scheme: dark)').matches ?? false;
}
private window(): (Window & typeof globalThis) | null {
return this.document.defaultView;
}
}
export function provideAppearance(): EnvironmentProviders {
return makeEnvironmentProviders([
provideAppInitializer(() => inject(AppearanceService).restore()),
]);
}

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { SUPPORTED_LOCALES } from './api';
import { copyFor } from './copy';
/**
* The dictionaries are hand-written, so the thing worth testing is that none
* of them drifted: a missing key renders as an empty heading in production
* and nowhere else.
*/
describe('copyFor', () => {
const reference = copyFor('es');
function leafPaths(value: unknown, prefix = ''): string[] {
if (typeof value !== 'object' || value === null) {
return [prefix];
}
return Object.entries(value).flatMap(([key, child]) =>
leafPaths(child, prefix ? `${prefix}.${key}` : key)
);
}
it.each(SUPPORTED_LOCALES)('has every key in %s', (locale) => {
expect(leafPaths(copyFor(locale)).sort()).toEqual(leafPaths(reference).sort());
});
it('never answers with an empty string', () => {
for (const locale of SUPPORTED_LOCALES) {
const empties = leafPaths(copyFor(locale)).filter((path) => {
const value = path
.split('.')
.reduce<unknown>(
(node, key) => (node as Record<string, unknown>)[key],
copyFor(locale)
);
return typeof value === 'string' && value.trim() === '';
});
expect(empties).toEqual([]);
}
});
it('falls back to Spanish for a locale it does not know', () => {
expect(copyFor('pt' as never)).toBe(reference);
});
});

View File

@@ -0,0 +1,390 @@
/**
* The chrome's own words. Content arrives from the API already resolved to
* one language; headings, labels and button text are ours, so they live here
* and are picked with the same locale.
*/
import type { Locale } from './api';
export interface Copy {
nav: {
services: string;
projects: string;
team: string;
contribute: string;
contact: string;
darkMode: string;
lightMode: string;
};
hero: {
eyebrow: string;
title: string;
highlight: string;
lead: string;
primaryCta: string;
secondaryCta: string;
};
stats: Record<
| 'projects'
| 'active_projects'
| 'members'
| 'open_calls'
| 'services'
| 'clients'
| 'good_first_issues'
| 'stars',
string
>;
sections: {
services: { title: string; lead: string };
projects: { title: string; lead: string };
clients: { title: string; lead: string };
testimonials: { title: string; lead: string };
radar: { title: string; lead: string };
team: { title: string; lead: string };
calls: { title: string; lead: string };
issues: { title: string; lead: string };
talks: { title: string; lead: string };
contact: { title: string; lead: string };
};
projectStatus: Record<
'idea' | 'active' | 'paused' | 'completed' | 'archived',
string
>;
rings: Record<'adopt' | 'trial' | 'assess' | 'hold', string>;
labels: {
demo: string;
repository: string;
stars: string;
needsHelp: string;
founder: string;
alumni: string;
hoursPerWeek: string;
weeks: string;
seniority: string;
closes: string;
apply: string;
watchTalk: string;
slides: string;
openIssue: string;
comments: string;
empty: string;
seeMore: string;
};
form: {
kind: string;
kindContribute: string;
kindRequest: string;
name: string;
email: string;
organisation: string;
hours: string;
message: string;
messageHint: string;
consent: string;
submit: string;
sending: string;
success: string;
duplicate: string;
error: string;
closed: string;
};
footer: { built: string; api: string; rights: string };
maintenance: { title: string; back: string; until: string };
}
const es: Copy = {
nav: {
services: 'Servicios',
projects: 'Proyectos',
team: 'Equipo',
contribute: 'Colabora',
contact: 'Contacto',
darkMode: 'Modo oscuro',
lightMode: 'Modo claro',
},
hero: {
eyebrow: 'Colectivo de desarrollo',
title: 'Construimos software',
highlight: 'en abierto',
lead: 'Proyectos reales, código público y gente que enseña lo que aprende. Si tienes una idea que empujar o unas horas que aportar, este es el sitio.',
primaryCta: 'Ver proyectos',
secondaryCta: 'Hablemos',
},
stats: {
projects: 'Proyectos',
active_projects: 'En marcha',
members: 'Personas',
open_calls: 'Vacantes abiertas',
services: 'Servicios',
clients: 'Clientes',
good_first_issues: 'Issues para empezar',
stars: 'Estrellas',
},
sections: {
services: { title: 'Qué hacemos', lead: 'Lo que alguien nos pediría.' },
projects: { title: 'Proyectos', lead: 'Lo que ya hemos construido.' },
clients: { title: 'Con quién trabajamos', lead: 'Organizaciones detrás del trabajo.' },
testimonials: { title: 'Lo que dicen', lead: 'Palabras de quien nos contrató.' },
radar: { title: 'Radar técnico', lead: 'Qué adoptamos, qué probamos y qué dejamos pasar.' },
team: { title: 'Quiénes somos', lead: 'Las personas detrás de todo esto.' },
calls: { title: 'Buscamos gente', lead: 'Vacantes abiertas ahora mismo.' },
issues: { title: 'Primeras contribuciones', lead: 'Issues pensadas para empezar hoy.' },
talks: { title: 'Charlas', lead: 'Lo que hemos contado fuera.' },
contact: { title: 'Escríbenos', lead: 'Cuéntanos qué necesitas o qué te apetece hacer.' },
},
projectStatus: {
idea: 'Idea',
active: 'Activo',
paused: 'En pausa',
completed: 'Terminado',
archived: 'Archivado',
},
rings: { adopt: 'Adoptar', trial: 'Probar', assess: 'Evaluar', hold: 'Esperar' },
labels: {
demo: 'Demo',
repository: 'Repositorio',
stars: 'estrellas',
needsHelp: 'Busca ayuda',
founder: 'Fundador/a',
alumni: 'Alumni',
hoursPerWeek: 'h/semana',
weeks: 'semanas',
seniority: 'Nivel',
closes: 'Cierra el',
apply: 'Me interesa',
watchTalk: 'Ver charla',
slides: 'Diapositivas',
openIssue: 'Abrir issue',
comments: 'comentarios',
empty: 'Todavía no hay nada publicado aquí.',
seeMore: 'Ver más',
},
form: {
kind: 'Escribes para…',
kindContribute: 'Aportar tiempo',
kindRequest: 'Pedir ayuda para un proyecto',
name: 'Nombre',
email: 'Correo',
organisation: 'Organización',
hours: 'Horas por semana',
message: 'Mensaje',
messageHint: 'Mínimo 20 caracteres.',
consent: 'Acepto que guardéis mis datos para responderme.',
submit: 'Enviar',
sending: 'Enviando…',
success: 'Mensaje recibido. Te contestamos en cuanto podamos.',
duplicate: 'Ya teníamos este mensaje. No hace falta reenviarlo.',
error: 'No hemos podido enviarlo. Inténtalo en un rato.',
closed: 'El formulario está cerrado temporalmente.',
},
footer: {
built: 'Hecho con Angular, Analog y Nx.',
api: 'Contenido servido por la API pública de Pireon.',
rights: 'Todos los derechos reservados.',
},
maintenance: {
title: 'Estamos de mantenimiento',
back: 'Volvemos en un rato.',
until: 'Volvemos el',
},
};
const en: Copy = {
nav: {
services: 'Services',
projects: 'Projects',
team: 'Team',
contribute: 'Contribute',
contact: 'Contact',
darkMode: 'Dark mode',
lightMode: 'Light mode',
},
hero: {
eyebrow: 'A development collective',
title: 'We build software',
highlight: 'in the open',
lead: 'Real projects, public code and people who teach what they learn. If you have an idea to push or a few hours to give, you are in the right place.',
primaryCta: 'See projects',
secondaryCta: "Let's talk",
},
stats: {
projects: 'Projects',
active_projects: 'In progress',
members: 'People',
open_calls: 'Open calls',
services: 'Services',
clients: 'Clients',
good_first_issues: 'Good first issues',
stars: 'Stars',
},
sections: {
services: { title: 'What we do', lead: 'What somebody would be asking for.' },
projects: { title: 'Projects', lead: 'What we have already built.' },
clients: { title: 'Who we work with', lead: 'The organisations behind the work.' },
testimonials: { title: 'What they said', lead: 'Words from the people who hired us.' },
radar: { title: 'Tech radar', lead: 'What we adopt, trial, assess and hold.' },
team: { title: 'Who we are', lead: 'The people behind all of this.' },
calls: { title: 'We are looking for people', lead: 'Calls open right now.' },
issues: { title: 'First contributions', lead: 'Issues meant to be picked up today.' },
talks: { title: 'Talks', lead: 'What we have told elsewhere.' },
contact: { title: 'Write to us', lead: 'Tell us what you need, or what you feel like building.' },
},
projectStatus: {
idea: 'Idea',
active: 'Active',
paused: 'Paused',
completed: 'Completed',
archived: 'Archived',
},
rings: { adopt: 'Adopt', trial: 'Trial', assess: 'Assess', hold: 'Hold' },
labels: {
demo: 'Demo',
repository: 'Repository',
stars: 'stars',
needsHelp: 'Needs help',
founder: 'Founder',
alumni: 'Alumni',
hoursPerWeek: 'h/week',
weeks: 'weeks',
seniority: 'Level',
closes: 'Closes on',
apply: "I'm interested",
watchTalk: 'Watch talk',
slides: 'Slides',
openIssue: 'Open issue',
comments: 'comments',
empty: 'Nothing published here yet.',
seeMore: 'See more',
},
form: {
kind: 'You are writing to…',
kindContribute: 'Give some time',
kindRequest: 'Ask for help with a project',
name: 'Name',
email: 'E-mail',
organisation: 'Organisation',
hours: 'Hours per week',
message: 'Message',
messageHint: 'At least 20 characters.',
consent: 'I agree to have my details stored so you can reply.',
submit: 'Send',
sending: 'Sending…',
success: 'Message received. We will get back to you as soon as we can.',
duplicate: 'We already had this message. No need to send it again.',
error: 'We could not send it. Please try again in a while.',
closed: 'The form is temporarily closed.',
},
footer: {
built: 'Built with Angular, Analog and Nx.',
api: 'Content served by the Pireon public API.',
rights: 'All rights reserved.',
},
maintenance: {
title: 'We are under maintenance',
back: 'Back in a while.',
until: 'Back on',
},
};
const ca: Copy = {
nav: {
services: 'Serveis',
projects: 'Projectes',
team: 'Equip',
contribute: 'Col·labora',
contact: 'Contacte',
darkMode: 'Mode fosc',
lightMode: 'Mode clar',
},
hero: {
eyebrow: 'Col·lectiu de desenvolupament',
title: 'Construïm programari',
highlight: 'en obert',
lead: 'Projectes reals, codi públic i gent que ensenya el que aprèn. Si tens una idea per empènyer o unes hores per aportar, aquest és el lloc.',
primaryCta: 'Veure projectes',
secondaryCta: 'Parlem-ne',
},
stats: {
projects: 'Projectes',
active_projects: 'En marxa',
members: 'Persones',
open_calls: 'Vacants obertes',
services: 'Serveis',
clients: 'Clients',
good_first_issues: 'Issues per començar',
stars: 'Estrelles',
},
sections: {
services: { title: 'Què fem', lead: 'El que algú ens demanaria.' },
projects: { title: 'Projectes', lead: 'El que ja hem construït.' },
clients: { title: 'Amb qui treballem', lead: 'Organitzacions darrere de la feina.' },
testimonials: { title: 'Què en diuen', lead: 'Paraules de qui ens ha contractat.' },
radar: { title: 'Radar tècnic', lead: 'Què adoptem, què provem i què deixem passar.' },
team: { title: 'Qui som', lead: 'Les persones darrere de tot això.' },
calls: { title: 'Busquem gent', lead: 'Vacants obertes ara mateix.' },
issues: { title: 'Primeres contribucions', lead: 'Issues pensades per començar avui.' },
talks: { title: 'Xerrades', lead: 'El que hem explicat a fora.' },
contact: { title: 'Escriu-nos', lead: 'Explica què necessites o què et ve de gust fer.' },
},
projectStatus: {
idea: 'Idea',
active: 'Actiu',
paused: 'En pausa',
completed: 'Acabat',
archived: 'Arxivat',
},
rings: { adopt: 'Adoptar', trial: 'Provar', assess: 'Avaluar', hold: 'Esperar' },
labels: {
demo: 'Demo',
repository: 'Repositori',
stars: 'estrelles',
needsHelp: 'Busca ajuda',
founder: 'Fundador/a',
alumni: 'Alumni',
hoursPerWeek: 'h/setmana',
weeks: 'setmanes',
seniority: 'Nivell',
closes: 'Tanca el',
apply: "M'interessa",
watchTalk: 'Veure xerrada',
slides: 'Diapositives',
openIssue: 'Obrir issue',
comments: 'comentaris',
empty: 'Encara no hi ha res publicat aquí.',
seeMore: 'Veure més',
},
form: {
kind: 'Escrius per…',
kindContribute: 'Aportar temps',
kindRequest: 'Demanar ajuda per a un projecte',
name: 'Nom',
email: 'Correu',
organisation: 'Organització',
hours: 'Hores per setmana',
message: 'Missatge',
messageHint: 'Mínim 20 caràcters.',
consent: 'Accepto que deseu les meves dades per respondre’m.',
submit: 'Enviar',
sending: 'Enviant…',
success: 'Missatge rebut. Et contestem tan aviat com puguem.',
duplicate: 'Ja teníem aquest missatge. No cal reenviar-lo.',
error: 'No hem pogut enviar-lo. Torna-ho a provar d’aquí una estona.',
closed: 'El formulari està tancat temporalment.',
},
footer: {
built: 'Fet amb Angular, Analog i Nx.',
api: 'Contingut servit per l’API pública de Pireon.',
rights: 'Tots els drets reservats.',
},
maintenance: {
title: 'Estem de manteniment',
back: 'Tornem d’aquí una estona.',
until: 'Tornem el',
},
};
const dictionaries: Record<Locale, Copy> = { es, en, ca };
export function copyFor(locale: Locale): Copy {
return dictionaries[locale] ?? dictionaries.es;
}

View File

@@ -0,0 +1,53 @@
/**
* Which breakpoint the viewport is in, as a signal.
*
* `pi-carousel` takes a `device` and decides how many items fit from it. The
* library ships `DeviceConfigsFacade` for this, but nothing in it observes the
* window — an app is expected to call `setDevice()` itself — so this reads the
* width directly and keeps the value up to date.
*
* The server has no viewport. It answers `DESKTOP`, which is what the carousel
* renders widest: a phone briefly sees three testimonials side by side rather
* than one blank frame, and hydration corrects it.
*/
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import {
DestroyRef,
inject,
PLATFORM_ID,
signal,
type Signal,
} from '@angular/core';
import type { Device } from '@pi/ui';
const TABLET_FROM = 640;
const DESKTOP_FROM = 1024;
function deviceFor(width: number): Device {
if (width >= DESKTOP_FROM) {
return 'DESKTOP';
}
return width >= TABLET_FROM ? 'TABLET' : 'MOBILE';
}
export function injectDevice(): Signal<Device> {
const document = inject(DOCUMENT);
const isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
const view = document.defaultView;
const device = signal<Device>(
isBrowser && view ? deviceFor(view.innerWidth) : 'DESKTOP'
);
if (isBrowser && view) {
const onResize = () => device.set(deviceFor(view.innerWidth));
view.addEventListener('resize', onResize, { passive: true });
inject(DestroyRef).onDestroy(() =>
view.removeEventListener('resize', onResize)
);
}
return device.asReadonly();
}

View File

@@ -0,0 +1,121 @@
/**
* The landing's icon set, inlined.
*
* `@pi/ui` ships no SVGs: `pi-icon` renders a `mat-icon` whose name is looked
* up in Angular Material's registry, and the library's own `IconRegistryService`
* registers every name as a URL under `/assets/icons/`. That means one HTTP
* request per icon — and during SSR those requests have no origin to resolve
* against, so every one of them fails and the server HTML comes out without
* icons. Registered as literals instead, they are just strings: no network,
* and the icon is in the first byte the browser receives.
*
* The same files are still on disk under `public/assets/icons` for the
* `type="custom"` variants of `pi-icon` / `pi-icon-button`, which render an
* `<img>` rather than reading the registry.
*/
import {
inject,
makeEnvironmentProviders,
provideAppInitializer,
type EnvironmentProviders,
} from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import { MatIconRegistry } from '@angular/material/icon';
const LANDING_ICONS: Record<string, string> = {
'arrow-forward':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h15M13 6l6 6-6 6"/></svg>',
'briefcase':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M9 7V5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2M3 12h18"/></svg>',
'buildings':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21V8l6-3v16M9 21h12V11l-6-2M13 21v-4h4v4"/><path d="M6 11h.01M6 15h.01"/></svg>',
'calendar':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/></svg>',
'chat':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a8 8 0 0 1-11.6 7.1L4 20.5l1.4-5.2A8 8 0 1 1 21 12Z"/></svg>',
'check-rounded':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="m8 12.2 2.7 2.8L16 9.5"/></svg>',
'check':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m4.5 12.5 5 5 10-11"/></svg>',
'chevron-back':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m14.5 6-6 6 6 6"/></svg>',
'chevron-down':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9.5 6 6 6-6"/></svg>',
'chevron-forward':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m9.5 6 6 6-6 6"/></svg>',
'chevron-up':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m6 14.5 6-6 6 6"/></svg>',
'close-rounded':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="m9 9 6 6M15 9l-6 6"/></svg>',
'close':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6l12 12M18 6 6 18"/></svg>',
'coins':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="6.5" rx="7" ry="3"/><path d="M5 6.5v5c0 1.7 3.1 3 7 3s7-1.3 7-3v-5M5 11.5v5c0 1.7 3.1 3 7 3s7-1.3 7-3v-5"/></svg>',
'document':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8l-5-5Z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></svg>',
'email':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3.5 7 7.4 5.3a2 2 0 0 0 2.2 0L20.5 7"/></svg>',
'exclamation':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5.5M12 16.3h.01"/></svg>',
'focus':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M3 8V5a2 2 0 0 1 2-2h3M16 3h3a2 2 0 0 1 2 2v3M21 16v3a2 2 0 0 1-2 2h-3M8 21H5a2 2 0 0 1-2-2v-3"/></svg>',
'globe':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18Z"/></svg>',
'graphic':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20V4M4 20h16"/><path d="m7.5 15 3.5-4 3 2.6L20 7"/></svg>',
'grid':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7.5" height="7.5" rx="1.5"/><rect x="13.5" y="3" width="7.5" height="7.5" rx="1.5"/><rect x="3" y="13.5" width="7.5" height="7.5" rx="1.5"/><rect x="13.5" y="13.5" width="7.5" height="7.5" rx="1.5"/></svg>',
'info':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 11v5.5M12 7.7h.01"/></svg>',
'light-bulb':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6M10 21h4"/><path d="M12 3a6 6 0 0 0-3.5 10.9c.6.4 1 1.1 1 1.9v.2h5v-.2c0-.8.4-1.5 1-1.9A6 6 0 0 0 12 3Z"/></svg>',
'link':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.1.1l2.9-2.9a5 5 0 0 0-7.1-7.1L11 4.9"/><path d="M14 11a5 5 0 0 0-7.1-.1L4 13.8a5 5 0 0 0 7.1 7.1l1.9-1.9"/></svg>',
'list':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6h13M8 12h13M8 18h13M3.5 6h.01M3.5 12h.01M3.5 18h.01"/></svg>',
'location':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21s7-5.6 7-11a7 7 0 1 0-14 0c0 5.4 7 11 7 11Z"/><circle cx="12" cy="10" r="2.5"/></svg>',
'moon':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13.4A8 8 0 1 1 10.6 4a6.6 6.6 0 0 0 9.4 9.4Z"/></svg>',
'play-arrow':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8 5.5v13l10-6.5-10-6.5Z"/></svg>',
'search':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="6.5"/><path d="m16 16 4.5 4.5"/></svg>',
'star-fill':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="m12 3.6 2.5 5.2 5.7.8-4.1 4 1 5.6-5.1-2.7-5.1 2.7 1-5.6-4.1-4 5.7-.8L12 3.6Z"/></svg>',
'sun':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.3 5.3l1.4 1.4M17.3 17.3l1.4 1.4M18.7 5.3l-1.4 1.4M6.7 17.3l-1.4 1.4"/></svg>',
'support':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M9.5 9.5 5.6 5.6M14.5 9.5l3.9-3.9M14.5 14.5l3.9 3.9M9.5 14.5l-3.9 3.9"/><circle cx="12" cy="12" r="3.5"/></svg>',
'time-detail':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5.2l3.2 2"/></svg>',
'user':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21v-1a6 6 0 0 1 6-6h4a6 6 0 0 1 6 6v1"/></svg>',
'verification-list':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4h9a2 2 0 0 1 2 2v13a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V8"/><path d="M4 5.5 5.8 7.5 9 3.8M9 11h7M9 15h5"/></svg>',
};
/** Names that resolve. Anything else must not reach `pi-icon`. */
export const SHIPPED_ICONS = new Set(Object.keys(LANDING_ICONS));
/**
* Services carry a free-form `icon` from the panel: it may be one of these
* names, an emoji, or something nobody has drawn yet. A miss in the registry
* throws rather than degrading, so nothing from the API reaches it unchecked.
*/
export function resolveIcon(name: string | undefined, fallback: string): string {
return name && SHIPPED_ICONS.has(name) ? name : fallback;
}
export function provideLandingIcons(): EnvironmentProviders {
return makeEnvironmentProviders([
provideAppInitializer(() => {
const registry = inject(MatIconRegistry);
const sanitizer = inject(DomSanitizer);
for (const [name, svg] of Object.entries(LANDING_ICONS)) {
registry.addSvgIconLiteral(name, sanitizer.bypassSecurityTrustHtml(svg));
}
}),
]);
}

View File

@@ -0,0 +1,71 @@
/**
* The handful of strings `@pi/ui` translates on its own — a field's error
* message, a chart legend, a close label.
*
* They are provided synchronously rather than through the library's
* `providePiTranslate()`, which fetches `./assets/i18n/*.json` over HTTP: on
* the server that request has no origin to resolve against, so it fails on
* every render and the strings would only appear after hydration. The
* landing's own copy lives in `copy.ts` for the same reason.
*/
import {
provideTranslateService,
TranslateService,
type TranslationObject,
} from '@ngx-translate/core';
import {
inject,
makeEnvironmentProviders,
provideAppInitializer,
type EnvironmentProviders,
} from '@angular/core';
import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type Locale } from './api';
const STRINGS: Record<Locale, TranslationObject> = {
es: {
common: {
close: 'Cerrar',
edit: 'Editar',
legend: 'Leyenda',
min: 'Mín',
max: 'Máx',
confidentialInfo: 'Información confidencial',
},
},
en: {
common: {
close: 'Close',
edit: 'Edit',
legend: 'Legend',
min: 'Min',
max: 'Max',
confidentialInfo: 'Confidential information',
},
},
ca: {
common: {
close: 'Tancar',
edit: 'Editar',
legend: 'Llegenda',
min: 'Mín',
max: 'Màx',
confidentialInfo: 'Informació confidencial',
},
},
};
export function providePiStrings(): EnvironmentProviders {
return makeEnvironmentProviders([
provideTranslateService({ fallbackLang: DEFAULT_LOCALE }),
provideAppInitializer(() => {
const translate = inject(TranslateService);
for (const locale of SUPPORTED_LOCALES) {
translate.setTranslation(locale, STRINGS[locale]);
}
translate.use(DEFAULT_LOCALE);
}),
]);
}

View File

@@ -0,0 +1,405 @@
import { DOCUMENT } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { Meta, Title } from '@angular/platform-browser';
import { injectLoad, type RouteMeta } from '@analogjs/router';
import { injectResponse } from '@analogjs/router/tokens';
import {
PiCardCenterContentDirective,
PiCardComponent,
PiChipComponent,
} from '@pi/ui';
import { copyFor } from '../core/copy';
import { CallCard } from '../ui/call-card';
import { ClientLogo } from '../ui/client-logo';
import { ContactForm } from '../ui/contact-form';
import { Hero } from '../ui/hero';
import { IssueRow } from '../ui/issue-row';
import { MaintenanceScreen } from '../ui/maintenance-screen';
import { MemberCard } from '../ui/member-card';
import { ProjectCard } from '../ui/project-card';
import { Section } from '../ui/section';
import { ServiceCard } from '../ui/service-card';
import { SiteFooter } from '../ui/site-footer';
import { SiteHeader, type NavLink } from '../ui/site-header';
import { TalkCard } from '../ui/talk-card';
import { TestimonialCarousel } from '../ui/testimonial-carousel';
import { load } from './(home).server';
export const routeMeta: RouteMeta = {
title: 'Pireon — Construimos software en abierto',
meta: [
{
name: 'description',
content:
'Colectivo de desarrollo: proyectos abiertos, servicios, vacantes para colaborar y contacto directo.',
},
{ property: 'og:title', content: 'Pireon — Construimos software en abierto' },
{ property: 'og:type', content: 'website' },
],
};
@Component({
selector: 'app-home',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
PiCardComponent,
PiCardCenterContentDirective,
PiChipComponent,
CallCard,
ClientLogo,
ContactForm,
Hero,
IssueRow,
MaintenanceScreen,
MemberCard,
ProjectCard,
Section,
ServiceCard,
SiteFooter,
SiteHeader,
TalkCard,
TestimonialCarousel,
],
styleUrl: './home.page.scss',
template: `
@if (data().maintenance; as notice) {
<app-maintenance-screen
[notice]="notice"
[copy]="copy()"
[locale]="data().locale"
/>
} @else {
<a class="landing-skip" href="#contenido">Saltar al contenido</a>
<app-site-header
[copy]="copy()"
[locale]="data().locale"
[links]="navLinks()"
[locales]="locales()"
(navigate)="scrollTo($event)"
/>
<main id="contenido">
<app-hero
[copy]="copy()"
[site]="data().site"
[locale]="data().locale"
[primaryHref]="heroTarget()"
(navigate)="scrollTo($event)"
/>
@if (data().services.length > 0) {
<app-section
anchor="servicios"
[title]="copy().sections.services.title"
[lead]="copy().sections.services.lead"
>
<div class="landing-grid">
@for (service of data().services; track service.id) {
<app-service-card [service]="service" />
}
</div>
</app-section>
}
@if (data().projects.length > 0) {
<app-section
anchor="proyectos"
[title]="copy().sections.projects.title"
[lead]="copy().sections.projects.lead"
>
<div class="landing-grid">
@for (project of data().projects; track project.id) {
<app-project-card [project]="project" [copy]="copy()" />
}
</div>
</app-section>
}
@if (data().clients.length > 0) {
<app-section
anchor="clientes"
[title]="copy().sections.clients.title"
[lead]="copy().sections.clients.lead"
>
<div class="landing-grid landing-grid--logos">
@for (client of data().clients; track client.id) {
<app-client-logo [client]="client" />
}
</div>
</app-section>
}
@if (data().testimonials.length > 0) {
<app-section
anchor="testimonios"
[title]="copy().sections.testimonials.title"
[lead]="copy().sections.testimonials.lead"
>
<app-testimonial-carousel [testimonials]="data().testimonials" />
</app-section>
}
@if (data().radar.length > 0) {
<app-section
anchor="radar"
[title]="copy().sections.radar.title"
[lead]="copy().sections.radar.lead"
>
<div class="landing-grid landing-grid--four">
@for (group of radarRings(); track group.ring) {
<pi-card
class="landing-card"
[hasShadow]="true"
[isStroked]="true"
gap="m"
padding="l"
innerPadding="none"
alignment="stretch"
context="public"
>
<pi-card-center-content>
<p class="landing-eyebrow">{{ group.label }}</p>
<ul class="landing-card__chips">
@for (technology of group.technologies; track technology.id) {
<li>
<pi-chip
size="small"
appearance="outline"
[label]="technology.name"
/>
</li>
}
</ul>
</pi-card-center-content>
</pi-card>
}
</div>
</app-section>
}
@if (data().members.length > 0) {
<app-section
anchor="equipo"
[title]="copy().sections.team.title"
[lead]="copy().sections.team.lead"
>
<div class="landing-grid landing-grid--four">
@for (member of data().members; track member.id) {
<app-member-card [member]="member" [copy]="copy()" />
}
</div>
</app-section>
}
@if (data().calls.length > 0 || data().issues.length > 0) {
<app-section
anchor="colabora"
[title]="copy().sections.calls.title"
[lead]="copy().sections.calls.lead"
>
@if (data().calls.length > 0) {
<div class="landing-grid">
@for (call of data().calls; track call.id) {
<app-call-card
[call]="call"
[copy]="copy()"
[locale]="data().locale"
(apply)="scrollTo('#contacto')"
/>
}
</div>
}
@if (data().issues.length > 0) {
<div class="landing-subsection">
<h3 class="landing-subsection__title">
{{ copy().sections.issues.title }}
</h3>
<p class="landing-subsection__lead">
{{ copy().sections.issues.lead }}
</p>
<ul class="landing-issues">
@for (issue of data().issues; track issue.url) {
<li><app-issue-row [issue]="issue" [copy]="copy()" /></li>
}
</ul>
</div>
}
</app-section>
}
@if (data().talks.length > 0) {
<app-section
anchor="charlas"
[title]="copy().sections.talks.title"
[lead]="copy().sections.talks.lead"
>
<div class="landing-grid">
@for (talk of data().talks; track talk.id) {
<app-talk-card [talk]="talk" [copy]="copy()" [locale]="data().locale" />
}
</div>
</app-section>
}
<app-section
anchor="contacto"
[title]="copy().sections.contact.title"
[lead]="copy().sections.contact.lead"
>
<div class="landing-form__wrap">
<app-contact-form
[copy]="copy()"
[locale]="data().locale"
[enabled]="submissionsEnabled()"
/>
</div>
</app-section>
</main>
<app-site-footer [copy]="copy()" />
}
`,
})
export default class HomePage {
private readonly document = inject(DOCUMENT);
private readonly title = inject(Title);
private readonly meta = inject(Meta);
protected readonly data = toSignal(injectLoad<typeof load>(), {
requireSync: true,
});
protected readonly copy = computed(() => copyFor(this.data().locale));
/** The switcher needs somewhere to go even if `/site` was unreachable. */
protected readonly locales = computed(
() =>
this.data().site?.locales ?? [
{ code: 'es', name: 'Español', flag: '', is_default: true },
]
);
protected readonly submissionsEnabled = computed(
() => this.data().site?.submissions_enabled ?? true
);
/** Only the sections that actually have something in them get an anchor. */
protected readonly navLinks = computed<NavLink[]>(() => {
const data = this.data();
const copy = this.copy();
const candidates: (NavLink & { visible: boolean })[] = [
{
anchor: 'servicios',
label: copy.nav.services,
visible: data.services.length > 0,
},
{
anchor: 'proyectos',
label: copy.nav.projects,
visible: data.projects.length > 0,
},
{ anchor: 'equipo', label: copy.nav.team, visible: data.members.length > 0 },
{
anchor: 'colabora',
label: copy.nav.contribute,
visible: data.calls.length > 0 || data.issues.length > 0,
},
];
return candidates.filter((link) => link.visible);
});
/**
* Only offered when the projects section is actually there. Pointing "Ver
* proyectos" at the contact form because nothing is published yet is worse
* than showing one button.
*/
protected readonly heroTarget = computed(() =>
this.data().projects.length > 0 ? '#proyectos' : null
);
protected readonly radarRings = computed(() => {
const copy = this.copy();
const rings = ['adopt', 'trial', 'assess', 'hold'] as const;
return rings
.map((ring) => ({
ring,
label: copy.rings[ring],
technologies: this.data().radar.filter(
(technology) => technology.ring === ring
),
}))
.filter((group) => group.technologies.length > 0);
});
/**
* A maintenance page served as 200 is a page crawlers index and a CDN keeps.
*
* `injectResponse()` is the render request's own response — unlike the one a
* page `load` sees, which belongs to the internal request Analog resolves
* loaders through and never reaches the browser. Called from the constructor
* so it lands before anything is flushed.
*/
private answerWithMaintenanceStatus(): void {
const notice = this.data().maintenance;
const response = injectResponse();
if (!notice || !response || response.headersSent) {
return;
}
response.statusCode = 503;
if (notice.retry_after) {
response.setHeader('Retry-After', String(notice.retry_after));
}
// Overrides the `s-maxage` route rule: a cached maintenance page would
// outlive the maintenance.
response.setHeader('Cache-Control', 'no-store');
}
/**
* The Pi buttons render a `<button>`, so the anchors they point at have to
* be scrolled to by hand. `scrollIntoView` rather than a hash assignment so
* the URL keeps its `?lang=`.
*/
protected scrollTo(anchor: string): void {
this.document
.querySelector(anchor)
?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
constructor() {
this.answerWithMaintenanceStatus();
effect(() => {
const data = this.data();
const copy = this.copy();
// The document is served from one URL per language, so the lang
// attribute has to follow the locale the content was resolved into.
this.document.documentElement.lang = data.locale;
this.title.setTitle(
data.maintenance
? `Pireon — ${copy.maintenance.title}`
: `Pireon — ${copy.hero.title} ${copy.hero.highlight}`
);
this.meta.updateTag({ name: 'description', content: copy.hero.lead });
});
}
}

View File

@@ -0,0 +1,92 @@
/**
* Everything the landing page renders, fetched on the server in one pass.
*
* The API is designed for a server-rendered frontend — slugs instead of ids,
* `Vary: Accept-Language`, per-address submission throttling — so the whole
* page is assembled here and hydrated with the result. Calls run in parallel
* and none of them can reject: a section whose endpoint failed renders empty
* instead of taking the page down with it.
*/
import type { PageServerLoad } from '@analogjs/router';
import {
localeFromUrl,
pireonApi,
type Locale,
type MaintenanceNotice,
type SitePayload,
} from '../core/api';
import type {
PublicCall,
PublicClient,
PublicIssue,
PublicMember,
PublicProject,
PublicService,
PublicTalk,
PublicTechnology,
PublicTestimonial,
} from '../core/api.types';
export interface LandingData {
locale: Locale;
site: SitePayload | null;
maintenance: MaintenanceNotice | null;
services: PublicService[];
projects: PublicProject[];
clients: PublicClient[];
testimonials: PublicTestimonial[];
radar: PublicTechnology[];
members: PublicMember[];
calls: PublicCall[];
issues: PublicIssue[];
talks: PublicTalk[];
}
export const load = async ({ req }: PageServerLoad): Promise<LandingData> => {
// `params` carries route parameters only, and Analog resolves loaders
// through an internal request that forwards the query string but not the
// headers — so `?lang=` is the one signal available here.
const locale = localeFromUrl(req?.url);
const [
site,
services,
projects,
clients,
testimonials,
radar,
members,
calls,
issues,
talks,
] = await Promise.all([
pireonApi.site(locale),
pireonApi.services(locale),
pireonApi.projects(locale, { per_page: 6, translated: true }),
pireonApi.clients(locale),
pireonApi.testimonials(locale),
pireonApi.radar(locale),
pireonApi.members(locale),
pireonApi.calls(locale),
pireonApi.goodFirstIssues({ limit: 6 }),
pireonApi.talks(locale, { limit: 6 }),
]);
return {
locale,
site: site.data,
// `/site` is the one call worth believing about maintenance: it is the
// endpoint the frontend hits on every page anyway.
maintenance: site.maintenance,
services: services.data ?? [],
projects: projects.data ?? [],
clients: clients.data ?? [],
testimonials: testimonials.data ?? [],
radar: radar.data ?? [],
members: members.data ?? [],
calls: calls.data ?? [],
issues: issues.data ?? [],
talks: talks.data ?? [],
};
};

View File

@@ -0,0 +1,72 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import type { RouteMeta } from '@analogjs/router';
import {
PiActionButtonComponent,
PiIconComponent,
PiTextColorDirective,
PiTextMediumDirective,
PiTextSmallDirective,
PiTextWeightBoldDirective,
PiTextXLargeDirective,
} from '@pi/ui';
export const routeMeta: RouteMeta = {
title: 'Pireon — Página no encontrada',
meta: [{ name: 'robots', content: 'noindex' }],
};
/**
* The status itself comes from `src/server/middleware/not-found-status.ts`:
* a page `load` cannot set it, because Analog resolves loaders through an
* internal request whose response never reaches the browser.
*
* Composed from the library's parts rather than from `pi-empty-state`, which
* would have been the obvious fit: it renders its title as an `<h4>`, and a
* page whose only heading is an `h4` has no `h1` at all. `pi-not-found`, the
* library's own version of this page, is unusable here for a different reason
* — it renders a `pi-headline` pointing at an illustration on the collective's
* CDN and translates its text through an HTTP loader.
*/
@Component({
selector: 'app-not-found',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
PiIconComponent,
PiActionButtonComponent,
PiTextXLargeDirective,
PiTextMediumDirective,
PiTextSmallDirective,
PiTextColorDirective,
PiTextWeightBoldDirective,
],
styleUrl: './not-found.page.scss',
template: `
<main class="not-found">
<div class="landing-shell not-found__inner">
<p class="landing-eyebrow">404</p>
<pi-icon icon="search" size="x-large" color="grey" />
<h1 xl bold class="not-found__title">Esta página no existe</h1>
<p m pi-color color="grey" class="not-found__lead">
La dirección puede haber cambiado. Vuelve al inicio y sigue desde ahí.
</p>
<a class="not-found__home" href="/">
<pi-action-button
size="large"
color="primary"
text="Ir al inicio"
[hasIcon]="true"
/>
</a>
<p s pi-color color="grey" class="not-found__note">
Si has llegado desde un enlace nuestro, cuéntanoslo.
</p>
</div>
</main>
`,
})
export default class NotFoundPage {}

View File

@@ -0,0 +1,75 @@
// The home page's own layout: the grids each section drops its cards into,
// plus the skip link. Everything else belongs to a component.
.landing-grid {
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(1, minmax(0, 1fr));
@media (min-width: 640px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (min-width: 1024px) {
grid-template-columns: repeat(var(--landing-columns, 3), minmax(0, 1fr));
}
}
.landing-grid--four {
@media (min-width: 1024px) {
--landing-columns: 4;
}
}
.landing-grid--logos {
gap: 1rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
@media (min-width: 640px) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
@media (min-width: 1024px) {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}
.landing-issues {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 1.25rem;
padding: 0;
list-style: none;
}
.landing-subsection {
margin-top: 3rem;
}
.landing-subsection__title {
margin: 0;
font-size: 1.25rem;
}
.landing-subsection__lead {
margin: 0.375rem 0 0;
color: var(--pi-color-grey-600, #5b5b6b);
}
.landing-form__wrap {
max-width: 40rem;
}
.landing-skip {
position: absolute;
left: -9999px;
&:focus {
left: 1rem;
top: 1rem;
z-index: 100;
padding: 0.5rem 1rem;
background: var(--pi-color-white, #fff);
border-radius: 0.5rem;
}
}

View File

@@ -0,0 +1,35 @@
// Scoped to the page. Only the shared page primitives — `landing-shell`,
// `landing-eyebrow` — come from the global sheet.
.not-found {
display: flex;
align-items: center;
min-height: 100vh;
padding-block: 4rem;
}
.not-found__inner {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.not-found__title {
margin: 1rem 0 0;
}
.not-found__lead {
max-width: 34ch;
margin: 0.75rem 0 0;
}
.not-found__home {
display: inline-block;
margin-top: 2rem;
text-decoration: none;
}
.not-found__note {
margin-top: 3rem;
font-size: 0.8125rem;
}

View File

@@ -0,0 +1,50 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import type { Image } from '../core/api.types';
/**
* Renders an API `Image` for what it is worth.
*
* The payload carries `width`/`height` so the page does not reflow, a
* `srcset` so nobody downloads the original, and `alt` that is an empty
* string when the image is decorative. An upload whose derived versions are
* not ready yet still has a `url`, so this degrades to a plain `<img>`
* rather than to a gap.
*/
@Component({
selector: 'app-api-image',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<img
[src]="image().url"
[attr.srcset]="srcset()"
[attr.sizes]="srcset() ? sizes() : null"
[attr.width]="image().width ?? null"
[attr.height]="image().height ?? null"
[alt]="image().alt ?? ''"
[loading]="loading()"
decoding="async"
[class]="imgClass()"
/>
`,
})
export class ApiImage {
readonly image = input.required<Image>();
readonly sizes = input('(min-width: 768px) 33vw, 100vw');
readonly loading = input<'lazy' | 'eager'>('lazy');
readonly imgClass = input('h-full w-full object-cover');
protected readonly srcset = computed(() => {
const entries = Object.entries(this.image().srcset ?? {});
if (entries.length === 0) {
return null;
}
return entries
.map(([descriptor, url]) =>
/^\d+$/.test(descriptor) ? `${url} ${descriptor}w` : `${url} ${descriptor}`
)
.join(', ');
});
}

View File

@@ -0,0 +1,131 @@
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
import {
PiActionButtonComponent,
PiCardBottomContentDirective,
PiCardCenterContentDirective,
PiCardComponent,
PiCardTopContentDirective,
PiChipComponent,
PiTextColorDirective,
PiTextSmallDirective,
PiTextWeightBoldDirective,
PiTextXSmallDirective,
} from '@pi/ui';
import type { Copy } from '../core/copy';
import type { Locale } from '../core/api';
import type { PublicCall } from '../core/api.types';
@Component({
selector: 'app-call-card',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
PiCardComponent,
PiCardTopContentDirective,
PiCardCenterContentDirective,
PiCardBottomContentDirective,
PiChipComponent,
PiActionButtonComponent,
PiTextSmallDirective,
PiTextXSmallDirective,
PiTextColorDirective,
PiTextWeightBoldDirective,
],
template: `
<pi-card
class="landing-card"
[hasShadow]="true"
[isStroked]="true"
gap="m"
padding="l"
innerPadding="none"
alignment="stretch"
context="public"
>
<pi-card-top-content>
<div class="landing-card__chips">
@if (hours(); as label) {
<pi-chip size="small" color="accent" icon="time-detail" [label]="label" />
}
@if (weeks(); as label) {
<pi-chip size="small" appearance="outline" [label]="label" />
}
@if (call().seniority; as seniority) {
<pi-chip size="small" appearance="outline" [label]="seniority" />
}
</div>
</pi-card-top-content>
<pi-card-center-content>
<h3 bold class="landing-card__title">{{ call().title }}</h3>
@if (call().project?.title; as project) {
<p xs pi-color color="grey">{{ project }}</p>
}
@if (call().description; as description) {
<p s pi-color color="grey">{{ description }}</p>
}
@if (call().technologies?.length) {
<ul class="landing-card__chips">
@for (technology of call().technologies; track technology.id) {
<li>
<pi-chip size="small" appearance="outline" [label]="technology.name" />
</li>
}
</ul>
}
</pi-card-center-content>
<pi-card-bottom-content>
<div class="landing-card__footer">
@if (closesAt(); as closes) {
<span xs pi-color color="grey"
>{{ copy().labels.closes }} {{ closes }}</span
>
}
<pi-action-button
size="small"
color="primary"
appearance="outline"
[hasIcon]="true"
[text]="copy().labels.apply"
(execute)="apply.emit()"
/>
</div>
</pi-card-bottom-content>
</pi-card>
`,
})
export class CallCard {
readonly call = input.required<PublicCall>();
readonly copy = input.required<Copy>();
readonly locale = input.required<Locale>();
readonly apply = output<void>();
// `pi-chip-value` renders the number and its unit as adjacent spans with no
// separation, so "4h/semana" comes out as one word. Built here instead.
protected readonly hours = computed(() => {
const hours = this.call().hours_per_week;
return hours ? `${hours} ${this.copy().labels.hoursPerWeek}` : null;
});
protected readonly weeks = computed(() => {
const weeks = this.call().duration_weeks;
return weeks ? `${weeks} ${this.copy().labels.weeks}` : null;
});
protected readonly closesAt = computed(() => {
const value = this.call().closes_at;
if (!value) {
return null;
}
return new Intl.DateTimeFormat(this.locale(), {
day: 'numeric',
month: 'long',
}).format(new Date(value));
});
}

View File

@@ -0,0 +1,9 @@
.landing-logo {
display: block;
text-decoration: none;
color: inherit;
}
.landing-logo__name {
font-weight: 500;
}

View File

@@ -0,0 +1,46 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { PiCardComponent, PiCardCenterContentDirective } from '@pi/ui';
import type { PublicClient } from '../core/api.types';
import { ApiImage } from './api-image';
@Component({
selector: 'app-client-logo',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ApiImage, PiCardComponent, PiCardCenterContentDirective],
styleUrl: './client-logo.scss',
template: `
<a
class="landing-logo"
[href]="client().url || null"
[attr.target]="client().url ? '_blank' : null"
[attr.rel]="client().url ? 'noopener noreferrer' : null"
[attr.title]="client().name"
>
<pi-card
[isStroked]="true"
color="white"
alignment="center"
padding="m"
innerPadding="none"
gap="none"
context="public"
>
<pi-card-center-content>
@if (client().logo; as logo) {
<app-api-image
[image]="logo"
sizes="160px"
imgClass="landing-logo__mark"
/>
} @else {
<span class="landing-logo__name">{{ client().name }}</span>
}
</pi-card-center-content>
</pi-card>
</a>
`,
})
export class ClientLogo {
readonly client = input.required<PublicClient>();
}

View File

@@ -0,0 +1,30 @@
.landing-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.landing-form__kind {
border: 0;
margin: 0;
padding: 0;
legend {
padding: 0 0 0.5rem;
font-weight: 600;
}
}
.landing-form__row {
display: grid;
gap: 1.25rem;
@media (min-width: 640px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.landing-form__error {
margin: 0;
color: var(--pi-color-warn, #d64545);
}

View File

@@ -0,0 +1,305 @@
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import {
afterNextRender,
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
signal,
} from '@angular/core';
import {
applyWhen,
email,
form,
FormField,
minLength,
required,
submit,
} from '@angular/forms/signals';
import {
PiActionButtonComponent,
PiCheckboxComponent,
PiEmailFieldComponent,
PiLabelDirective,
PiNoteComponent,
PiNumberFieldComponent,
PiSegmentedControlComponent,
PiTextareaComponent,
PiTextFieldComponent,
PiTextXSmallDirective,
type SegmentedControlOption,
} from '@pi/ui';
import type { Copy } from '../core/copy';
import type { Locale, SubmissionReceipt } from '../core/api';
type FormState = 'idle' | 'sending' | 'sent' | 'duplicate' | 'error';
type Kind = 'contribute' | 'request';
/**
* The public API's write path, reached through this app's own `/api/contact`
* handler rather than directly: the API throttles per e-mail address on the
* assumption that an SSR frontend posts on the visitor's behalf, and going
* through the server keeps the API host out of the browser.
*
* The fields are `@pi/ui`'s, which implement Signal Forms' control contracts —
* `[formField]` wires value, disabled, errors, touched and required, so none of
* those may be bound by hand here (Angular rejects it with NG8022).
*/
@Component({
selector: 'app-contact-form',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
FormField,
PiTextFieldComponent,
PiEmailFieldComponent,
PiTextareaComponent,
PiNumberFieldComponent,
PiCheckboxComponent,
PiSegmentedControlComponent,
PiActionButtonComponent,
PiLabelDirective,
PiNoteComponent,
PiTextXSmallDirective,
],
styleUrl: './contact-form.scss',
template: `
@if (!enabled()) {
<pi-note
status="info"
appearance="fill"
gap="xs"
[title]="copy().form.closed"
[text]="''"
/>
} @else if (state() === 'sent' || state() === 'duplicate') {
<pi-note
status="success"
appearance="fill"
gap="xs"
role="status"
[title]="copy().sections.contact.title"
[text]="receiptMessage()"
/>
} @else {
<form class="landing-form" (ngSubmit)="send()" novalidate>
<fieldset class="landing-form__kind">
<legend>{{ copy().form.kind }}</legend>
<pi-segmented-control
[ariaLabel]="copy().form.kind"
[options]="kinds()"
[(value)]="kind"
/>
</fieldset>
<div class="landing-form__row">
<pi-text-field [formField]="contact.name" [placeholder]="copy().form.name">
<pi-label [isRequired]="true">{{ copy().form.name }}</pi-label>
</pi-text-field>
<pi-email-field
[formField]="contact.email"
[placeholder]="copy().form.email"
>
<pi-label [isRequired]="true">{{ copy().form.email }}</pi-label>
</pi-email-field>
</div>
<div class="landing-form__row">
@if (kind() === 'request') {
<pi-text-field
[formField]="contact.organisation"
[placeholder]="copy().form.organisation"
>
<pi-label [isRequired]="true">{{ copy().form.organisation }}</pi-label>
</pi-text-field>
} @else {
<pi-number-field [formField]="contact.hours_per_week">
<pi-label>{{ copy().form.hours }}</pi-label>
</pi-number-field>
}
</div>
<pi-textarea
[formField]="contact.message"
[placeholder]="copy().form.messageHint"
>
<pi-label [isRequired]="true">{{ copy().form.message }}</pi-label>
</pi-textarea>
<!--
Honeypot. Never rendered visibly and never focusable: a submission
carrying it is filed as spam upstream and still answers 201, so a bot
learns nothing from the response.
-->
<div class="visually-hidden" aria-hidden="true">
<label>
Website
<input
type="text"
tabindex="-1"
autocomplete="off"
[value]="website()"
(input)="website.set($any($event.target).value)"
/>
</label>
</div>
<pi-checkbox [formField]="contact.consent" [label]="copy().form.consent" />
@if (state() === 'error') {
<p xs role="alert" class="landing-form__error">{{ errorMessage() }}</p>
}
<pi-action-button
size="large"
color="primary"
[text]="state() === 'sending' ? copy().form.sending : copy().form.submit"
[disabled]="!hydrated() || state() === 'sending'"
(execute)="send()"
/>
</form>
}
`,
})
export class ContactForm {
readonly copy = input.required<Copy>();
readonly locale = input.required<Locale>();
readonly enabled = input(true);
private readonly http = inject(HttpClient);
protected readonly state = signal<FormState>('idle');
private readonly receipt = signal<SubmissionReceipt | null>(null);
/**
* The page is server-rendered, so for a moment the form is real HTML with no
* listener on it: a click in that window submits natively, navigates away,
* and takes the visitor's message with it. The button waits for hydration.
*/
protected readonly hydrated = signal(false);
/** Not part of the form: a bot filling it must not fail validation. */
protected readonly website = signal('');
/** Drives which second field is shown, and whether `organisation` is required. */
protected readonly kind = signal<Kind>('contribute');
private readonly model = signal({
name: '',
email: '',
organisation: '',
hours_per_week: null as number | null,
message: '',
consent: false,
});
/**
* Every rule is gated on the field having been touched.
*
* `@pi/ui`'s fields derive their error state from `errors().length` alone —
* `touched` is not part of it — so an untouched empty form would render red
* borders and "falta el nombre" before the visitor typed a character. Adding
* the rules only once a field has been left keeps that away, and `submit()`
* marks the whole tree touched before it checks validity, so an empty form
* still cannot be sent.
*/
protected readonly contact = form(this.model, (field) => {
const touched = (ctx: { state: { touched: () => boolean } }) =>
ctx.state.touched();
applyWhen(field.name, touched, (name) => {
required(name, { message: 'Falta el nombre' });
minLength(name, 2, { message: 'Nombre demasiado corto' });
});
applyWhen(field.email, touched, (address) => {
required(address, { message: 'Falta el correo' });
email(address, { message: 'Correo no válido' });
});
applyWhen(field.message, touched, (message) => {
required(message, { message: 'Falta el mensaje' });
// The API rejects anything shorter, and rightly: a one-word submission
// is never anything but noise.
minLength(message, 20, { message: 'Mínimo 20 caracteres' });
});
applyWhen(field.consent, touched, (consent) => {
required(consent, { message: 'Hace falta tu consentimiento' });
});
// Only an organisation asking for help has to name itself.
applyWhen(field.organisation, touched, (organisation) => {
required(organisation, {
when: () => this.kind() === 'request',
message: 'Falta la organización',
});
});
});
protected readonly kinds = computed<SegmentedControlOption<Kind>[]>(() => [
{ value: 'contribute', label: this.copy().form.kindContribute },
{ value: 'request', label: this.copy().form.kindRequest },
]);
protected readonly receiptMessage = computed(
() =>
this.receipt()?.message ??
(this.state() === 'duplicate'
? this.copy().form.duplicate
: this.copy().form.success)
);
protected readonly errorMessage = computed(() => {
const errors = this.receipt()?.errors;
const first = errors ? Object.values(errors)[0]?.[0] : null;
return first ?? this.copy().form.error;
});
constructor() {
afterNextRender(() => this.hydrated.set(true));
}
protected send(): void {
// `submit()` marks the tree touched and only runs the action when valid,
// so there is no separate "have they tried yet" flag to keep.
void submit(this.contact, {
action: async () => {
const value = this.model();
this.state.set('sending');
this.receipt.set(null);
const payload = {
...value,
kind: this.kind(),
organisation: value.organisation || null,
website: this.website() || null,
};
try {
const body = await this.post(payload);
this.receipt.set(body);
this.state.set(body.data?.duplicate ? 'duplicate' : 'sent');
} catch (error) {
this.receipt.set((error as HttpErrorResponse).error as SubmissionReceipt);
this.state.set('error');
}
return undefined;
},
});
}
private post(payload: unknown): Promise<SubmissionReceipt> {
return new Promise((resolve, reject) => {
this.http
.post<SubmissionReceipt>(`/api/contact?lang=${this.locale()}`, payload)
.subscribe({ next: resolve, error: reject });
});
}
}

View File

@@ -0,0 +1,85 @@
:host {
display: block;
}
.landing-hero {
position: relative;
isolation: isolate;
padding-block: 5rem 3rem;
overflow: hidden;
// Two soft lights rather than a hero image: nothing to download, and nothing
// that dates the page when the brand shifts. `isolate` on the section keeps
// the negative z-index inside it instead of behind the page background.
&::before,
&::after {
content: '';
position: absolute;
z-index: -1;
border-radius: 50%;
pointer-events: none;
filter: blur(80px);
}
&::before {
top: -14rem;
left: 50%;
width: 64rem;
height: 38rem;
transform: translateX(-58%);
opacity: 0.5;
background: radial-gradient(
closest-side,
var(--pi-color-primary),
transparent
);
}
&::after {
top: 4rem;
right: -8rem;
width: 26rem;
height: 26rem;
opacity: 0.35;
background: radial-gradient(
closest-side,
var(--pi-color-accent),
transparent
);
}
@media (min-width: 768px) {
padding-block: 7rem 4rem;
}
}
.landing-hero__title {
margin: 0;
max-width: 24ch;
font-size: clamp(2.25rem, 6vw, 3.75rem);
line-height: 1.08;
letter-spacing: -0.02em;
}
.landing-hero__accent {
background: linear-gradient(
100deg,
var(--pi-color-primary, #6d5cf0),
var(--pi-color-accent, #16b39c)
);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.landing-hero__lead {
max-width: 46ch;
margin: 1.5rem 0 0;
}
.landing-hero__actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 2.25rem;
}

View File

@@ -0,0 +1,74 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import {
PiActionButtonComponent,
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
} from '@pi/ui';
import type { Copy } from '../core/copy';
import type { Locale, SitePayload } from '../core/api';
import { StatGrid } from './stat-grid';
@Component({
selector: 'app-hero',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
StatGrid,
PiActionButtonComponent,
PiTextLargeDirective,
PiTextWeightBoldDirective,
PiTextColorDirective,
],
styleUrl: './hero.scss',
template: `
<section id="top" class="landing-hero">
<div class="landing-shell">
<p class="landing-eyebrow">{{ copy().hero.eyebrow }}</p>
<h1 bold class="landing-hero__title">
{{ copy().hero.title }}
<span class="landing-hero__accent">{{ copy().hero.highlight }}</span>
</h1>
<p l pi-color color="grey" class="landing-hero__lead">
{{ copy().hero.lead }}
</p>
<div class="landing-hero__actions">
@if (primaryHref(); as href) {
<pi-action-button
size="large"
color="primary"
[text]="copy().hero.primaryCta"
[hasIcon]="true"
(execute)="navigate.emit(href)"
/>
}
<pi-action-button
size="large"
color="grey"
appearance="outline"
[text]="copy().hero.secondaryCta"
(execute)="navigate.emit('#contacto')"
/>
</div>
<app-stat-grid [site]="site()" [copy]="copy()" [locale]="locale()" />
</div>
</section>
`,
})
export class Hero {
readonly copy = input.required<Copy>();
readonly site = input.required<SitePayload | null>();
readonly locale = input.required<Locale>();
/** Null while there is no published section worth sending anyone to. */
readonly primaryHref = input<string | null>('#proyectos');
/**
* `pi-action-button` renders a `<button>`, so scrolling to an anchor is the
* page's job rather than the browser's.
*/
readonly navigate = output<string>();
}

View File

@@ -0,0 +1,10 @@
.landing-issue {
display: block;
text-decoration: none;
color: inherit;
}
.landing-issue__title {
display: block;
font-weight: 500;
}

View File

@@ -0,0 +1,81 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import {
PiChipComponent,
PiIconComponent,
PiListItemComponent,
PiListItemLeftContentDirective,
PiListItemLeftIconDirective,
PiListItemRightContentDirective,
PiTextColorDirective,
PiTextXSmallDirective,
} from '@pi/ui';
import type { Copy } from '../core/copy';
import type { PublicIssue } from '../core/api.types';
/**
* A newcomer-friendly issue, mirrored hourly from the git provider. Claimed
* issues never arrive here, so every row is genuinely an invitation.
*/
@Component({
selector: 'app-issue-row',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
PiListItemComponent,
PiListItemLeftIconDirective,
PiListItemLeftContentDirective,
PiListItemRightContentDirective,
PiIconComponent,
PiChipComponent,
PiTextXSmallDirective,
PiTextColorDirective,
],
styleUrl: './issue-row.scss',
template: `
<a class="landing-issue" [href]="issue().url" target="_blank" rel="noopener noreferrer">
<pi-list-item
[isClickable]="true"
[isStroked]="true"
color="grey"
borderType="middle"
padding="m"
>
<pi-list-item-left-icon>
<pi-icon icon="list" size="small" color="primary" />
</pi-list-item-left-icon>
<pi-list-item-left-content>
<span class="landing-issue__title">{{ issue().title }}</span>
<span xs pi-color color="grey">{{ meta() }}</span>
</pi-list-item-left-content>
<pi-list-item-right-content>
<pi-chip
size="small"
appearance="outline"
color="accent"
[label]="issue().is_good_first_issue ? 'good first issue' : 'help wanted'"
/>
</pi-list-item-right-content>
</pi-list-item>
</a>
`,
})
export class IssueRow {
readonly issue = input.required<PublicIssue>();
readonly copy = input.required<Copy>();
protected readonly meta = computed(() => {
const issue = this.issue();
return [
`${issue.repository.full_name}#${issue.number}`,
issue.repository.primary_language,
issue.comments > 0
? `${issue.comments} ${this.copy().labels.comments}`
: null,
]
.filter(Boolean)
.join(' · ');
});
}

View File

@@ -0,0 +1,71 @@
.maintenance {
position: relative;
isolation: isolate;
display: flex;
align-items: center;
min-height: 100vh;
overflow: hidden;
// The same light as the hero, so this reads as the same site rather than an
// error page from somewhere else — but centred behind the message instead of
// washing the top of the viewport.
&::before {
content: '';
position: absolute;
z-index: -1;
top: 50%;
left: 50%;
width: 34rem;
height: 34rem;
transform: translate(-50%, -55%);
border-radius: 50%;
opacity: 0.35;
filter: blur(70px);
background: radial-gradient(
closest-side,
var(--pi-color-accent),
transparent
);
pointer-events: none;
}
}
.maintenance__inner {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
// Pinned to the top rather than stacked with the message: the notice should
// sit in the middle of the viewport, not be pushed down by the wordmark.
.maintenance__brand {
position: absolute;
top: 2rem;
left: 50%;
transform: translateX(-50%);
display: inline-flex;
align-items: center;
gap: 0.625rem;
font-weight: 700;
}
// The only branding on this page, so it gets more room than in the header.
.maintenance__mark {
width: 2.5rem;
height: 2.5rem;
object-fit: contain;
}
.maintenance__title {
margin: 1rem 0 0;
}
.maintenance__message {
max-width: 40ch;
margin: 1rem 0 0;
}
.maintenance__until {
margin-top: 1.5rem;
}

View File

@@ -0,0 +1,87 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import {
PiIconComponent,
PiTextColorDirective,
PiTextMediumDirective,
PiTextSmallDirective,
PiTextWeightBoldDirective,
PiTextXLargeDirective,
} from '@pi/ui';
import type { Copy } from '../core/copy';
import type { Locale, MaintenanceNotice } from '../core/api';
/**
* What the whole page becomes while `/public/v1/*` answers 503.
*
* Not a banner over the landing: during maintenance every collection is
* unavailable, so the page underneath would be a hero, an empty everything and
* a contact form that the API would refuse anyway. The notice arrives already
* resolved to the visitor's language, so it is rendered as sent rather than
* replaced with a generic apology.
*/
@Component({
selector: 'app-maintenance-screen',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
PiIconComponent,
PiTextXLargeDirective,
PiTextMediumDirective,
PiTextSmallDirective,
PiTextColorDirective,
PiTextWeightBoldDirective,
],
styleUrl: './maintenance-screen.scss',
template: `
<main class="maintenance">
<div class="landing-shell maintenance__inner">
<span class="maintenance__brand">
<img
class="maintenance__mark"
src="/logo.png"
alt=""
width="256"
height="256"
decoding="async"
/>
Pireon
</span>
<pi-icon icon="time-detail" size="x-large" color="accent" />
<h1 xl bold class="maintenance__title">{{ copy().maintenance.title }}</h1>
<p m pi-color color="grey" class="maintenance__message">
{{ notice().message || copy().maintenance.back }}
</p>
@if (until(); as when) {
<p s pi-color color="grey" class="maintenance__until">
{{ copy().maintenance.until }} {{ when }}
</p>
}
</div>
</main>
`,
})
export class MaintenanceScreen {
readonly notice = input.required<MaintenanceNotice>();
readonly copy = input.required<Copy>();
readonly locale = input.required<Locale>();
/** Only shown when the API committed to a time; it usually has not. */
protected readonly until = computed(() => {
const value = this.notice().until;
if (!value) {
return null;
}
return new Intl.DateTimeFormat(this.locale(), {
day: 'numeric',
month: 'long',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(value));
});
}

View File

@@ -0,0 +1,114 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import {
PiAvatarComponent,
PiCardCenterContentDirective,
PiCardComponent,
PiCardTopContentDirective,
PiChipComponent,
PiTextColorDirective,
PiTextSmallDirective,
PiTextViewerLinkComponent,
PiTextWeightBoldDirective,
} from '@pi/ui';
import type { Copy } from '../core/copy';
import type { PublicMember } from '../core/api.types';
@Component({
selector: 'app-member-card',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
PiCardComponent,
PiCardTopContentDirective,
PiCardCenterContentDirective,
PiAvatarComponent,
PiChipComponent,
PiTextViewerLinkComponent,
PiTextSmallDirective,
PiTextColorDirective,
PiTextWeightBoldDirective,
],
template: `
<pi-card
class="landing-card"
[hasShadow]="true"
[isStroked]="true"
gap="m"
padding="l"
innerPadding="none"
alignment="center"
context="public"
>
<pi-card-top-content>
<!-- The avatar falls back to initials on its own, and again to an
icon, so a missing photo never leaves a hole. -->
<pi-avatar
size="x-large"
shape="circle"
color="primary"
icon="user"
[image]="member().avatar?.url"
[alt]="member().avatar?.alt"
[name]="member().name"
/>
</pi-card-top-content>
<pi-card-center-content>
<h3 bold class="landing-card__title">{{ member().name }}</h3>
@if (member().role || member().headline; as subtitle) {
<p s pi-color color="grey">{{ subtitle }}</p>
}
<div class="landing-card__chips landing-card__chips--center">
@if (member().is_founder) {
<pi-chip size="small" color="primary" [label]="copy().labels.founder" />
}
@if (!member().is_current) {
<pi-chip
size="small"
appearance="outline"
[label]="copy().labels.alumni"
/>
}
</div>
@if (links().length > 0) {
<div class="landing-card__links landing-card__links--center">
@for (link of links(); track link.label) {
<pi-text-viewer-link
icon="link"
iconSize="x-small"
[value]="link.label"
[link]="link.url"
target="_blank"
/>
}
</div>
}
</pi-card-center-content>
</pi-card>
`,
})
export class MemberCard {
readonly member = input.required<PublicMember>();
readonly copy = input.required<Copy>();
protected readonly links = computed(() => {
const member = this.member();
const entries = Object.entries(member.links ?? {}).map(([label, url]) => ({
label,
url,
}));
// The username is handed over separately, so build the URL rather than
// hope somebody also filled the links map in.
if (member.github_username && !entries.some((link) => link.label === 'github')) {
entries.unshift({
label: 'github',
url: `https://github.com/${member.github_username}`,
});
}
return entries.slice(0, 3);
});
}

View File

@@ -0,0 +1,148 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import {
PiCardBottomContentDirective,
PiCardCenterContentDirective,
PiCardComponent,
PiCardTopContentDirective,
PiChipComponent,
PiChipStatusComponent,
PiTextColorDirective,
PiTextSmallDirective,
PiTextViewerLinkComponent,
PiTextWeightBoldDirective,
} from '@pi/ui';
import type { Copy } from '../core/copy';
import type { PublicProject } from '../core/api.types';
import { ApiImage } from './api-image';
@Component({
selector: 'app-project-card',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ApiImage,
PiCardComponent,
PiCardTopContentDirective,
PiCardCenterContentDirective,
PiCardBottomContentDirective,
PiChipComponent,
PiChipStatusComponent,
PiTextViewerLinkComponent,
PiTextSmallDirective,
PiTextColorDirective,
PiTextWeightBoldDirective,
],
template: `
<pi-card
class="landing-card"
[hasShadow]="true"
[isStroked]="true"
gap="m"
padding="m"
innerPadding="none"
alignment="stretch"
context="public"
>
<pi-card-top-content>
@if (project().cover_image; as cover) {
<div class="landing-card__media">
<app-api-image [image]="cover" />
</div>
}
<div class="landing-card__chips">
<pi-chip-status
size="small"
[status]="statusTone()"
[label]="copy().projectStatus[project().status]"
/>
@if (project().needs_help) {
<pi-chip
size="small"
appearance="outline"
color="accent"
[label]="copy().labels.needsHelp"
/>
}
@if (stars() > 0) {
<pi-chip size="small" icon="star-fill" [label]="stars().toString()" />
}
</div>
</pi-card-top-content>
<pi-card-center-content>
<h3 bold class="landing-card__title">{{ project().title }}</h3>
@if (project().tagline; as tagline) {
<p s pi-color color="grey">{{ tagline }}</p>
}
@if (technologies().length > 0) {
<ul class="landing-card__chips">
@for (technology of technologies(); track technology.id) {
<li>
<pi-chip size="small" appearance="outline" [label]="technology.name" />
</li>
}
</ul>
}
</pi-card-center-content>
<pi-card-bottom-content>
<div class="landing-card__links">
@if (project().demo_url; as demo) {
<pi-text-viewer-link
icon="play-arrow"
iconSize="small"
[value]="copy().labels.demo"
[link]="demo"
target="_blank"
/>
}
@if (repositoryUrl(); as repository) {
<pi-text-viewer-link
icon="link"
iconSize="small"
[value]="copy().labels.repository"
[link]="repository"
target="_blank"
/>
}
</div>
</pi-card-bottom-content>
</pi-card>
`,
})
export class ProjectCard {
readonly project = input.required<PublicProject>();
readonly copy = input.required<Copy>();
/** Enough to read the stack at a glance; the rest is noise on a card. */
protected readonly technologies = computed(() =>
(this.project().technologies ?? []).slice(0, 5)
);
protected readonly stars = computed(() =>
(this.project().repositories ?? []).reduce(
(total, repository) => total + (repository.stars ?? 0),
0
)
);
protected readonly repositoryUrl = computed(
() => (this.project().repositories ?? []).find((repo) => repo.url)?.url ?? null
);
/** The API's five project states onto the chip's five tones. */
protected readonly statusTone = computed(() => {
const tones: Record<
PublicProject['status'],
'success' | 'error' | 'pending' | 'info' | 'disabled'
> = {
idea: 'info',
active: 'success',
paused: 'pending',
completed: 'info',
archived: 'disabled',
};
return tones[this.project().status];
});
}

View File

@@ -0,0 +1,73 @@
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import {
Directive,
ElementRef,
inject,
PLATFORM_ID,
afterNextRender,
DestroyRef,
} from '@angular/core';
/** Marks the document as able to animate; see `styles.scss` for why. */
const READY_CLASS = 'reveal-ready';
/**
* Fades a block in the first time it scrolls into view.
*
* The hidden state is not in the server-rendered HTML. It is applied by CSS
* that only matches under `html.reveal-ready`, a class this directive adds at
* runtime — so a visitor without JavaScript, or one whose hydration failed,
* gets the page fully visible rather than a blank column. `prefers-reduced-
* motion` skips the whole thing and reveals immediately.
*/
@Directive({
selector: '[appReveal]',
host: { class: 'reveal' },
})
export class Reveal {
private readonly element = inject(ElementRef<HTMLElement>).nativeElement;
private readonly document = inject(DOCUMENT);
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private readonly destroyRef = inject(DestroyRef);
constructor() {
if (!this.isBrowser) {
return;
}
afterNextRender(() => this.observe());
}
private observe(): void {
const view = this.document.defaultView;
const reduced = view?.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduced || typeof IntersectionObserver === 'undefined') {
this.element.classList.add('is-revealed');
return;
}
this.document.documentElement.classList.add(READY_CLASS);
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) {
continue;
}
entry.target.classList.add('is-revealed');
// Once is enough: this is an entrance, not a scroll effect.
observer.unobserve(entry.target);
}
},
// A little before the block's top edge arrives, so the fade finishes
// about when the reader gets there.
{ rootMargin: '0px 0px -12% 0px', threshold: 0.05 }
);
observer.observe(this.element);
this.destroyRef.onDestroy(() => observer.disconnect());
}
}

View File

@@ -0,0 +1,29 @@
:host {
display: block;
}
.landing-section {
padding-block: 4rem;
scroll-margin-top: 5.5rem;
@media (min-width: 768px) {
padding-block: 6rem;
}
}
.landing-section__head {
max-width: 42rem;
margin-bottom: 2.5rem;
h2 {
margin: 0;
}
p {
margin: 0.75rem 0 0;
}
@media (min-width: 768px) {
margin-bottom: 3.5rem;
}
}

View File

@@ -0,0 +1,50 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { Reveal } from './reveal';
import {
PiTextColorDirective,
PiTextMediumDirective,
PiTextWeightBoldDirective,
PiTextXXLargeDirective,
} from '@pi/ui';
/**
* One band of the landing page: anchor, heading, lead, content.
*
* The heading is plain HTML with the library's text directives rather than
* `pi-headline` — that component is a figure that loads its image from the
* collective's CDN, not a section title.
*/
@Component({
selector: 'app-section',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
Reveal,
PiTextXXLargeDirective,
PiTextWeightBoldDirective,
PiTextMediumDirective,
PiTextColorDirective,
],
styleUrl: './section.scss',
template: `
<section appReveal [id]="anchor()" class="landing-section">
<div class="landing-shell">
<header class="landing-section__head">
@if (eyebrow(); as text) {
<p class="landing-eyebrow">{{ text }}</p>
}
<h2 xxl bold>{{ title() }}</h2>
@if (lead(); as text) {
<p m pi-color color="grey">{{ text }}</p>
}
</header>
<ng-content />
</div>
</section>
`,
})
export class Section {
readonly anchor = input.required<string>();
readonly title = input.required<string>();
readonly lead = input<string | null>(null);
readonly eyebrow = input<string | null>(null);
}

View File

@@ -0,0 +1,62 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import {
PiCardCenterContentDirective,
PiCardComponent,
PiCardTopContentDirective,
PiIconComponent,
PiTextColorDirective,
PiTextSmallDirective,
PiTextWeightBoldDirective,
} from '@pi/ui';
import { resolveIcon } from '../core/icons';
import type { PublicService } from '../core/api.types';
@Component({
selector: 'app-service-card',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
PiCardComponent,
PiCardTopContentDirective,
PiCardCenterContentDirective,
PiIconComponent,
PiTextSmallDirective,
PiTextColorDirective,
PiTextWeightBoldDirective,
],
template: `
<pi-card
class="landing-card"
[hasShadow]="true"
[isStroked]="true"
gap="m"
padding="l"
innerPadding="none"
alignment="stretch"
context="public"
>
<pi-card-top-content>
<pi-icon
[icon]="icon()"
size="large"
[color]="service().is_featured ? 'primary' : 'accent'"
/>
</pi-card-top-content>
<pi-card-center-content>
<h3 bold class="landing-card__title">{{ service().title }}</h3>
@if (service().tagline; as tagline) {
<p s pi-color color="grey">{{ tagline }}</p>
}
</pi-card-center-content>
</pi-card>
`,
})
export class ServiceCard {
readonly service = input.required<PublicService>();
/** The panel lets anyone type anything here, emoji included. */
protected readonly icon = computed(() =>
resolveIcon(this.service().icon, 'support')
);
}

View File

@@ -0,0 +1,20 @@
.landing-footer {
padding-block: 2.5rem 3rem;
}
.landing-footer__row {
display: flex;
flex-direction: column;
gap: 0.5rem;
justify-content: space-between;
margin-top: 1.5rem;
p {
margin: 0;
}
@media (min-width: 768px) {
flex-direction: row;
align-items: center;
}
}

View File

@@ -0,0 +1,39 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import {
PiDividerComponent,
PiTextColorDirective,
PiTextSmallDirective,
} from '@pi/ui';
import type { Copy } from '../core/copy';
@Component({
selector: 'app-site-footer',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiDividerComponent, PiTextSmallDirective, PiTextColorDirective],
styleUrl: './site-footer.scss',
template: `
<footer class="landing-footer">
<div class="landing-shell">
<pi-divider color="grey" width="large" />
<div class="landing-footer__row">
<p s pi-color color="grey">© {{ year }} Pireon. {{ copy().footer.rights }}</p>
<p s pi-color color="grey">
{{ copy().footer.built }}
<a
href="https://landing-api.pireon.dev/docs/api"
target="_blank"
rel="noopener noreferrer"
>API ↗</a
>
</p>
</div>
</div>
</footer>
`,
})
export class SiteFooter {
readonly copy = input.required<Copy>();
protected readonly year = new Date().getFullYear();
}

View File

@@ -0,0 +1,162 @@
:host {
display: block;
}
.landing-header {
position: sticky;
top: 0;
z-index: 50;
background-color: color-mix(in srgb, var(--pi-color-white, #fff) 88%, transparent);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--pi-color-grey-200, #e4e4ea);
}
.landing-header__inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
height: 4rem;
}
.landing-brand {
display: inline-flex;
align-items: center;
gap: 0.625rem;
font-weight: 700;
text-decoration: none;
color: inherit;
}
// The mark carries its own glow, so it needs no background of its own — and
// `alt=""` because the wordmark beside it already names the site.
.landing-brand__mark {
width: 2rem;
height: 2rem;
object-fit: contain;
}
.landing-header__nav {
display: none;
gap: 1.75rem;
a {
text-decoration: none;
color: var(--pi-color-grey-600, #5b5b6b);
font-size: 0.9375rem;
&:hover {
color: inherit;
}
}
@media (min-width: 900px) {
display: flex;
}
}
.landing-header__actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
// The library components carry their own `:host { display: … }`. Component
// styles are scoped by attribute, so specificity ties with theirs — hence the
// extra class rather than a bare one.
.landing-header .landing-header__locales,
.landing-header .landing-header__cta {
display: none;
@media (min-width: 640px) {
display: inline-flex;
}
}
@media (max-width: 899px) {
.landing-header .landing-header__locales {
display: none;
}
}
// Three bars rather than `pi-hamburger`: that component translates its own
// label and is styled for the app shell's drawer, not a marketing bar.
.landing-header__toggle {
display: inline-flex;
flex-direction: column;
justify-content: center;
gap: 4px;
width: 2.25rem;
height: 2.25rem;
padding: 0 0.5rem;
border: 1px solid var(--pi-color-grey-200, #e4e4ea);
border-radius: 0.625rem;
background: none;
cursor: pointer;
span {
display: block;
height: 1.5px;
background-color: currentColor;
}
@media (min-width: 900px) {
display: none;
}
}
.landing-header__mobile {
border-top: 1px solid var(--pi-color-grey-200, #e4e4ea);
padding-block: 0.5rem;
a {
display: block;
padding-block: 0.625rem;
text-decoration: none;
color: var(--pi-color-grey-600, #5b5b6b);
}
@media (min-width: 900px) {
display: none;
}
}
.landing-header__mobile-locales {
display: flex;
gap: 0.5rem;
padding-block: 0.75rem 0.25rem;
border-top: 1px solid var(--pi-color-grey-200, #e4e4ea);
a {
padding: 0.25rem 0.625rem;
border-radius: 0.5rem;
font-size: 0.8125rem;
font-weight: 600;
}
a.is-active {
background-color: var(--pi-color-primary-50, #ede9fe);
color: var(--pi-color-primary, #6d5cf0);
}
}
.landing-header .landing-header__appearance {
display: none;
@media (min-width: 640px) {
display: inline-flex;
}
}
.landing-header__mobile-appearance {
display: block;
width: 100%;
padding: 0.625rem 0;
border: 0;
border-top: 1px solid var(--pi-color-grey-200, #e4e4ea);
background: none;
text-align: left;
font: inherit;
color: var(--pi-color-grey-600, #5b5b6b);
cursor: pointer;
}

View File

@@ -0,0 +1,176 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
output,
signal,
} from '@angular/core';
import { DOCUMENT } from '@angular/common';
import {
PiActionButtonComponent,
PiIconButtonComponent,
PiSegmentedControlComponent,
type SegmentedControlOption,
} from '@pi/ui';
import { AppearanceService } from '../core/appearance';
import type { Copy } from '../core/copy';
import type { Locale, SitePayload } from '../core/api';
export interface NavLink {
anchor: string;
label: string;
}
@Component({
selector: 'app-site-header',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
PiActionButtonComponent,
PiIconButtonComponent,
PiSegmentedControlComponent,
],
styleUrl: './site-header.scss',
template: `
<header class="landing-header">
<div class="landing-shell landing-header__inner">
<a class="landing-brand" href="#top">
<img
class="landing-brand__mark"
src="/logo.png"
alt=""
width="256"
height="256"
decoding="async"
/>
<span>Pireon</span>
</a>
<nav class="landing-header__nav" aria-label="Principal">
@for (link of links(); track link.anchor) {
<a [href]="'#' + link.anchor">{{ link.label }}</a>
}
</nav>
<div class="landing-header__actions">
@if (localeOptions().length > 1) {
<pi-segmented-control
class="landing-header__locales"
ariaLabel="Idioma"
[options]="localeOptions()"
[value]="locale()"
(valueChange)="switchLocale($event)"
/>
}
<pi-icon-button
class="landing-header__appearance"
iconType="custom"
color="grey"
[icon]="appearance.dark() ? 'sun' : 'moon'"
[attr.aria-label]="
appearance.dark() ? copy().nav.lightMode : copy().nav.darkMode
"
[attr.aria-pressed]="appearance.dark()"
(execute)="appearance.toggle()"
/>
<pi-action-button
class="landing-header__cta"
color="primary"
[text]="copy().nav.contact"
(execute)="navigate.emit('#contacto')"
/>
<button
type="button"
class="landing-header__toggle"
aria-label="Menú"
[attr.aria-expanded]="open()"
aria-controls="mobile-nav"
(click)="open.set(!open())"
>
<span></span><span></span><span></span>
</button>
</div>
</div>
@if (open()) {
<nav id="mobile-nav" class="landing-header__mobile">
<div class="landing-shell">
@for (link of links(); track link.anchor) {
<a [href]="'#' + link.anchor" (click)="open.set(false)">{{
link.label
}}</a>
}
<!--
The segmented control is hidden on this width, so the languages
come back here as plain links rather than being unreachable on a
phone.
-->
<button
type="button"
class="landing-header__mobile-appearance"
[attr.aria-pressed]="appearance.dark()"
(click)="appearance.toggle()"
>
{{ appearance.dark() ? copy().nav.lightMode : copy().nav.darkMode }}
</button>
@if (locales().length > 1) {
<div class="landing-header__mobile-locales">
@for (option of locales(); track option.code) {
<a
[href]="'/?lang=' + option.code"
[attr.hreflang]="option.code"
[attr.aria-current]="option.code === locale() ? 'true' : null"
[class.is-active]="option.code === locale()"
>{{ option.code.toUpperCase() }}</a
>
}
</div>
}
</div>
</nav>
}
</header>
`,
})
export class SiteHeader {
readonly copy = input.required<Copy>();
readonly locale = input.required<Locale>();
readonly links = input.required<NavLink[]>();
readonly locales = input.required<SitePayload['locales']>();
readonly navigate = output<string>();
protected readonly appearance = inject(AppearanceService);
private readonly document = inject(DOCUMENT);
protected readonly open = signal(false);
protected readonly localeOptions = computed<SegmentedControlOption<Locale>[]>(
() =>
this.locales().map((option) => ({
value: option.code as Locale,
label: option.code.toUpperCase(),
}))
);
/**
* A full navigation rather than a client-side one: the page's content is
* resolved from `?lang=` on the server, so the server has to render it again.
*/
protected switchLocale(next: Locale | undefined): void {
if (!next || next === this.locale()) {
return;
}
this.document.defaultView?.location.assign(`/?lang=${next}`);
}
}

View File

@@ -0,0 +1,10 @@
.landing-stats {
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: 3.5rem;
@media (min-width: 900px) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}

View File

@@ -0,0 +1,85 @@
import { TestBed } from '@angular/core/testing';
import { describe, expect, it } from 'vitest';
import type { SitePayload } from '../core/api';
import { copyFor } from '../core/copy';
import { provideLandingIcons } from '../core/icons';
import { StatGrid } from './stat-grid';
function site(stats: SitePayload['stats']): SitePayload {
return {
locales: [{ code: 'es', name: 'Español', flag: '', is_default: true }],
default_locale: 'es',
content_version: 1,
submissions_enabled: true,
stats,
};
}
function render(payload: SitePayload | null): HTMLElement {
TestBed.configureTestingModule({
// `pi-stat-card` renders a `mat-icon`, which throws on a name the registry
// does not know — so the real icons go in here too.
providers: [provideLandingIcons()],
});
const fixture = TestBed.createComponent(StatGrid);
fixture.componentRef.setInput('site', payload);
fixture.componentRef.setInput('copy', copyFor('es'));
fixture.componentRef.setInput('locale', 'es');
fixture.detectChanges();
return fixture.nativeElement as HTMLElement;
}
function labels(element: HTMLElement): string[] {
return [...element.querySelectorAll('.pi-stat-card__label')].map((node) =>
node.textContent?.trim() ?? ''
);
}
describe('StatGrid', () => {
it('drops the numbers that are still zero', () => {
const element = render(site({ projects: 4, members: 0, stars: 0 }));
expect(labels(element)).toEqual(['Proyectos']);
});
it('renders nothing at all when the site has no numbers yet', () => {
expect(render(site({ projects: 0 })).querySelector('pi-stat-card')).toBeNull();
});
it('survives an unreachable /site', () => {
expect(render(null).querySelector('pi-stat-card')).toBeNull();
});
it('shows at most four, in the order that reads best', () => {
const element = render(
site({
projects: 9,
active_projects: 3,
members: 12,
open_calls: 2,
stars: 400,
clients: 5,
})
);
expect(labels(element)).toEqual([
'Proyectos',
'En marcha',
'Personas',
'Vacantes abiertas',
]);
});
it('formats the value for the active locale', () => {
const element = render(site({ stars: 1280 }));
const value = element.querySelector('.pi-stat-card__value')?.textContent?.trim();
// Spanish leaves four-digit numbers ungrouped, so this is the formatter
// speaking rather than a raw `toString()`.
expect(value).toBe('1280');
});
});

View File

@@ -0,0 +1,66 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import { PiStatCardComponent } from '@pi/ui';
import type { Copy } from '../core/copy';
import type { Locale, SitePayload } from '../core/api';
/**
* The headline numbers from `/site`, as `pi-stat-card`. Zeroes are dropped
* rather than shown: "0 proyectos" says less than nothing, and a young site
* has plenty of them.
*/
@Component({
selector: 'app-stat-grid',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiStatCardComponent],
styleUrl: './stat-grid.scss',
template: `
@if (stats().length > 0) {
<div class="landing-stats">
@for (stat of stats(); track stat.key) {
<pi-stat-card
[icon]="stat.icon"
[label]="stat.label"
[value]="stat.value"
[color]="stat.color"
/>
}
</div>
}
`,
})
export class StatGrid {
readonly site = input.required<SitePayload | null>();
readonly copy = input.required<Copy>();
readonly locale = input.required<Locale>();
protected readonly stats = computed(() => {
const raw = this.site()?.stats ?? {};
const labels = this.copy().stats;
const format = new Intl.NumberFormat(this.locale());
// Icon names come from the library's own registry list; the SVGs behind
// them live in `public/assets/icons`.
const order = [
{ key: 'projects', icon: 'grid', color: 'primary' },
{ key: 'active_projects', icon: 'focus', color: 'success' },
{ key: 'members', icon: 'user', color: 'accent' },
{ key: 'open_calls', icon: 'briefcase', color: 'highlighted' },
{ key: 'good_first_issues', icon: 'list', color: 'primary' },
{ key: 'stars', icon: 'star-fill', color: 'warn' },
{ key: 'services', icon: 'support', color: 'accent' },
{ key: 'clients', icon: 'buildings', color: 'grey' },
] as const;
return order
.filter((entry) => (raw[entry.key] ?? 0) > 0)
.slice(0, 4)
.map((entry) => ({
key: entry.key,
icon: entry.icon,
color: entry.color,
label: labels[entry.key],
value: format.format(raw[entry.key] ?? 0),
}));
});
}

View File

@@ -0,0 +1,105 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import {
PiCardBottomContentDirective,
PiCardCenterContentDirective,
PiCardComponent,
PiCardTopContentDirective,
PiChipComponent,
PiTextColorDirective,
PiTextSmallDirective,
PiTextViewerLinkComponent,
PiTextWeightBoldDirective,
} from '@pi/ui';
import type { Copy } from '../core/copy';
import type { Locale } from '../core/api';
import type { PublicTalk } from '../core/api.types';
@Component({
selector: 'app-talk-card',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
PiCardComponent,
PiCardTopContentDirective,
PiCardCenterContentDirective,
PiCardBottomContentDirective,
PiChipComponent,
PiTextViewerLinkComponent,
PiTextSmallDirective,
PiTextColorDirective,
PiTextWeightBoldDirective,
],
template: `
<pi-card
class="landing-card"
[hasShadow]="true"
[isStroked]="true"
gap="m"
padding="l"
innerPadding="none"
alignment="stretch"
context="public"
>
<pi-card-top-content>
<div class="landing-card__chips">
<pi-chip size="small" icon="location" [label]="place()" />
@if (heldAt(); as date) {
<pi-chip size="small" appearance="outline" icon="calendar" [label]="date" />
}
</div>
</pi-card-top-content>
<pi-card-center-content>
<h3 bold class="landing-card__title">{{ talk().title }}</h3>
@if (talk().summary; as summary) {
<p s pi-color color="grey">{{ summary }}</p>
}
</pi-card-center-content>
<pi-card-bottom-content>
<div class="landing-card__links">
@if (talk().video_url; as video) {
<pi-text-viewer-link
icon="play-arrow"
iconSize="small"
[value]="copy().labels.watchTalk"
[link]="video"
target="_blank"
/>
}
@if (talk().slides_url; as slides) {
<pi-text-viewer-link
icon="document"
iconSize="small"
[value]="copy().labels.slides"
[link]="slides"
target="_blank"
/>
}
</div>
</pi-card-bottom-content>
</pi-card>
`,
})
export class TalkCard {
readonly talk = input.required<PublicTalk>();
readonly copy = input.required<Copy>();
readonly locale = input.required<Locale>();
protected readonly place = computed(() =>
[this.talk().event, this.talk().city].filter(Boolean).join(' · ')
);
protected readonly heldAt = computed(() => {
const value = this.talk().held_at;
if (!value) {
return null;
}
return new Intl.DateTimeFormat(this.locale(), {
month: 'short',
year: 'numeric',
}).format(new Date(value));
});
}

View File

@@ -0,0 +1,14 @@
.landing-quote {
margin: 0;
font-style: normal;
}
.landing-quote__author {
display: flex;
align-items: center;
gap: 0.75rem;
strong {
display: block;
}
}

View File

@@ -0,0 +1,76 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import {
PiAvatarComponent,
PiCardBottomContentDirective,
PiCardCenterContentDirective,
PiCardComponent,
PiTextColorDirective,
PiTextMediumDirective,
PiTextXSmallDirective,
} from '@pi/ui';
import type { PublicTestimonial } from '../core/api.types';
@Component({
selector: 'app-testimonial-card',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
PiCardComponent,
PiCardCenterContentDirective,
PiCardBottomContentDirective,
PiAvatarComponent,
PiTextMediumDirective,
PiTextXSmallDirective,
PiTextColorDirective,
],
styleUrl: './testimonial-card.scss',
template: `
<pi-card
class="landing-card"
[hasShadow]="true"
[isStroked]="true"
gap="l"
padding="l"
innerPadding="none"
alignment="stretch"
color="grey"
context="public"
>
<pi-card-center-content>
<blockquote m class="landing-quote">{{ testimonial().quote }}</blockquote>
</pi-card-center-content>
<pi-card-bottom-content>
<div class="landing-quote__author">
<pi-avatar
size="medium"
shape="circle"
color="accent"
icon="user"
[image]="testimonial().author.avatar?.url"
[name]="testimonial().author.name"
/>
<span>
<strong>{{ testimonial().author.name }}</strong>
@if (subtitle(); as text) {
<span xs pi-color color="grey">{{ text }}</span>
}
</span>
</div>
</pi-card-bottom-content>
</pi-card>
`,
})
export class TestimonialCard {
readonly testimonial = input.required<PublicTestimonial>();
protected subtitle(): string | null {
const testimonial = this.testimonial();
return (
[testimonial.author.role, testimonial.client?.name]
.filter(Boolean)
.join(' · ') || null
);
}
}

View File

@@ -0,0 +1,20 @@
:host {
display: block;
}
.landing-carousel app-testimonial-card {
display: block;
height: 100%;
// The track has no gap of its own, so the breathing room goes on the item.
padding-inline: 0.5rem;
}
// The server-rendered fallback: the same cards, without the track.
.landing-grid {
display: grid;
gap: 1.5rem;
@media (min-width: 768px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}

View File

@@ -0,0 +1,73 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
PLATFORM_ID,
} from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { PiCarouselComponent, PiCarouselItemDirective } from '@pi/ui';
import { injectDevice } from '../core/device';
import type { PublicTestimonial } from '../core/api.types';
import { TestimonialCard } from './testimonial-card';
/**
* Testimonials read better one at a time than as a wall of three.
*
* The carousel is browser-only, and not by preference: `pi-carousel` wires its
* drag handlers from `ngAfterViewInit` through `renderer.listen` on a
* `document`-backed element, with no platform guard. On the server that throws,
* and an exception during rendering does not merely lose the carousel — it
* abandons the rest of the page, so everything below this section stopped
* reaching the server-rendered HTML.
*
* So the server renders the quotes as a plain grid and the browser replaces it
* with the carousel. The two disagree by design, which is what
* `ngSkipHydration` is for: Angular re-renders this subtree instead of trying
* to match it.
*/
@Component({
selector: 'app-testimonial-carousel',
changeDetection: ChangeDetectionStrategy.OnPush,
host: { ngSkipHydration: 'true' },
imports: [PiCarouselComponent, PiCarouselItemDirective, TestimonialCard],
styleUrl: './testimonial-carousel.scss',
template: `
@if (isBrowser) {
<!--
hideSwipeButtons defaults to true, so without setting it the carousel
offers nothing but the dots and a drag.
-->
<pi-carousel
class="landing-carousel"
[device]="device()"
[itemsDesktop]="2"
[itemsTablet]="1"
[itemsMobile]="1"
[isDraggable]="true"
[movePerPage]="true"
[hideSwipeButtons]="false"
color="primary"
>
@for (testimonial of testimonials(); track testimonial.id) {
<ng-template pi-carousel-item>
<app-testimonial-card [testimonial]="testimonial" />
</ng-template>
}
</pi-carousel>
} @else {
<div class="landing-grid">
@for (testimonial of testimonials(); track testimonial.id) {
<app-testimonial-card [testimonial]="testimonial" />
}
</div>
}
`,
})
export class TestimonialCarousel {
readonly testimonials = input.required<PublicTestimonial[]>();
protected readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
protected readonly device = injectDevice();
}

View File

@@ -0,0 +1,7 @@
import '@angular/platform-server/init';
import { render } from '@analogjs/router/server';
import { AppComponent } from './app/app.component';
import { config } from './app/app.config.server';
export default render(AppComponent, config);

8
landing/src/main.ts Normal file
View File

@@ -0,0 +1,8 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';
bootstrapApplication(AppComponent, appConfig).catch((err) =>
console.error(err)
);

View File

@@ -0,0 +1,32 @@
import { defineEventHandler, getRequestURL, setResponseStatus } from 'h3';
/**
* Answers 404 for addresses this app does not serve.
*
* The status cannot be set from the catch-all page's `load`: Analog resolves
* loaders through an internal request, so anything set on that response never
* reaches the browser. Here it can be, and the route table is small enough to
* state outright — the landing is one page.
*/
const KNOWN_PATHS = new Set(['/', '/index.html']);
const PASSTHROUGH_PREFIXES = ['/api/', '/_analog/', '/assets/', '/@', '/src/', '/node_modules/'];
export default defineEventHandler((event) => {
const { pathname } = getRequestURL(event);
if (KNOWN_PATHS.has(pathname)) {
return;
}
// Static files answer for themselves — a missing one is Nitro's 404, not a
// page, and stamping this status on a hit would break the asset.
if (
PASSTHROUGH_PREFIXES.some((prefix) => pathname.startsWith(prefix)) ||
/\.[a-z0-9]+$/i.test(pathname)
) {
return;
}
setResponseStatus(event, 404);
});

View File

@@ -0,0 +1,99 @@
/**
* The browser's only write path.
*
* The public API throttles submissions per e-mail address rather than per IP
* precisely because an SSR frontend sends everything from one address, so the
* form posts here and this handler forwards it. Keeping the API host out of
* the browser also keeps CORS out of the picture.
*/
import { defineEventHandler, readBody, setResponseStatus, getQuery } from 'h3';
import {
postSubmission,
resolveLocale,
type SubmissionPayload,
} from '../../../app/core/api';
interface ContactBody {
name?: string;
email?: string;
message?: string;
organisation?: string | null;
hours_per_week?: number | string | null;
kind?: 'contribute' | 'request';
consent?: boolean;
links?: Record<string, string>;
/** Honeypot. Never rendered visibly; a bot that fills it is filed as spam. */
website?: string | null;
}
export default defineEventHandler(async (event) => {
const body = (await readBody<ContactBody>(event)) ?? {};
const locale = resolveLocale(getQuery(event)['lang']);
const kind = body.kind === 'request' ? 'request' : 'contribute';
const name = body.name?.trim() ?? '';
const email = body.email?.trim() ?? '';
const message = body.message?.trim() ?? '';
const organisation = body.organisation?.trim() ?? '';
const errors: Record<string, string[]> = {};
if (name.length < 2) {
errors['name'] = ['required'];
}
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
errors['email'] = ['required'];
}
// The API rejects anything shorter, and rightly: a one-word submission is
// never anything but noise.
if (message.length < 20) {
errors['message'] = ['min'];
}
if (kind === 'request' && organisation.length === 0) {
errors['organisation'] = ['required'];
}
if (body.consent !== true) {
errors['consent'] = ['required'];
}
// Caught here rather than spent on a round trip: the API would answer 422
// with the same verdict, and its throttle counts the attempt either way.
if (Object.keys(errors).length > 0) {
setResponseStatus(event, 422);
return { errors, message: 'Validation failed' };
}
const hours = Number(body.hours_per_week);
const payload: SubmissionPayload = {
name,
email,
message,
consent: true,
kind,
organisation: organisation || null,
hours_per_week: Number.isFinite(hours) && hours > 0 ? hours : null,
...(body.links && Object.keys(body.links).length > 0
? { links: body.links }
: {}),
...(body.website ? { website: body.website } : {}),
};
try {
const outcome = await postSubmission('/contact', payload, locale);
setResponseStatus(event, outcome.status);
return outcome.body;
} catch {
setResponseStatus(event, 502);
return { message: 'upstream_unreachable' };
}
});

View File

@@ -0,0 +1,14 @@
import { defineEventHandler } from 'h3';
/**
* Liveness for the container, and nothing more.
*
* It deliberately does not call the Pireon API. A health check that fails when
* the upstream is down would have the orchestrator restart a container that is
* working perfectly — the landing answers maintenance in that case, which is
* the correct behaviour, not a fault.
*/
export default defineEventHandler(() => ({
status: 'ok',
uptime: Math.round(process.uptime()),
}));

477
landing/src/styles.scss Normal file
View File

@@ -0,0 +1,477 @@
// Global stylesheet.
//
// `@pi/ui/styles/common/index.scss` is deliberately NOT used: its tail calls
// `mat.define-palette()` / `mat.define-light-theme()`, removed from Angular
// Material in v19, so it does not compile against the `^22.0.0` the library
// itself asks for. The six parts it aggregates do compile, so they are pulled
// in one by one and the Material theme is declared here with the current API.
@use '@angular/material' as mat;
@use '@pi/ui/styles/common/abstracts' as *;
@use '@pi/ui/styles/common/base' as *;
@use '@pi/ui/styles/common/components' as *;
@use '@pi/ui/styles/common/layouts' as *;
@use '@pi/ui/styles/common/vendors' as *;
@use '@pi/ui/styles/common/wrappers' as *;
@use '@pi/ui/styles/themes' as *;
// Builds one ramp from a single hue against a given surface: the low steps are
// tints of that surface (so a "faint background" stays faint), the high steps
// approach the hue itself (so "strong text" stays strong). The library's own
// ramps run 10 → 900 on the same idea, and its components pick from anywhere
// along them — the segmented control's active pill is `primary-75`, a note's
// background is `accent-50`, a chip border is `primary-200` — so overriding a
// brand colour means overriding all of it.
@mixin brand-ramp($name, $hue, $surface) {
--pi-color-#{$name}-10: #{color-mix(in srgb, $hue 5%, $surface)};
--pi-color-#{$name}-25: #{color-mix(in srgb, $hue 9%, $surface)};
--pi-color-#{$name}-50: #{color-mix(in srgb, $hue 13%, $surface)};
--pi-color-#{$name}-75: #{color-mix(in srgb, $hue 17%, $surface)};
--pi-color-#{$name}-100: #{color-mix(in srgb, $hue 23%, $surface)};
--pi-color-#{$name}-200: #{color-mix(in srgb, $hue 55%, $surface)};
--pi-color-#{$name}-300: #{color-mix(in srgb, $hue 75%, $surface)};
--pi-color-#{$name}-400: #{$hue};
--pi-color-#{$name}: #{$hue};
--pi-color-#{$name}-900: #{color-mix(in srgb, $hue 72%, black)};
// Used for faint washes behind chips and notes.
--pi-color-#{$name}-200-rgba: #{rgba($hue, 0.18)};
}
// The dark ramp differs only in which end `900` runs to: on a dark surface the
// "strong" step has to get lighter, not darker.
@mixin dark-ramp($name, $hue) {
--pi-color-#{$name}-10: #{color-mix(in srgb, $hue 5%, #161922)};
--pi-color-#{$name}-25: #{color-mix(in srgb, $hue 9%, #161922)};
--pi-color-#{$name}-50: #{color-mix(in srgb, $hue 13%, #161922)};
--pi-color-#{$name}-75: #{color-mix(in srgb, $hue 17%, #161922)};
--pi-color-#{$name}-100: #{color-mix(in srgb, $hue 23%, #161922)};
--pi-color-#{$name}-200: #{color-mix(in srgb, $hue 55%, #161922)};
--pi-color-#{$name}-300: #{color-mix(in srgb, $hue 75%, #161922)};
--pi-color-#{$name}-400: #{$hue};
--pi-color-#{$name}: #{$hue};
--pi-color-#{$name}-900: #{color-mix(in srgb, $hue 70%, #ffffff)};
// Used for faint washes behind chips and notes; the light values are opaque
// pastels that would glow on a dark surface.
--pi-color-#{$name}-200-rgba: #{rgba($hue, 0.18)};
}
html {
// Violet/cyan reads as the same family as the collective's own mark, and the
// Pi components take their colour from `--pi-color-*` anyway; this is what
// the Material primitives underneath the form fields need.
@include mat.theme(
(
color: (
primary: mat.$violet-palette,
tertiary: mat.$cyan-palette,
),
typography: Inter,
density: 0,
)
);
}
// ---------------------------------------------------------------------------
// Brand colour.
//
// Sampled from the logo rather than chosen: its gradient runs from #b952fe
// through #317ef4 to #1388dc, so the hues here are its two ends (276° and
// 205°). The values are not the samples themselves — at the logo's lightness
// white text on them lands at 3.7:1, under the 4.5:1 floor — but the darkest
// step of each hue that clears it, with saturation pulled back so a page of
// them is not electric.
//
// Declared on `.pi-shell`, which is the `<body>`: `.pi-shell__alternative`
// writes its own violet-and-teal ramps to that same element, so anything set on
// `<html>` would only be inherited and would lose.
// ---------------------------------------------------------------------------
.pi-shell {
@include brand-ramp('primary', #a347e1, #ffffff);
@include brand-ramp('accent', #1879bf, #ffffff);
@include brand-ramp('highlighted', #6c2fb5, #ffffff);
}
// The `gradient` theme's signature — a gradient focus ring on fields — is a
// token, so it can be lifted onto the `alternative` shell without swapping
// themes. `_mat-form-field.scss` reads it; the flat border is turned off so it
// does not show through the ring.
.pi-shell__alternative {
--pi-theme-field-focus-gradient: linear-gradient(
120deg,
var(--pi-color-primary),
var(--pi-color-accent),
var(--pi-color-highlighted)
);
--pi-theme-field-border-color-focus: transparent;
}
html,
body {
height: 100%;
}
body {
margin: 0;
background-color: var(--pi-color-grey-50, #f7f7fa);
}
// Anchors land below the sticky header rather than under it.
html {
scroll-behavior: smooth;
scroll-padding-top: 5.5rem;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
// ---------------------------------------------------------------------------
// The only styles that stay global.
//
// Everything a single component owns now lives beside it in its own `.scss`.
// What is left here is either genuinely document-wide (the page shell, the
// skip-link helper) or shared by enough components that six copies scoped six
// ways would be worse than one rule — the card primitive is used by every card
// on the page, and `landing-card__media img` has to reach an `<img>` that
// belongs to `app-api-image`, which a scoped parent sheet cannot do.
// ---------------------------------------------------------------------------
.landing-shell {
width: 100%;
max-width: 76rem;
margin-inline: auto;
padding-inline: 1.25rem;
@media (min-width: 768px) {
padding-inline: 2rem;
}
}
.landing-eyebrow {
margin: 0 0 0.75rem;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--pi-color-accent, #16b39c);
}
// ---------------------------------------------------------------------------
// The card, which is where most of the page's surface area lives.
//
// `@pi/ui`'s theme contract stops at `--pi-theme-card-radius` and
// `--pi-theme-card-shadow`: there is no token for a card's fill, so a gradient
// surface and a gradient edge have to be written rather than themed. Both are
// declared here once, on the class every card in `ui/` already carries.
// ---------------------------------------------------------------------------
.landing-card {
position: relative;
height: 100%;
isolation: isolate;
// The library draws a flat border for `isStroked`; the gradient ring below
// replaces it, so it is faded out rather than doubled.
border-color: transparent !important;
background-image: linear-gradient(
155deg,
color-mix(in srgb, var(--pi-color-primary) 7%, var(--pi-color-white)) 0%,
var(--pi-color-white) 45%,
color-mix(in srgb, var(--pi-color-accent) 5%, var(--pi-color-white)) 100%
);
transition:
transform 0.22s ease-out,
box-shadow 0.22s ease-out;
// The edge. `mask-composite` paints the ring only — the same technique the
// library uses for its gradient focus ring, so the two read as one idea.
&::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
border-radius: var(--pi-theme-card-radius, 1.5rem);
padding: 1px;
background: linear-gradient(
150deg,
color-mix(in srgb, var(--pi-color-primary) 55%, transparent) 0%,
color-mix(in srgb, var(--pi-color-accent) 35%, transparent) 55%,
transparent 100%
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
&:hover {
transform: translateY(-3px);
box-shadow: 0 18px 40px -18px color-mix(in srgb, var(--pi-color-primary) 45%, transparent);
}
h3 {
margin: 0;
}
p {
margin: 0.5rem 0 0;
}
}
.landing-card__title {
font-size: 1.125rem;
}
.landing-card__media {
aspect-ratio: 16 / 9;
overflow: hidden;
border-radius: var(--pi-border-radius-medium, 0.75rem);
margin-bottom: 1rem;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.landing-card__chips {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin: 0.75rem 0 0;
padding: 0;
list-style: none;
}
.landing-card__chips--center {
justify-content: center;
}
.landing-card__links {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.landing-card__links--center {
justify-content: center;
}
.landing-card__footer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
width: 100%;
}
// Handed to `app-api-image` as `imgClass`, so it lands on an element inside
// that component rather than in the one that asked for it — which is also why
// the greyscale treatment is here and not on the wrapper: applied to the
// anchor it would tint the card's own surface too.
.landing-logo__mark {
max-height: 2.5rem;
width: auto;
object-fit: contain;
filter: grayscale(1);
transition: filter 0.2s ease;
}
.landing-logo:hover .landing-logo__mark {
filter: none;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
// ---------------------------------------------------------------------------
// Dark mode.
//
// `@pi/ui` has no dark styles at all — `PiThemeService` toggles `html.dark-mode`
// and its theme layer exposes exactly one surface token
// (`--pi-theme-field-surface`), so a dark palette cannot be expressed as a
// theme. It is written here instead, by redefining the library's own colour
// contract under that class.
//
// The tokens go on `html.dark-mode .pi-shell` — the `<body>` — and not on
// `html.dark-mode`, because that is the element the theme itself writes to:
// `.pi-shell__alternative` redeclares the whole primary / accent / highlighted
// ramp there. A custom property set on `<html>` is only inherited, so the
// shell's own declaration on `<body>` wins for everything inside it, and half
// the palette would stay light. Matching the element and adding a class is
// enough to take precedence.
//
// `--pi-color-white` is deliberately NOT inverted: the library uses it as "the
// colour that contrasts with a brand fill" (the text of a filled button, of a
// filled chip), not only as a surface. Surfaces that happen to use it are
// redirected further down instead.
// ---------------------------------------------------------------------------
html.dark-mode {
// Flips every Material token in one line: `mat.theme` emits its values as
// `light-dark(…)`, which reads this and nothing else. It also gives form
// controls and scrollbars their dark rendering, so it belongs on the root.
color-scheme: dark;
}
html.dark-mode .pi-shell {
--landing-bg: #0f1116;
--landing-surface: #161922;
--landing-surface-raised: #1c202b;
// Neutral ramp, inverted. Light-to-dark becomes dark-to-light, so a rule
// written for "a faint background" stays faint and one written for "strong
// text" stays strong.
--pi-color-grey-10: #12141a;
--pi-color-grey-25: #161922;
--pi-color-grey-50: #1a1e28;
--pi-color-grey-75: #1f2430;
--pi-color-grey-100: #262c3a;
--pi-color-grey-200: #333a4b;
--pi-color-grey-300: #4b5265;
--pi-color-grey-400: #8a91a4;
--pi-color-grey: #a9b0c1;
--pi-color-grey-900: #e8eaf1;
// `grey-900` is what `base` gives the body as its text colour, and `black` is
// what the headings and strong text use.
--pi-color-black: #f4f5f9;
--pi-color-black-200: #d8dbe6;
--pi-color-background-400: #0f1116;
--pi-color-background-300: #333a4b;
--pi-color-white-900: #1c202b;
// Brand ramps, regenerated rather than patched token by token.
//
// The library's ramps run 10 → 900 from "a barely-there tint of the hue" to
// "the hue at full strength", and components pick from anywhere along them:
// the segmented control's active pill is `primary-75`, a note's background is
// `accent-50`, a chip border is `primary-200`. Overriding the four that
// happened to be visible left the rest bright on a dark surface, so the whole
// ramp is derived from one hue mixed into the surface.
// The logo's two hues again, lifted until they clear 4.5:1 as text on the
// dark surface — the same brand in both modes, not two.
@include dark-ramp('primary', #ae5de5);
@include dark-ramp('accent', #339be6);
@include dark-ramp('highlighted', #c79bf0);
@include dark-ramp('success', #34c98a);
@include dark-ramp('warn', #f07070);
// The one surface the theme contract does expose.
--pi-theme-field-surface: var(--landing-surface);
--pi-theme-field-border-color: #333a4b;
--pi-theme-field-border-color-hover: #4b5265;
background-color: var(--landing-bg);
// Surfaces that resolve to `--pi-color-white`, redirected one by one because
// that token has to keep meaning "white" everywhere else.
//
// `!important` on purpose. The library paints a white card from
// `.pi-card.pi-color__white.pi-color__mode--light:not([class*='pi-color__opacity']):not([class*='pi-color__density'])`
// — five classes deep — and this file cannot edit it. Restating that chain
// here would be both unreadable and one refactor away from silently losing.
.pi-card.pi-color__white,
.pi-panel.pi-color__white {
background-color: var(--landing-surface) !important;
}
.pi-card.pi-color__grey,
.pi-list-item.pi-color__grey {
background-color: var(--landing-surface-raised) !important;
}
// Material paints the three pieces of the outline notch opaque so the border
// does not run behind the floating label. Opaque means white here, whatever
// `color-scheme` says, so they follow the field surface instead.
.mat-mdc-notch-piece {
background-color: var(--landing-surface) !important;
}
.landing-header {
background-color: color-mix(in srgb, var(--landing-bg) 85%, transparent);
border-bottom-color: var(--pi-color-grey-200);
}
// Client logos are supplied by the API and drawn for a light page. The
// inversion goes on the mark alone — on the anchor it would flip the card's
// surface back to white.
.landing-logo__mark {
filter: grayscale(1) invert(1) brightness(1.7);
}
.landing-logo:hover .landing-logo__mark {
filter: invert(1) brightness(1.7);
}
// Same gradient, mixed into the dark surface: a tint of the brand at the top
// left, the surface in the middle, a tint of the accent at the bottom right.
.landing-card {
background-image: linear-gradient(
155deg,
color-mix(in srgb, var(--pi-color-primary) 12%, var(--landing-surface)) 0%,
var(--landing-surface) 45%,
color-mix(in srgb, var(--pi-color-accent) 8%, var(--landing-surface)) 100%
) !important;
&::before {
background: linear-gradient(
150deg,
color-mix(in srgb, var(--pi-color-primary) 45%, transparent) 0%,
color-mix(in srgb, var(--pi-color-accent) 28%, transparent) 55%,
transparent 100%
);
}
&:hover {
box-shadow: 0 18px 44px -20px color-mix(in srgb, var(--pi-color-primary) 60%, transparent);
}
}
}
// ---------------------------------------------------------------------------
// Scroll entrance.
//
// The hidden state only applies under `html.reveal-ready`, a class the
// `appReveal` directive adds at runtime. Server-rendered HTML carries neither,
// so a visitor without JavaScript — or one whose hydration failed — reads a
// fully visible page instead of a blank column. The media query is a second
// belt: the directive already skips the observer when motion is reduced.
// ---------------------------------------------------------------------------
html.reveal-ready .reveal {
opacity: 0;
transform: translateY(18px);
transition:
opacity 0.55s ease-out,
transform 0.55s ease-out;
}
html.reveal-ready .reveal.is-revealed {
opacity: 1;
transform: none;
}
@media (prefers-reduced-motion: reduce) {
html.reveal-ready .reveal {
opacity: 1;
transform: none;
transition: none;
}
}

View File

@@ -0,0 +1,8 @@
import '@angular/compiler';
import '@analogjs/vitest-angular/setup-snapshots';
import '@analogjs/vitest-angular/setup-serializers';
import { setupTestBed } from '@analogjs/vitest-angular/setup-testbed';
setupTestBed();

1
landing/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

16
landing/tsconfig.app.json Normal file
View File

@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../dist/out-tsc",
"types": [],
"target": "ES2022",
"useDefineForClassFields": false
},
"files": ["src/main.ts", "src/main.server.ts"],
"include": [
"src/**/*.d.ts",
"src/app/pages/**/*.page.ts",
"src/server/middleware/**/*.ts"
],
"exclude": ["**/*.test.ts", "**/*.spec.ts"]
}

Some files were not shown because too many files have changed in this diff Show More