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 |