git:20260915.1a354c1 to git:20260922.79f21ec

1 added, 0 removed. Audit A to A.

# app/ — AGENTS
Nuxt 4 (`compatibilityVersion: 4`) + Vuetify 4.1.5 SSR frontend for cambio-uruguay.com: live USD rates, ~60 content/tool pages, trilingual (es default / en / pt).
## Run / build / test / lint
- Dev: `npm run dev` → **port 3311** (`0.0.0.0`); prod: `npm run build` then `npm run start`.
- PM2 and the staging probe must explicitly set `NODE_ENV=production`: external Vue modules select their runtime when Node starts, independently of the Nuxt build mode. Also keep their `--max-old-space-size=512` budget aligned, below PM2's 900 MiB RSS restart threshold. An absent environment and oversized default heap contributed to memory restarts and intermittent 502s on 2026-09-07. Keep `ecosystem.config.cjs` and `scripts/check-staging.cjs` aligned; see `docs/app/SSR_RUNTIME.md`.
- After `.nuxt` is wiped (dev restart, branch switch): `npx nuxi prepare` (also runs via `postinstall`/`prepare` = `nuxt prepare`).
- **`npm run typecheck` (vue-tsc) is BROKEN — it crashes. Use `npm run lint` (eslint flat) instead;** `npm run lintfix` to auto-fix.
- Prettier is enforced *through* eslint (`prettier/prettier` rule): `semi:false, singleQuote, arrowParens:avoid, printWidth:100, trailingComma:es5, tabWidth:2`. lint-staged runs `lintfix` on js/ts/vue, `prettier --write` on json/md/css. Known conflict: prettier vs `vue/html-self-closing` anchor/attr wrapping — let `lintfix` settle it, don't hand-format.
- Unit: `npm run test:unit` (= `vitest run`; `tests/unit/**`, **environment: node, no Nuxt runtime** — tested logic must avoid Nuxt auto-imports & use relative imports). Most `utils/*.ts` have a paired `*.test.ts`.
- E2E: `npm run test:e2e` (Playwright, system Chrome, `tests/e2e/**`, baseURL `localhost:3311`, `workers:1`, reuses running dev server). **Gate every interaction on hydration** (`expect(...).toPass()` retries) or you hit first-click races (Suspense hydration bug is latent site-wide).
## Layout
- `pages/` — file-routed views (`casa/`, `cotizacion/`, `dolar/`, `convertir/`, `guias/[slug]`, `indicadores/`, `herramientas/`, `sucursales/`, `glosario/`, `blog/`, `cuenta/` subdirs).
- `utils/` — **typed data catalogs + pure logic** (`~110` files). Content pages are data-driven: a `utils/<topic>.ts` catalog (often with computed rankings + a `*.test.ts`) feeds `pages/<slug>.vue`. New content pages follow this. No Vue/Nuxt imports here — keep pure so vitest-node can load them.
- `components/` — Vuetify components; subdirs `OgImage/` (`Cambio.vue` OG template), `charts/`, `map/`, `mapa/`, `nearby/`, `reddit/`, `import-cart/`, `account/`, `analysis/`, `Faq/`, `videos/` (`Embed.vue` es una **fachada**: la miniatura reserva el 16/9 y el iframe de YouTube recién se monta al hacer clic — 24 players reales no pasan Core Web Vitals ni el banner de consentimiento).
- `server/` — Nitro. `api/` (routes, mostly `.get.ts` proxying/merging `api.cambio-uruguay.com`), `tasks/` (scheduled — see below), `models/` (mongoose), `utils/` (e.g. `firebaseAdmin.ts`), `data/`.
- `composables/` — `useThemeMode`, `useExchangeRates`, `useTrack`, `useConsent`, `useRedditSentiment`, `useAuthFetch`, `useSavedDrift`, etc.
- `stores/` — Pinia (`auth`, `cambio`, `importCart`, `loading`, `firebaseAuthApi`, `firebaseMessagingApi`).
- `i18n/locales/` — `es.ts` / `en.ts` / `pt.ts` (+ `json/`). Strategy `prefix_except_default`; labels are i18n keys, never raw literals.
- `plugins/` (`.client.ts` for browser-only: firebase, clarity, consent, pwa-install, track-clicks), `middleware/auth.ts` (client-only login guard), `layouts/` (`default`, `error`, `widget`), `assets/`.
## Source-of-truth & guard rails (these cause CI-red bugs)
+ - Enlaces PAGOS (patrocinio, afiliado): siempre `rel="noopener noreferrer sponsored"`, etiqueta `ads.label`, host del destino impreso y utm_* en la URL (`utils/outboundUtm.ts`). Registros puros: `utils/sponsorships.ts`, `utils/affiliates.ts`; la URL/id llega por runtimeConfig y con la config vacía NO se dibuja nada. Las condiciones públicas viven en `/publicidad` (`utils/mediaKit.ts`, sin cifras).
- **`utils/siteNav.ts` = single source of truth** for desktop header, mobile drawer, footer, XML+HTML sitemap, Ctrl+K palette, `/buscar`. A new `pages/*.vue` **not registered in `NAV_SECTIONS`** (or in `EXCLUDED_ROUTES`/`UNLISTED_ROUTES`) fails `tests/unit/siteNav-coverage.test.ts`. It's a PURE module (no catalog imports); long-tail search lives in `utils/searchIndex.ts`. New tool → also `utils/tools.ts` (`toolSlugs`).
- **No Gemini/AI in this app** — enforced by `tests/unit/noGeminiInApp.test.ts`. All AI (blog moves/predictions/figures/loans) moved to the backend; app only proxies + caches. Figures/company constants baked into utils serve as baseline; live values arrive from backend.
- OG image is generated **per public page** (nuxt-og-image via `OgImage/Cambio.vue`); `og:image` is intentionally NOT hardcoded in `nuxt.config`. `npm run check:og-images` verifies coverage.
## Theme & responsive (recurring bug sources)
- Mobile first visit must remain usable with **no saved preferences**: there is no global chat, timed social popup, or automatic install invitation. `CookieConsent` starts in document flow; only the footer's explicit preferences action opens its dialog. `PwaInstallAction` is optional in the footer and after a successful rental subscription; `pwa-install.client.ts` captures the browser event but never invokes installation without a click. `DonationCard` stays in document flow. Do not pre-dismiss these states in first-visit E2E tests.
- Rental, sales and opportunity filters use a compact sticky mobile toolbar and an explicitly opened side panel. Keep Apply/Clear accessible in short viewports, preserve draft cancellation, and test while scrolled down as well as on arrival. Advanced options may be collapsed but active advanced criteria must remain discoverable.
- First visits default to light, including on a dark operating system. Preserve explicitly saved dark/system preferences. `useThemeMode` persists to `localStorage['cu_theme']` (`THEME_STORAGE_KEY` in `utils/theme.ts`) and stamps `data-theme` on `<html>`; Vuetify and the pre-paint bootstrap must agree. The mobile drawer must have its closed display and 288px width in SSR, before client JavaScript loads.
- Light-mode AA fixes live in `assets/css/critical.css`. **A permanently-dark/colored banner darkens its own text in light mode** → add class **`.on-dark`** to the slab root so the global rule restores light text.
- Wide tables: add global class **`cu-mobile-cards`** + `data-label` per `<td>` to stack into cards <600px (`assets/css/responsive-tables.css`); VDataTables use native `:mobile` instead.
- Vuetify base stylesheet is **`~/assets/variables.scss`, NOT `vuetify/styles`** (loading both double-emits & breaks the v4→v3 look reverts). `legacy-vuetify.css` restores MD2 elevations/case. Do not add `@nuxtjs/leaflet` (ships leaflet.css globally); Leaflet CSS is scoped per-route.
- **Vuetify 4's reset does NOT zero text-block margins**, so `<p>`/`<h*>`/`<ul>`/`<pre>` still carry the UA `margin-block: 1em`. It collapses between stacked siblings (invisible) but **not** inside a padded box or a flex/grid item — the callout/alert idiom `icon + <div><p class="title"><p class="intro">` rendered its title 16px below the icon on 20 pages. `critical.css` neutralises the `:first-child` case globally; **a paragraph that follows a sibling still overrides any authored gap smaller than 1em, so declare `margin-top` on every text block whose spacing you care about** (see DESIGN.md → "The Text Block Owns Its Top Margin Rule"). Sweep with `npm run audit:margins` (`BASE=`, `PAGES=`, `VIEWPORTS=`, `SCOPE=first-child`); `TRAPPED` findings are always defects, `REVIEW` findings are gaps currently paid for by the browser.
- **Every page root is a `<VContainer>`** (or a `<div>`/`<main>` wrapper whose first child is one). The layout's `.container_custom` only gives 12px; the container adds the other 16px plus the shared width cap. A bare `<div>`/`<v-row>` root renders 12px from the phone's edge — 57 templates did until 2026-09-12 (DESIGN.md → "The Page Brings Its Container Rule"). `tests/unit/pageContainer.test.ts` fails the build on a new one; `npm run audit:gutters` (`BASE=`, `PAGES=`, `VIEWPORTS=`, `FLOOR=`, `ALL=1`) measures the rendered edge. Use `fluid` for charts and wide tables; full-bleed stays on the route class in `layouts/default.vue`.
## Auth, analytics, tasks
- Firebase auth (Google / email / magic-link / guest / Discord-OAuth→custom-token) + favorites + rate alerts + Telegram linking. Web SDK config in `runtimeConfig.public.firebase`; Admin server-side (`server/utils/firebaseAdmin.ts`).
- Rental subscriptions (`/api/me/rental-alerts`, `components/rentals/RentalAlert*`) reuse that auth but require a recoverable account and a server-verified email for email delivery. `rentals:alerts` runs every ten minutes with Mongo leases and a per-channel outbox; first observation/resume is silent, and novelty uses durable source-advert IDs rather than daily `firstSeen` or property grouping keys. `PushRegistration` is the authoritative device owner, independent of legacy `User.fcmTokens`. Never call real transports from dev/prerender or a dry-run. See `docs/app/RENTAL_ALERTS.md` for opt-out, cursor and delivery invariants.
- **This app only WRITES to GA4** (`nuxt-gtag`, Consent Mode v2 default-deny) — no Google credentials live here. The read path is the backend job `currency-site-analytics` (GA4 Data API → app Mongo `siteanalyticssnapshots`), which `/api/site-analytics` serves to `/estadisticas-del-sitio` (its live "Ahora mismo" block proxies `GET /site-analytics-realtime` on the API instead, polled once a minute while the tab is visible); setup in `docs/analytics/GA4_DATA_API.md`. That snapshot is a public, daily, 28-day aggregate: still **not** the signal for what to build — demand comes from the Reddit pipeline (`server/tasks/reddit/sentiment.ts` → `useRedditSentiment`), because consent-denied traffic never reaches GA4 at all.
- Nitro `scheduledTasks` (UTC) in `nuxt.config.ts`: `drivers:daily`, `blog:daily`, `figures:drift`, `newsletter:daily`, `alerts:check` (*/10m), `telegram:summary`, `couriers:scrape`, `withdraw:iva-check`, `casas:reviews`, `reddit:sentiment`, `bcu:warnings`. Durable fs stores under `.data/` (blog, couriers, withdraw, casas-reviews, figures, company).
## Deeper docs
`docs/app/SEO_README.md`, `docs/app/AUTH_README.md`, `docs/app/PERFORMANCE_IMPROVEMENTS.md`; SEO strategy/audits in `docs/seo/`. Calculadoras puras con página propia: `docs/app/PLAN_DE_VIDA.md`, `docs/app/MEAL_PREP.md` (`/meal-prep-uruguay`: motor en `utils/mealprep/`, precios proyectados de `GET /precios/articles`, sin job). Env template: `app/.env.example`. Backend/deploy specifics live in the root `AGENTS.md` and the maintainer's memory, not here.