Initial commit
38
landing/eslint.config.mjs
Normal 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
@@ -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
3
landing/package.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
69
landing/project.json
Normal 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
1
landing/public/assets/icons/arrow-forward.svg
Normal 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 |
1
landing/public/assets/icons/briefcase.svg
Normal 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 |
1
landing/public/assets/icons/buildings.svg
Normal 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 |
1
landing/public/assets/icons/calendar.svg
Normal 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 |
1
landing/public/assets/icons/chat.svg
Normal 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 |
1
landing/public/assets/icons/check-rounded.svg
Normal 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 |
1
landing/public/assets/icons/check.svg
Normal 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 |
1
landing/public/assets/icons/chevron-back.svg
Normal 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 |
1
landing/public/assets/icons/chevron-down.svg
Normal 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 |
1
landing/public/assets/icons/chevron-forward.svg
Normal 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 |
1
landing/public/assets/icons/chevron-up.svg
Normal 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 |
1
landing/public/assets/icons/close-rounded.svg
Normal 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 |
1
landing/public/assets/icons/close.svg
Normal 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 |
1
landing/public/assets/icons/coins.svg
Normal 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 |
1
landing/public/assets/icons/document.svg
Normal 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 |
1
landing/public/assets/icons/email.svg
Normal 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 |
1
landing/public/assets/icons/exclamation.svg
Normal 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 |
1
landing/public/assets/icons/focus.svg
Normal 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 |
1
landing/public/assets/icons/globe.svg
Normal 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 |
1
landing/public/assets/icons/graphic.svg
Normal 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 |
1
landing/public/assets/icons/grid.svg
Normal 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 |
1
landing/public/assets/icons/info.svg
Normal 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 |
1
landing/public/assets/icons/light-bulb.svg
Normal 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 |
1
landing/public/assets/icons/link.svg
Normal 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 |
1
landing/public/assets/icons/list.svg
Normal 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 |
1
landing/public/assets/icons/location.svg
Normal 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 |
1
landing/public/assets/icons/moon.svg
Normal 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 |
1
landing/public/assets/icons/play-arrow.svg
Normal 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 |
1
landing/public/assets/icons/search.svg
Normal 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 |
1
landing/public/assets/icons/star-fill.svg
Normal 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 |
1
landing/public/assets/icons/sun.svg
Normal 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 |
1
landing/public/assets/icons/support.svg
Normal 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 |
1
landing/public/assets/icons/time-detail.svg
Normal 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 |
1
landing/public/assets/icons/user.svg
Normal 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 |
1
landing/public/assets/icons/verification-list.svg
Normal 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
BIN
landing/public/logo-96.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
landing/public/logo.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
10
landing/src/app/app.component.ts
Normal 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 {}
|
||||
10
landing/src/app/app.config.server.ts
Normal 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);
|
||||
55
landing/src/app/app.config.ts
Normal 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(),
|
||||
],
|
||||
};
|
||||
30
landing/src/app/core/api.spec.ts
Normal 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
@@ -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 };
|
||||
}
|
||||
401
landing/src/app/core/api.types.ts
Normal 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[];
|
||||
}
|
||||
92
landing/src/app/core/appearance.ts
Normal 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()),
|
||||
]);
|
||||
}
|
||||
48
landing/src/app/core/copy.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
390
landing/src/app/core/copy.ts
Normal 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;
|
||||
}
|
||||
53
landing/src/app/core/device.ts
Normal 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();
|
||||
}
|
||||
121
landing/src/app/core/icons.ts
Normal 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));
|
||||
}
|
||||
}),
|
||||
]);
|
||||
}
|
||||
71
landing/src/app/core/pi-strings.ts
Normal 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);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
405
landing/src/app/pages/(home).page.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
92
landing/src/app/pages/(home).server.ts
Normal 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 ?? [],
|
||||
};
|
||||
};
|
||||
72
landing/src/app/pages/[...not-found].page.ts
Normal 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 {}
|
||||
75
landing/src/app/pages/home.page.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
35
landing/src/app/pages/not-found.page.scss
Normal 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;
|
||||
}
|
||||
50
landing/src/app/ui/api-image.ts
Normal 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(', ');
|
||||
});
|
||||
}
|
||||
131
landing/src/app/ui/call-card.ts
Normal 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));
|
||||
});
|
||||
}
|
||||
9
landing/src/app/ui/client-logo.scss
Normal file
@@ -0,0 +1,9 @@
|
||||
.landing-logo {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.landing-logo__name {
|
||||
font-weight: 500;
|
||||
}
|
||||
46
landing/src/app/ui/client-logo.ts
Normal 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>();
|
||||
}
|
||||
30
landing/src/app/ui/contact-form.scss
Normal 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);
|
||||
}
|
||||
305
landing/src/app/ui/contact-form.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
85
landing/src/app/ui/hero.scss
Normal 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;
|
||||
}
|
||||
74
landing/src/app/ui/hero.ts
Normal 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>();
|
||||
}
|
||||
10
landing/src/app/ui/issue-row.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
.landing-issue {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.landing-issue__title {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
}
|
||||
81
landing/src/app/ui/issue-row.ts
Normal 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(' · ');
|
||||
});
|
||||
}
|
||||
71
landing/src/app/ui/maintenance-screen.scss
Normal 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;
|
||||
}
|
||||
87
landing/src/app/ui/maintenance-screen.ts
Normal 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));
|
||||
});
|
||||
}
|
||||
114
landing/src/app/ui/member-card.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
148
landing/src/app/ui/project-card.ts
Normal 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];
|
||||
});
|
||||
}
|
||||
73
landing/src/app/ui/reveal.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
29
landing/src/app/ui/section.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
50
landing/src/app/ui/section.ts
Normal 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);
|
||||
}
|
||||
62
landing/src/app/ui/service-card.ts
Normal 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')
|
||||
);
|
||||
}
|
||||
20
landing/src/app/ui/site-footer.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
39
landing/src/app/ui/site-footer.ts
Normal 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();
|
||||
}
|
||||
162
landing/src/app/ui/site-header.scss
Normal 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;
|
||||
}
|
||||
176
landing/src/app/ui/site-header.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
10
landing/src/app/ui/stat-grid.scss
Normal 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));
|
||||
}
|
||||
}
|
||||
85
landing/src/app/ui/stat-grid.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
66
landing/src/app/ui/stat-grid.ts
Normal 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),
|
||||
}));
|
||||
});
|
||||
}
|
||||
105
landing/src/app/ui/talk-card.ts
Normal 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));
|
||||
});
|
||||
}
|
||||
14
landing/src/app/ui/testimonial-card.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
76
landing/src/app/ui/testimonial-card.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
20
landing/src/app/ui/testimonial-carousel.scss
Normal 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));
|
||||
}
|
||||
}
|
||||
73
landing/src/app/ui/testimonial-carousel.ts
Normal 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();
|
||||
}
|
||||
7
landing/src/main.server.ts
Normal 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
@@ -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)
|
||||
);
|
||||
32
landing/src/server/middleware/not-found-status.ts
Normal 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);
|
||||
});
|
||||
99
landing/src/server/routes/api/contact.post.ts
Normal 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' };
|
||||
}
|
||||
});
|
||||
14
landing/src/server/routes/api/health.get.ts
Normal 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
@@ -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;
|
||||
}
|
||||
}
|
||||
8
landing/src/test-setup.ts
Normal 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
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
16
landing/tsconfig.app.json
Normal 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"]
|
||||
}
|
||||