Initial commit
20
.claude/settings.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extraKnownMarketplaces": {
|
||||
"nx-claude-plugins": {
|
||||
"source": {
|
||||
"source": "github",
|
||||
"repo": "nrwl/nx-ai-agents-config"
|
||||
}
|
||||
}
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"nx@nx-claude-plugins": true
|
||||
},
|
||||
"sandbox": {
|
||||
"network": {
|
||||
"allowedDomains": [
|
||||
"www.google-analytics.com"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
24
.dockerignore
Normal file
@@ -0,0 +1,24 @@
|
||||
# Everything the image neither builds from nor ships.
|
||||
node_modules
|
||||
dist
|
||||
tmp
|
||||
.nx
|
||||
.angular
|
||||
.git
|
||||
.github
|
||||
.vscode
|
||||
.idea
|
||||
.claude
|
||||
docs
|
||||
landing-e2e
|
||||
**/*.spec.ts
|
||||
test-output
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose*.yml
|
||||
README.md
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
13
.editorconfig
Normal file
@@ -0,0 +1,13 @@
|
||||
# Editor configuration, see http://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
10
.env.example
Normal file
@@ -0,0 +1,10 @@
|
||||
# Base URL of the Pireon API, without a trailing slash. Read on the server
|
||||
# only: the browser never talks to the API directly.
|
||||
PIREON_API_URL=https://landing-api.pireon.dev/api
|
||||
|
||||
# Host port the container is published on.
|
||||
LANDING_PORT=3000
|
||||
|
||||
# Only needed to *build* the image: `@pi/ui` comes from the private Nexus.
|
||||
# Not read at runtime, and never baked into a layer — see Dockerfile.
|
||||
NEXUS_TOKEN=
|
||||
48
.gitignore
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
# See https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
|
||||
# compiled output
|
||||
dist
|
||||
tmp
|
||||
out-tsc
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# misc
|
||||
/.sass-cache
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
.nx/cache
|
||||
.nx/workspace-data
|
||||
.nx/migrate-runs
|
||||
.claude/worktrees
|
||||
.claude/settings.local.json
|
||||
|
||||
.nx/polygraph
|
||||
.angular
|
||||
16
.npmrc
Normal file
@@ -0,0 +1,16 @@
|
||||
# Default registry stays npmjs on purpose.
|
||||
#
|
||||
# nexus.pireon.dev/repository/npm-releases is a *hosted* release repo, not a
|
||||
# proxy: it answers 404 for anything published on npmjs (verified with tslib).
|
||||
# Pointing `registry` at it would break every install in this workspace.
|
||||
registry=https://registry.npmjs.org/
|
||||
|
||||
# Pireon's own packages (@pi/*) come from Nexus.
|
||||
@pi:registry=https://nexus.pireon.dev/repository/npm-releases/
|
||||
//nexus.pireon.dev/repository/npm-releases/:always-auth=true
|
||||
|
||||
# The token is deliberately not here. Locally it already lives in ~/.npmrc; in
|
||||
# CI, write it before installing:
|
||||
# npm config set //nexus.pireon.dev/repository/npm-releases/:_authToken "$NEXUS_TOKEN"
|
||||
# Putting `${NEXUS_TOKEN}` in this file instead would make npm fail outright on
|
||||
# any machine where the variable is unset — including this one.
|
||||
6
.prettierignore
Normal file
@@ -0,0 +1,6 @@
|
||||
# Add files here to ignore them from prettier formatting
|
||||
/dist
|
||||
/coverage
|
||||
/.nx/cache
|
||||
/.nx/workspace-data
|
||||
.angular
|
||||
3
.prettierrc
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"singleQuote": true
|
||||
}
|
||||
8
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"nrwl.angular-console",
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"ms-playwright.playwright"
|
||||
]
|
||||
}
|
||||
11
AGENTS.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# AGENTS.md
|
||||
|
||||
This is an [AnalogJS](https://analogjs.org) app — an Angular meta-framework powered by Vite.
|
||||
|
||||
Framework conventions for AI assistants — file-based routing, server/API routes, data fetching, content routes, and modern Angular usage — ship with the framework and are installed alongside your dependencies. Read them here:
|
||||
|
||||
- `node_modules/@analogjs/platform/AGENTS.md`
|
||||
|
||||
Reading from `@analogjs/platform` keeps the guidance in sync with the installed version instead of drifting from a copy checked into the project.
|
||||
|
||||
Full documentation: https://analogjs.org/docs · LLM-friendly index: https://analogjs.org/llms.txt
|
||||
62
Dockerfile
Normal file
@@ -0,0 +1,62 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build
|
||||
#
|
||||
# Nx and the Angular compiler need the whole workspace, so this stage is heavy
|
||||
# and is thrown away: only `dist/landing/analog` crosses into the runtime image.
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NX_DAEMON=false \
|
||||
CI=true \
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
||||
|
||||
# Manifests first, so a source-only change does not reinstall the world.
|
||||
COPY package.json package-lock.json .npmrc ./
|
||||
|
||||
# `@pi/ui` comes from the private Nexus, so the install needs a token — passed
|
||||
# as a BuildKit secret rather than a build ARG, which would be readable forever
|
||||
# in the image history. The npmrc it lands in is written and removed inside one
|
||||
# layer, so it leaves no trace even in this discarded stage.
|
||||
RUN --mount=type=secret,id=nexus_token,required=true \
|
||||
sh -eu -c '\
|
||||
printf "//nexus.pireon.dev/repository/npm-releases/:_authToken=%s\n" \
|
||||
"$(cat /run/secrets/nexus_token)" > /tmp/npmrc; \
|
||||
NPM_CONFIG_USERCONFIG=/tmp/npmrc npm ci --no-audit --no-fund; \
|
||||
rm -f /tmp/npmrc'
|
||||
|
||||
COPY nx.json tsconfig.base.json eslint.config.mjs ./
|
||||
COPY landing ./landing
|
||||
|
||||
RUN npx nx build landing
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runtime
|
||||
#
|
||||
# Nitro vendors its own dependencies into the build output, so there is nothing
|
||||
# to install here: the whole server is the 17 MB it copies.
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:22-alpine AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3000 \
|
||||
HOST=0.0.0.0
|
||||
|
||||
# `node` already exists in the base image, unprivileged and with no shell login.
|
||||
COPY --from=build --chown=node:node /app/dist/landing/analog ./analog
|
||||
|
||||
USER node
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Node rather than curl or wget: neither is in the base image, and adding one
|
||||
# for a health check is a package more than the job needs.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
|
||||
CMD ["node", "analog/server/index.mjs"]
|
||||
413
README.md
Normal file
@@ -0,0 +1,413 @@
|
||||
# landing-web
|
||||
|
||||
Landing page for the Pireon collective, built with [Nx](https://nx.dev) and
|
||||
[AnalogJS](https://analogjs.org) (Angular 22 on Vite), rendered on the server,
|
||||
fed by the public half of the
|
||||
[Pireon API](https://landing-api.pireon.dev/docs/api) and built out of
|
||||
`@pi/ui`, the collective's own component library.
|
||||
|
||||
```sh
|
||||
npm start # dev server on http://localhost:4200
|
||||
npm run build # production build into dist/landing
|
||||
npm run preview # run the built server (PORT=3000 by default)
|
||||
npm test # unit tests (Vitest)
|
||||
npm run lint # ESLint
|
||||
npm run e2e # Playwright end-to-end tests
|
||||
```
|
||||
|
||||
## Deploying
|
||||
|
||||
```sh
|
||||
NEXUS_TOKEN=… docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Two stages. The build one runs Nx and the Angular compiler over the whole
|
||||
workspace and is thrown away; only `dist/landing/analog` crosses into the
|
||||
runtime image, because Nitro vendors its own dependencies into that output —
|
||||
17 MB that runs with no install step, on `node:22-alpine`, as the unprivileged
|
||||
`node` user. The image comes out at 182 MB, nearly all of it the base.
|
||||
|
||||
`@pi/ui` comes from the private Nexus, so the install needs a token. It is
|
||||
passed as a **BuildKit secret**, not a build ARG: an ARG stays readable in the
|
||||
image history forever. `docker history` on the result shows no trace of it.
|
||||
|
||||
`/api/health` answers the container health check. It deliberately does not call
|
||||
the Pireon API — a check that failed when the upstream was down would have the
|
||||
orchestrator restart a container that is working correctly, since the landing
|
||||
answers maintenance in that case by design.
|
||||
|
||||
**The API is external** — a separate deployment on a sibling subdomain, not part
|
||||
of this stack.
|
||||
|
||||
**The landing must keep `/api` on its own origin.** Analog serves the app's
|
||||
server routes there, and — more importantly — resolves *every page's data*
|
||||
through `/api/_analog/pages/*`. Handing that path to the Pireon API on a shared
|
||||
origin would not cost the contact form, it would blank the site. Moving the
|
||||
app's own prefix is not the way out either: `apiPrefix` exists in the Analog
|
||||
config but `get-page-handlers.js` hardcodes `/api` for the loader routes
|
||||
regardless of it, so setting it splits the two apart and the loaders stop being
|
||||
registered at all. Verified by trying it. Different subdomains, as deployed
|
||||
today, have no conflict. The consequence for deployment is egress: the landing is
|
||||
server-rendered, so every request it serves makes ten calls to
|
||||
`landing-api.pireon.dev` *from inside the container*. Behind a restrictive
|
||||
egress policy that host has to be allowed, or every page renders as though the
|
||||
API were in maintenance. `core/api.ts` gives each call an 8-second budget, which
|
||||
is what stands between a slow upstream and a hung render.
|
||||
|
||||
The container runs with a read-only filesystem and `no-new-privileges`, with a
|
||||
tmpfs at `/tmp` for Nitro's scratch space. Put a reverse proxy in front for TLS
|
||||
and let it pass response headers through untouched: the app answers 503 with
|
||||
`Retry-After` during maintenance and 404 for addresses it does not serve, and
|
||||
both matter to crawlers and CDNs.
|
||||
|
||||
| Variable | Where | Default |
|
||||
| --- | --- | --- |
|
||||
| `PIREON_API_URL` | runtime | `https://landing-api.pireon.dev/api` |
|
||||
| `LANDING_PORT` | runtime, host side | `3000` |
|
||||
| `NEXUS_TOKEN` | **build only** | — |
|
||||
|
||||
Copy `.env.example` to `.env`; compose reads it.
|
||||
|
||||
## Seeing it with content
|
||||
|
||||
The live API answers `{"data": []}` for every collection until the panel has
|
||||
something published, so `npm start` shows a hero and a contact form and nothing
|
||||
else. That is correct, and impossible to review. Two terminals:
|
||||
|
||||
```sh
|
||||
npm run mock # stand-in API on :4599, with content in every section
|
||||
npm run start:mock # the app pointed at it, on :4200
|
||||
```
|
||||
|
||||
`npm run mock:down` is the other half — every endpoint answering 503 — which is
|
||||
the only way to see the maintenance banner:
|
||||
|
||||
```sh
|
||||
npm run mock:down
|
||||
PIREON_API_URL=http://localhost:4600/api npm run preview
|
||||
```
|
||||
|
||||
`docs/screenshots/` holds a capture of every screen in both modes, taken against
|
||||
those two servers.
|
||||
|
||||
## What it talks to
|
||||
|
||||
Only `/public/v1/*` — unauthenticated, read-only, and already published on the
|
||||
public website. The landing assembles itself from ten endpoints in one parallel
|
||||
pass on the server:
|
||||
|
||||
| Section | Endpoint |
|
||||
| --- | --- |
|
||||
| Hero numbers, locales, form switch | `/site` |
|
||||
| Qué hacemos | `/services` |
|
||||
| Proyectos | `/projects?per_page=6&translated=1` |
|
||||
| Con quién trabajamos | `/clients` |
|
||||
| Lo que dicen | `/testimonials` |
|
||||
| Radar técnico | `/radar` |
|
||||
| Quiénes somos | `/members` |
|
||||
| Buscamos gente | `/calls` |
|
||||
| Primeras contribuciones | `/good-first-issues?limit=6` |
|
||||
| Charlas | `/talks?limit=6` |
|
||||
| Contact form | `POST /contact` |
|
||||
|
||||
`landing/openapi.yaml` is the specification the app was written against, and
|
||||
`landing/src/app/core/api.types.ts` is the API's own generated type file. Both
|
||||
are copies — refresh them from the API when the contract moves:
|
||||
|
||||
```sh
|
||||
curl -o landing/openapi.yaml https://landing-api.pireon.dev/openapi.yaml
|
||||
curl -o landing/src/app/core/api.types.ts https://landing-api.pireon.dev/api-types.d.ts
|
||||
```
|
||||
|
||||
## How it is put together
|
||||
|
||||
```
|
||||
landing/src/
|
||||
app/
|
||||
core/api.ts server-side API client (never imported by the browser)
|
||||
core/api.types.ts generated payload types from the API
|
||||
core/copy.ts the chrome's own words, in es / en / ca
|
||||
core/icons.ts the icon set, inlined and registered as literals
|
||||
core/pi-strings.ts the strings `@pi/ui` translates on its own
|
||||
pages/(home).server.ts one load function, ten parallel calls
|
||||
pages/(home).page.ts the landing itself
|
||||
ui/ presentational components, one per payload type
|
||||
(thin wrappers over `@pi/ui`), each with its `.scss`
|
||||
server/
|
||||
routes/api/contact.post.ts forwards the contact form to the API
|
||||
middleware/not-found-status.ts
|
||||
styles.scss Material theme, `@pi/ui` sheets, shared primitives
|
||||
```
|
||||
|
||||
**Nothing renders from the browser.** All reads happen in the page's `load`
|
||||
function, and the one write goes through this app's own `/api/contact` handler.
|
||||
That is what the API expects: it throttles submissions per e-mail address
|
||||
rather than per IP *because* an SSR frontend posts on the visitor's behalf, and
|
||||
keeping the API host out of the browser means CORS never comes up.
|
||||
|
||||
**No call can reject.** `core/api.ts` turns every failure into
|
||||
`{ data, maintenance, error }`, so a section whose endpoint blinked renders
|
||||
empty instead of taking the page down.
|
||||
|
||||
**Maintenance takes over the whole page.** When `/public/v1/*` answers 503 the
|
||||
landing is replaced by `ui/maintenance-screen.ts`, not covered with a banner:
|
||||
every collection is unavailable at that point, so what was left underneath was a
|
||||
hero, eight empty sections and a contact form the API would refuse. The notice
|
||||
arrives already resolved to the visitor's language and is rendered as sent.
|
||||
|
||||
The response says so too — 503, the API's own `Retry-After`, and
|
||||
`Cache-Control: no-store` to override the `s-maxage` route rule, because a
|
||||
cached maintenance page outlives the maintenance. That status is set from the
|
||||
page component through `injectResponse()` (`@analogjs/router/tokens`), which is
|
||||
the *render* request's response; the one a page `load` sees belongs to the
|
||||
internal request Analog resolves loaders through and never reaches the browser.
|
||||
That is also why the 404 status comes from middleware instead.
|
||||
|
||||
**Empty sections disappear**, along with their entry in the nav — a young site
|
||||
shows a hero and a contact form rather than eight headings over nothing.
|
||||
|
||||
## Languages
|
||||
|
||||
Content is translatable and the API resolves it per request. The locale comes
|
||||
from `?lang=es|en|ca` and nothing else: Analog resolves `load` functions through
|
||||
an internal request that forwards the query string but not the headers, and a
|
||||
page cached with `s-maxage` that varied on `Accept-Language` would hand the
|
||||
second visitor the wrong language anyway. The switcher in the header is three
|
||||
plain links, and `<html lang>` follows.
|
||||
|
||||
## Rendering and caching
|
||||
|
||||
Nothing is prerendered: the page is assembled from content the panel publishes
|
||||
and answers on `?lang=`, so a build artefact would be stale and wrong for two of
|
||||
three locales. Freshness is bought back with
|
||||
`Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=600`, so
|
||||
a CDN absorbs the traffic and the origin re-renders once a minute.
|
||||
|
||||
## Package registry (Nexus)
|
||||
|
||||
`.npmrc` keeps npmjs as the default registry and routes only the `@pi` scope to
|
||||
Nexus, which is where `@pi/ui` comes from:
|
||||
|
||||
```
|
||||
registry=https://registry.npmjs.org/
|
||||
@pi:registry=https://nexus.pireon.dev/repository/npm-releases/
|
||||
```
|
||||
|
||||
This is not a preference — `npm-releases` is a **hosted** repo, not a proxy. It
|
||||
answers 404 for anything published on npmjs, so making it the default registry
|
||||
breaks every install in the workspace. There is no `group`/`proxy` repo on that
|
||||
Nexus today; if you want a single registry for everything, someone has to
|
||||
create an npmjs *proxy* repo and a *group* that bundles it with `npm-releases`,
|
||||
and then `registry=` can point at the group.
|
||||
|
||||
**Credentials** are not in this file. Locally they live in `~/.npmrc`. In CI,
|
||||
write the token before installing:
|
||||
|
||||
```sh
|
||||
npm config set //nexus.pireon.dev/repository/npm-releases/:_authToken "$NEXUS_TOKEN"
|
||||
```
|
||||
|
||||
An `${NEXUS_TOKEN}` placeholder inside `.npmrc` would make npm fail outright on
|
||||
any machine where the variable is unset, so it is deliberately absent.
|
||||
|
||||
**Publishing a library from this workspace** to Nexus — the app itself is
|
||||
`private` and is not publishable:
|
||||
|
||||
```jsonc
|
||||
// libs/<name>/package.json
|
||||
{
|
||||
"name": "@pi/<name>",
|
||||
"publishConfig": {
|
||||
"registry": "https://nexus.pireon.dev/repository/npm-releases/"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Styles
|
||||
|
||||
SCSS throughout, and no Tailwind — the library brings its own tokens, so a
|
||||
second system on top would have been two vocabularies for one page.
|
||||
|
||||
Each component owns its stylesheet next to it (`hero.ts` + `hero.scss`), scoped
|
||||
by Angular's emulated encapsulation. `styles.scss` holds only what cannot be
|
||||
scoped: the Material theme, the `@pi/ui` sheets, the document base, and the few
|
||||
primitives shared across components — `.landing-shell`, `.landing-eyebrow`, and
|
||||
the `.landing-card*` family every card on the page uses.
|
||||
|
||||
Two consequences of that encapsulation are worth remembering, because both bit
|
||||
during the split:
|
||||
|
||||
- A parent's sheet cannot reach inside a child component. `.landing-card__media
|
||||
img` targets an `<img>` that belongs to `app-api-image`, so it stays global;
|
||||
`.landing-logo__mark` is handed to that component as `imgClass` for the same
|
||||
reason.
|
||||
- It cannot restyle a library component's internals either. The 404 page leaves
|
||||
`pi-empty-state` at the size the library gives it rather than reaching in with
|
||||
`::ng-deep` to win a `font-size` argument.
|
||||
|
||||
Component styles are injected after the global sheet, so a library component's
|
||||
own `:host { display: … }` ties on specificity and wins on order. Where the page
|
||||
needs to override one — hiding the locale switcher on mobile — the selector is
|
||||
scoped through an ancestor to break the tie.
|
||||
|
||||
`nx.json` generates `scss` for new applications, components and pages.
|
||||
|
||||
## The component library
|
||||
|
||||
Everything visible is `@pi/ui` — `pi-card`, `pi-chip`, `pi-stat-card`,
|
||||
`pi-avatar`, `pi-list-item`, the form fields — under the library's
|
||||
`alternative` shell (its marketing look: gradients, hover glow, larger
|
||||
controls). The components in `ui/` are thin adapters: they turn one API
|
||||
payload into one library component and hold nothing else. Page rhythm — the
|
||||
band spacing, the header bar, the hero — is the only CSS this app writes, in
|
||||
`styles.scss`.
|
||||
|
||||
### The mark, and the colour that comes from it
|
||||
|
||||
`public/logo.png` is the brand mark, used in the header and on the maintenance
|
||||
screen, and as the favicon at two sizes. It came in as a 1024×1024 PNG of
|
||||
1.4 MB with most of the frame empty; it is cropped to the artwork and resized,
|
||||
which is what takes it to 42 KB — the header draws it at 32 px, so shipping the
|
||||
original would have been thirty times the bytes for the same pixels. The glow is
|
||||
baked into the file, so it needs no background of its own and reads on both the
|
||||
light and the dark surface.
|
||||
|
||||
**The brand palette is sampled from it**, not chosen. The mark's gradient runs
|
||||
from `#b952fe` through `#317ef4` to `#1388dc`, so `styles.scss` takes its two
|
||||
ends — hue 276° and 205° — as `--pi-color-primary` and `--pi-color-accent`,
|
||||
replacing the violet and teal that `pi-shell__alternative` ships. The values are
|
||||
not the samples themselves: at the logo's own lightness, white text on them
|
||||
lands at 3.7:1, under the 4.5:1 floor. Each is the darkest step of its hue that
|
||||
clears it, with saturation pulled back so a page of them is not electric.
|
||||
Everything downstream — buttons, chips, the hero aura, the card edges, the focus
|
||||
ring — reads those tokens, so the page and the mark are one brand.
|
||||
|
||||
`favicon.ico` is still the scaffold's. Modern browsers take the PNG links ahead
|
||||
of it; it only answers for clients that ask for `/favicon.ico` by convention.
|
||||
|
||||
**The rest of the logo folder.** `/Users/pigui/Desktop/logos/white/` is a
|
||||
byte-for-byte duplicate of the folder above it, not a light-on-dark set. Of the
|
||||
eight files, the three wordmarks (`10_38_19`, `10_41_03`, `10_47_10`) have **no
|
||||
alpha channel** — the transparency checkerboard is painted into the pixels — so
|
||||
they cannot be used as-is. The four that are genuinely transparent are
|
||||
`10_39_58`, `10_42_40` (the one in use), `10_49_03` and `10_49_22`. That last
|
||||
one is the newest file and the best asset in the set: the full "pireon" wordmark
|
||||
in white, transparent, with the gradient `e`. It is unused here only because it
|
||||
is white-on-dark and the landing's default is light; putting it in the header
|
||||
needs either a dark-text export of the same lockup, or a decision to make that
|
||||
bar dark in both modes.
|
||||
|
||||
### Motion
|
||||
|
||||
Two pieces, both of which have to survive a page that renders on the server:
|
||||
|
||||
- **`ui/testimonial-carousel.ts`** wraps the quotes in `pi-carousel`, which
|
||||
projects `<ng-template pi-carousel-item>` children and instantiates them
|
||||
itself. It needs a `device` to decide how many fit — the library ships
|
||||
`DeviceConfigsFacade` for that but nothing in it watches the window, so
|
||||
`core/device.ts` reads the viewport instead and answers `DESKTOP` on the
|
||||
server. Two of its defaults are worth knowing: `hideSwipeButtons` is `true`,
|
||||
so the arrows have to be asked for, and the controls only appear at all once
|
||||
there are more items than fit — with two testimonials it degrades to a plain
|
||||
row, which is the right answer.
|
||||
- **`ui/reveal.ts`** fades each section in as it arrives. The hidden state is
|
||||
*not* in the server-rendered HTML: it only applies under `html.reveal-ready`,
|
||||
a class the directive adds at runtime, so a visitor without JavaScript reads a
|
||||
full page instead of a blank column. `prefers-reduced-motion` skips the
|
||||
observer entirely.
|
||||
|
||||
`PiThemeService` owns the shell class, pinned to `alternative` in
|
||||
`app.config.ts` — the library's marketing look: pill buttons with a gradient
|
||||
fill, a tinted field surface, rounded cards with elevation. `styles.scss` layers
|
||||
on what the theme contract cannot express: the contract stops at
|
||||
`--pi-theme-card-radius` and `--pi-theme-card-shadow`, with no token for a
|
||||
card's fill, so the gradient surface, the gradient edge (`mask-composite`, the
|
||||
same technique the library uses for its focus ring) and the hover lift are
|
||||
written by hand. The `gradient` theme's focus ring *is* a token, so it is lifted
|
||||
onto this shell without swapping themes.
|
||||
|
||||
The dark palette — the service writes `body.className` from its own effect and
|
||||
starts on `default`, so without that the page would render server-side in one
|
||||
shell and visibly swap to the other on hydration.
|
||||
|
||||
### Dark mode
|
||||
|
||||
The library has none: `PiThemeService` toggles `html.dark-mode` and persists it,
|
||||
but `grep -r dark-mode node_modules/@pi/ui/styles` comes back empty, 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 in `styles.scss`
|
||||
instead, by redefining the library's own colour contract. Three things about it
|
||||
are worth knowing before touching it:
|
||||
|
||||
- **The tokens go on `html.dark-mode .pi-shell`, not on `html.dark-mode`.** The
|
||||
shell class writes its own primary / accent / highlighted ramps to `<body>`,
|
||||
and a custom property set on `<html>` is only inherited — so the shell won
|
||||
every tie and half the palette stayed light. Matching the element and adding a
|
||||
class is enough.
|
||||
- **`color-scheme: dark` stays on the root.** `mat.theme` emits its values as
|
||||
`light-dark(…)`, so that one line flips every Material token, plus the native
|
||||
form controls and scrollbars.
|
||||
- **`--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 —
|
||||
not only as a surface. The surfaces that do resolve to it are redirected one
|
||||
by one, with `!important`, because the library paints a card from a five-class
|
||||
selector this file cannot outrank.
|
||||
|
||||
`AppearanceService` owns the switch rather than `PiThemeService`, whose
|
||||
`readStoredDarkMode()` is `!!localStorage.getItem(key)` while `persist()` writes
|
||||
the boolean through: storing `false` reads back as the string `"false"`, which is
|
||||
truthy, so dark mode could be turned on but never off. It also honours
|
||||
`prefers-color-scheme` on a first visit, and an inline script in `index.html`
|
||||
applies the class before first paint so a dark visitor never sees a white flash.
|
||||
|
||||
Five things had to be worked around, all documented at their call site:
|
||||
|
||||
- **The library's own stylesheet does not compile.**
|
||||
`@pi/ui/styles/common/index.scss` ends in `mat.define-palette()` /
|
||||
`mat.define-light-theme()`, removed from Angular Material in v19, while the
|
||||
library asks for `^22.0.0`. The six parts it aggregates do compile, so
|
||||
`styles.scss` pulls them in one by one and declares the Material theme with
|
||||
the current API.
|
||||
- **No SVGs ship with it.** `pi-icon` renders a `mat-icon` whose name is looked
|
||||
up in Material's registry, and the library's `IconRegistryService` registers
|
||||
every name as a URL under `/assets/icons/`. That is one request per icon, and
|
||||
during SSR those have no origin to resolve against — every one failed and the
|
||||
server HTML came out iconless. `core/icons.ts` registers the same names as
|
||||
literals instead: no network, and the icon is in the first byte the browser
|
||||
receives. A name from the API is never passed through unchecked; services
|
||||
carry a free-form `icon` that may well be an emoji.
|
||||
- **Form fields show errors before they are touched.**
|
||||
`PiBaseFormFieldComponent.hasError` is `errors().length > 0`, with no
|
||||
`touched` in it, so an untouched empty form renders red borders and "falta el
|
||||
nombre" on first paint. The contact form gates every rule behind
|
||||
`applyWhen(field, touched, …)`; `submit()` marks the tree touched before it
|
||||
checks validity, so an empty form still cannot be sent.
|
||||
- **`pi-chip-value` glues the number to its unit** ("4h/semana" as one word), so
|
||||
those chips build their own label.
|
||||
- **`pi-headline` is a figure, not a heading** — it loads its image from the
|
||||
collective's CDN — and `pi-not-found` and `pi-hamburger` translate their own
|
||||
strings through an HTTP loader. Section titles and the mobile menu are plain
|
||||
HTML with the library's text directives.
|
||||
|
||||
Two `input.required` surprises worth knowing: `pi-card` requires `alignment`,
|
||||
`innerPadding` and `context`, and `pi-list-item` requires `color` and
|
||||
`borderType`. The library's README flags this — the Angular 14 → 22 migration
|
||||
turned `@Input() x!: T` into `input.required()`, which those never were.
|
||||
|
||||
## Analog gotchas worth knowing before editing
|
||||
|
||||
- **A `.server.ts` must be referenced from its `.page.ts`.** Analog treats an
|
||||
unreferenced one as a server-function module, parses it as JavaScript, and the
|
||||
build fails on the first `import type`. `injectLoad<typeof load>()` in the page
|
||||
is what keeps it compiled.
|
||||
- **`@analogjs/router`'s server entry reads `import.meta.env` at module scope.**
|
||||
Nitro leaves node_modules external in dev, so Node imports it raw and it
|
||||
throws before any loader runs. `vite.config.ts` inlines the package
|
||||
(`nitro.externals.inline`) to put it back through the bundler. Remove that and
|
||||
`npm start` renders an empty page.
|
||||
- **Partially-compiled libraries need Vite's SSR transform.** `@angular/cdk`,
|
||||
`@angular/material`, `@ngx-translate/*` and `@pi/ui` all ship partially
|
||||
compiled. Left external for SSR, the Angular linker never runs over them and
|
||||
the first Material component to render asks for the JIT compiler. They are
|
||||
listed in `ssr.noExternal` for that reason.
|
||||
61
docker-compose.yml
Normal file
@@ -0,0 +1,61 @@
|
||||
# Production. One service.
|
||||
#
|
||||
# The Pireon API is external — a separate deployment on the public internet, not
|
||||
# part of this stack — so there is nothing here to orchestrate alongside the
|
||||
# landing, and nothing to wait for on startup. What it does mean is that the
|
||||
# container needs outbound HTTPS: the page is server-rendered, so every request
|
||||
# it serves makes ten calls to `landing-api.pireon.dev` from inside the
|
||||
# container. Behind a restrictive egress policy, that host has to be allowed or
|
||||
# every page renders as if the API were in maintenance.
|
||||
#
|
||||
# NEXUS_TOKEN=… docker compose build # the build needs the private registry
|
||||
# docker compose up -d
|
||||
#
|
||||
# Put a reverse proxy in front for TLS. It should pass the response headers
|
||||
# through untouched: the app answers 503 with `Retry-After` while the API is in
|
||||
# maintenance, and 404 for addresses it does not serve.
|
||||
|
||||
services:
|
||||
landing:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
secrets:
|
||||
- nexus_token
|
||||
image: pireon/landing:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '${LANDING_PORT:-3000}:3000'
|
||||
environment:
|
||||
# Where the public API lives. The only setting the app actually reads.
|
||||
PIREON_API_URL: ${PIREON_API_URL:-https://landing-api.pireon.dev/api}
|
||||
PORT: '3000'
|
||||
NODE_ENV: production
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 15s
|
||||
retries: 3
|
||||
# The server writes nothing: the build output is all it has, and Nitro only
|
||||
# ever needs a scratch directory.
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: '10m'
|
||||
max-file: '3'
|
||||
|
||||
secrets:
|
||||
nexus_token:
|
||||
# Read from the environment at build time so the token never lands in a
|
||||
# file next to the compose config.
|
||||
environment: NEXUS_TOKEN
|
||||
BIN
docs/screenshots/01-home-light.png
Normal file
|
After Width: | Height: | Size: 569 KiB |
BIN
docs/screenshots/02-home-dark.png
Normal file
|
After Width: | Height: | Size: 451 KiB |
BIN
docs/screenshots/03-home-mobile-light.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
docs/screenshots/04-home-mobile-dark.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
docs/screenshots/05-mobile-menu.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
docs/screenshots/06-servicios.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
docs/screenshots/06d-servicios-dark.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
docs/screenshots/07-proyectos.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
docs/screenshots/07d-proyectos-dark.png
Normal file
|
After Width: | Height: | Size: 155 KiB |
BIN
docs/screenshots/08-clientes.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
docs/screenshots/08d-clientes-dark.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
docs/screenshots/09-testimonios.png
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
docs/screenshots/09d-testimonios-dark.png
Normal file
|
After Width: | Height: | Size: 151 KiB |
BIN
docs/screenshots/10-radar.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
docs/screenshots/10d-radar-dark.png
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
docs/screenshots/11-equipo.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
docs/screenshots/11d-equipo-dark.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
docs/screenshots/12-colabora.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
docs/screenshots/12d-colabora-dark.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
docs/screenshots/13-charlas.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
docs/screenshots/13d-charlas-dark.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
docs/screenshots/14-contacto.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
docs/screenshots/14d-contacto-dark.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
docs/screenshots/15-form-vacio.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
docs/screenshots/16-form-errores.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
docs/screenshots/17-form-relleno.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
docs/screenshots/18-form-peticion.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
docs/screenshots/19-home-en.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
docs/screenshots/20-home-ca.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
docs/screenshots/21-404-light.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
docs/screenshots/22-404-dark.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
docs/screenshots/23-mantenimiento.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
docs/screenshots/24-mantenimiento-dark.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
docs/screenshots/25-mantenimiento-movil.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
docs/screenshots/26-carrusel-movil.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
docs/screenshots/27-reduced-motion.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
docs/screenshots/28-logo-cabecera.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
docs/screenshots/28d-logo-cabecera-dark.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
54
eslint.config.mjs
Normal file
@@ -0,0 +1,54 @@
|
||||
import nx from "@nx/eslint-plugin";
|
||||
|
||||
export default [
|
||||
...nx.configs["flat/base"],
|
||||
...nx.configs["flat/typescript"],
|
||||
...nx.configs["flat/javascript"],
|
||||
{
|
||||
ignores: [
|
||||
"**/dist",
|
||||
"**/out-tsc"
|
||||
]
|
||||
},
|
||||
{
|
||||
files: [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"**/*.js",
|
||||
"**/*.jsx"
|
||||
],
|
||||
rules: {
|
||||
"@nx/enforce-module-boundaries": [
|
||||
"error",
|
||||
{
|
||||
enforceBuildableLibDependency: true,
|
||||
allow: [
|
||||
"^.*/eslint(\\.base)?\\.config\\.[cm]?[jt]s$"
|
||||
],
|
||||
depConstraints: [
|
||||
{
|
||||
sourceTag: "*",
|
||||
onlyDependOnLibsWithTags: [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
files: [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"**/*.cts",
|
||||
"**/*.mts",
|
||||
"**/*.js",
|
||||
"**/*.jsx",
|
||||
"**/*.cjs",
|
||||
"**/*.mjs"
|
||||
],
|
||||
// Override or add rules here
|
||||
rules: {}
|
||||
}
|
||||
];
|
||||
15
landing-e2e/eslint.config.mjs
Normal file
@@ -0,0 +1,15 @@
|
||||
import playwright from "eslint-plugin-playwright";
|
||||
import baseConfig from "../eslint.config.mjs";
|
||||
|
||||
export default [
|
||||
playwright.configs["flat/recommended"],
|
||||
...baseConfig,
|
||||
{
|
||||
files: [
|
||||
"**/*.ts",
|
||||
"**/*.js"
|
||||
],
|
||||
// Override or add rules here
|
||||
rules: {}
|
||||
}
|
||||
];
|
||||
40
landing-e2e/playwright.config.mts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
/**
|
||||
* Written without `@nx/playwright`'s preset on purpose: Playwright loads this
|
||||
* file through Node's ESM loader, and pulling in `@nx/devkit` from there drags
|
||||
* Nx's native binding in under `runSync`, where it fails to initialise. Only
|
||||
* `testDir`, `outputDir` and the web server came from the preset anyway.
|
||||
*/
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const workspaceRoot = resolve(here, '..');
|
||||
const baseURL = process.env['BASE_URL'] || 'http://localhost:4200';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './src',
|
||||
outputDir: resolve(workspaceRoot, 'dist/.playwright/landing-e2e/test-output'),
|
||||
timeout: 60_000,
|
||||
forbidOnly: !!process.env['CI'],
|
||||
retries: process.env['CI'] ? 2 : 0,
|
||||
workers: process.env['CI'] ? 1 : undefined,
|
||||
reporter: [['list']],
|
||||
use: {
|
||||
baseURL,
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
webServer: {
|
||||
command: 'npx nx run landing:serve',
|
||||
url: baseURL,
|
||||
reuseExistingServer: true,
|
||||
timeout: 180_000,
|
||||
cwd: workspaceRoot,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
11
landing-e2e/project.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "landing-e2e",
|
||||
"$schema": "../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "application",
|
||||
"sourceRoot": "landing-e2e/src",
|
||||
"implicitDependencies": [
|
||||
"landing"
|
||||
],
|
||||
"// targets": "to see all targets run: nx show project landing-e2e --web",
|
||||
"targets": {}
|
||||
}
|
||||
66
landing-e2e/src/landing.spec.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('landing', () => {
|
||||
test('renders the hero server-side', async ({ page }) => {
|
||||
const response = await page.goto('/');
|
||||
|
||||
expect(response?.status()).toBe(200);
|
||||
await expect(page.locator('h1')).toContainText('Construimos software');
|
||||
});
|
||||
|
||||
test('serves the page in the language the URL asks for', async ({ page }) => {
|
||||
await page.goto('/?lang=en');
|
||||
|
||||
await expect(page.locator('h1')).toContainText('We build software');
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', 'en');
|
||||
});
|
||||
|
||||
test('server-renders the whole page, not just the top of it', async ({
|
||||
browser,
|
||||
}) => {
|
||||
// With JavaScript off, what is left is exactly what the server sent. The
|
||||
// check is the last section and the footer: a component that throws during
|
||||
// rendering does not merely lose itself, it abandons everything after it,
|
||||
// and the page still answers 200 while quietly ending early. That is how
|
||||
// `pi-carousel` reaching for `document` once cost this page its contact
|
||||
// form server-side.
|
||||
const context = await browser.newContext({ javaScriptEnabled: false });
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.locator('h1')).toContainText('Construimos software');
|
||||
await expect(page.locator('#contacto')).toBeAttached();
|
||||
await expect(page.getByRole('contentinfo')).toContainText('Pireon');
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('answers 404 for an address it does not serve', async ({ page }) => {
|
||||
const response = await page.goto('/no-existe');
|
||||
|
||||
expect(response?.status()).toBe(404);
|
||||
await expect(page.locator('h1')).toContainText('no existe');
|
||||
});
|
||||
|
||||
test('refuses an incomplete contact submission without calling the API', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/');
|
||||
await page.locator('#contacto').scrollIntoViewIfNeeded();
|
||||
|
||||
const before = page.url();
|
||||
const submit = page.getByRole('button', { name: 'Enviar' });
|
||||
|
||||
// Enabled only once hydration attached the handler — before that a click
|
||||
// would submit natively and navigate away with the message. The generous
|
||||
// timeout is for the dev server's first compile, not for hydration.
|
||||
await expect(submit).toBeEnabled({ timeout: 30_000 });
|
||||
await submit.click();
|
||||
|
||||
// Nothing navigates and nothing is sent: the form holds the visitor there
|
||||
// rather than spending one of their throttled attempts on a bad payload.
|
||||
await expect(submit).toBeVisible();
|
||||
expect(page.url()).toBe(before);
|
||||
});
|
||||
});
|
||||
24
landing-e2e/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"outDir": "../dist/out-tsc",
|
||||
"sourceMap": false,
|
||||
"module": "commonjs",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.js",
|
||||
"playwright.config.mts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.js",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
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 |