From 8777b8ced7daeb54e2905931b412079b626ed4f3 Mon Sep 17 00:00:00 2001 From: Aitor Reviriego Amor Date: Tue, 2 Jun 2026 09:15:33 +0200 Subject: [PATCH] feat(frontend): datos de demo embebidos (demo autosuficiente en Vercel) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La demo dependía del backend .NET + mock en Render free, que se duermen y provocaban 'No hay datos' / 502 al abrir en frío. Como el dashboard ya calcula todos los agregados en cliente, ahora el frontend trae un dataset de ejemplo embebido y lo usa como fuente por defecto: - frontend/app/lib/sample-sales.json: 25 ventas ficticias (fixtures del mock). - fetchDashboard: usa BACKEND_URL si responde con datos; si no (sin URL, caído, vacío o timeout) cae al dataset embebido. La demo carga al instante, siempre, sin depender de Render. - DEMO.md: documenta el montaje (front en Vercel, datos embebidos, backend opcional y cómo conectarlo). README y DEPLOY actualizados. Co-Authored-By: Claude Opus 4.8 --- DEMO.md | 92 +++++++++++++ DEPLOY.md | 10 +- README.md | 20 +-- frontend/app/components/Dashboard.tsx | 12 +- frontend/app/lib/dashboard.ts | 21 ++- frontend/app/lib/sample-sales.json | 177 ++++++++++++++++++++++++++ 6 files changed, 298 insertions(+), 34 deletions(-) create mode 100644 DEMO.md create mode 100644 frontend/app/lib/sample-sales.json diff --git a/DEMO.md b/DEMO.md new file mode 100644 index 0000000..ca3ee91 --- /dev/null +++ b/DEMO.md @@ -0,0 +1,92 @@ +# Demo en vivo: cómo está montada + +Referencia de cómo funciona la demo pública, de dónde salen los datos y cómo usar el backend +real si hace falta. **Resumen en una frase:** la demo vive **solo en Vercel** y es +**autosuficiente** (datos de ejemplo embebidos), así que no depende de ningún backend ni sufre +cold-starts. + +## Decisión: datos embebidos en el frontend + +El dashboard **calcula todos los agregados en cliente** (KPIs, series, by-product, by-customer) +a partir de las ventas crudas. Eso permitió desacoplar la demo del backend: en vez de depender de +un servicio .NET + mock que en el tier gratuito **se duermen** (Render free duerme tras ~15 min y +provocaba el clásico "No hay datos" / 502 al abrir en frío), el frontend trae un **dataset de +ejemplo embebido** y lo usa directamente. + +Resultado: la demo **carga al instante, siempre**, sin Render, sin cold-start, sin servicios que +mantener despiertos. El backend real sigue existiendo (es el producto), pero es **opcional** para +la demo. + +## Dónde está desplegado el frontend + +- **Hosting:** Vercel (siempre activo, sin cold-start). +- **URL:** +- **Proyecto:** Next.js (App Router) en `frontend/`. Vercel hace auto-deploy en cada push a `main` + (root directory = `frontend`). + +## Cómo coge los datos de prueba + +1. **Dataset embebido:** `frontend/app/lib/sample-sales.json` — las 25 ventas ficticias del mock + (mismas fixtures que `backend/mocks/sap/data/sales.txt`, ya parseadas a JSON). +2. **`fetchDashboard()`** (`frontend/app/lib/dashboard.ts`) decide la fuente: + - Si `BACKEND_URL` apunta a un backend **alcanzable y con datos** → usa esos datos en vivo. + - En cualquier otro caso (sin `BACKEND_URL`, backend caído/dormido, respuesta vacía o timeout) + → **cae automáticamente al dataset embebido**. +3. El route handler `frontend/app/api/dashboard/route.ts` (`GET`) reexpone esto al cliente + mismo-origen; el componente `Dashboard` deriva KPIs/gráficos del set resultante y aplica los + filtros en cliente. + +**En la demo de Vercel, `BACKEND_URL` está vacío** → siempre sirve el dataset embebido → instantáneo. + +### Regenerar el dataset embebido + +Si cambian las fixtures del mock y quieres refrescar el JSON embebido, con el backend local +levantado en Mock: + +```bash +curl -s http://localhost:5080/api/sales | python3 -m json.tool > frontend/app/lib/sample-sales.json +``` + +## El backend (opcional): dónde está y cómo usarlo + +El backend .NET 10 (`backend/`, arquitectura hexagonal) **no es necesario para la demo**, pero es +el producto real y se puede usar/desplegar. + +### En local (con datos en vivo del mock, SAP o Shopify) + +```bash +docker compose up --build # levanta sap-mock + backend + frontend +# frontend en :3000 → BACKEND_URL=http://backend:8080 (lo pone docker-compose) +``` + +La fuente se elige con la env var **`SalesSource`** (ver `backend/CLAUDE.md` y `.env.example`): + +- `Mock` (por defecto) — lee el `.txt` del servicio `sap-mock`. +- `Sap` — OData real del SAP Business Accelerator Hub (requiere el secreto `Sap__ApiKey`). +- `Shopify` — Admin API real (requiere `Shopify__StoreUrl`, `Shopify__ClientId`, + `Shopify__ClientSecret`). + +Los secretos van en un `.env` en la raíz (gitignored); nunca en git. + +### Conectar la demo a un backend en vivo + +Si algún día quieres que la demo de Vercel use un backend real en vez del dataset embebido: + +1. Despliega el backend (+ mock si usas `SalesSource=Mock`) — ver [`DEPLOY.md`](./DEPLOY.md) + (Render Blueprint). Para SAP/Shopify, configura los secretos en el *Environment* del servicio. +2. En Vercel, define la env var **`BACKEND_URL`** apuntando a la URL pública del backend y + **Redeploy**. +3. El frontend usará el backend si responde con datos; si no, seguirá cayendo al dataset embebido. + +> Aviso: en hosting gratuito (Render) el backend se duerme y la primera petición tarda en +> despertar. Por eso, para una demo siempre-activa, lo recomendado es **dejar `BACKEND_URL` vacío** +> y servir el dataset embebido. + +## Mapa rápido + +| Pieza | Dónde | Necesaria para la demo | +|-------|-------|------------------------| +| Frontend (Next.js) | Vercel · `frontend/` | **Sí** | +| Dataset de ejemplo | `frontend/app/lib/sample-sales.json` | **Sí** (fuente por defecto) | +| Backend (.NET 10) | local (docker) u opcionalmente Render · `backend/` | No (opcional) | +| Mock (nginx) | local (docker) u opcionalmente Render · `backend/mocks/sap/` | No (opcional) | diff --git a/DEPLOY.md b/DEPLOY.md index fda727f..47c3b21 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -58,11 +58,17 @@ In the Vercel dashboard: **Add New… → Project → Import the GitHub repo `ai - **Root Directory**: `frontend` (the Next.js app lives in this subfolder). - **Framework Preset**: Next.js (auto-detected). -- **Environment Variables**: - - `BACKEND_URL` = `https://connect-analyzer-api.onrender.com` +- **Environment Variables** — `BACKEND_URL` is **optional**: + - **Leave it unset** (recommended) → the dashboard serves its bundled sample data + (`frontend/app/lib/sample-sales.json`): instant, always-on, no backend needed. See [`DEMO.md`](./DEMO.md). + - Or set `BACKEND_URL` = your backend URL (e.g. `https://connect-analyzer-api.onrender.com`) to + use live data; the frontend falls back to the bundled data if it's unreachable. Click **Deploy**. Vercel builds with `next build` and serves the dashboard. +> The backend (steps 1) is **not required** for the demo — the frontend is self-sufficient. Deploy +> it only if you want live data (incl. real SAP/Shopify) behind the dashboard. + ## 3. Optional: lock CORS to your Vercel origin `page.tsx` fetches the backend **server-side**, so the browser never calls the backend directly — CORS diff --git a/README.md b/README.md index 95068b5..a26cacb 100644 --- a/README.md +++ b/README.md @@ -12,19 +12,18 @@ real de SAP. ## Demo en vivo -| Pieza | URL | Hosting | -|--------------|---------------------------------------------------------|------------| -| Dashboard | | Vercel | -| Backend API | | Render | -| Mock SAP | | Render | +**Dashboard:** (Vercel — siempre activo, carga instantánea). -> El tier gratuito de Render duerme los servicios tras ~15 min sin tráfico, así que la **primera -> carga puede tardar ~30-50 s** (cold-start). Recargar es instantáneo. El backend reintenta el -> seed-on-startup con backoff para autocurarse cuando el mock también está despertando. +La demo es **autosuficiente**: el frontend trae un dataset de ejemplo embebido +(`frontend/app/lib/sample-sales.json`, las fixtures del mock) y **calcula todos los agregados en +cliente**, así que **no depende de ningún backend** ni sufre cold-starts. El backend .NET y el mock +son el producto real pero **opcionales** para la demo; si quieres datos en vivo (incl. SAP/Shopify), +se conecta vía la env var `BACKEND_URL` y, si no responde, el frontend cae al dataset embebido. +- **Cómo está montada la demo** (front, datos, backend opcional): ver [`DEMO.md`](./DEMO.md). - **Caso de estudio**: [Post en aitorevi.dev](https://aitorevi.dev/blog/sap-analyzer) — por qué hexagonal, patrón `Result`/`Error`, adaptador SAP real y persistencia con SQLite. -- **Cómo desplegarlo de cero**: ver [`DEPLOY.md`](./DEPLOY.md) (Render Blueprint + Vercel, sin tarjeta). +- **Cómo desplegar el backend de cero**: ver [`DEPLOY.md`](./DEPLOY.md) (Render Blueprint + Vercel, sin tarjeta). ## Arquitectura @@ -146,7 +145,8 @@ npm run lint # eslint ``` - `BACKEND_URL` — URL del backend (en Docker: `http://backend:8080`; en local por defecto - `http://localhost:5080`; en Vercel apunta a `https://connect-analyzer-api.onrender.com`). + `http://localhost:5080`). **Opcional**: si no se alcanza (o está vacío, como en la demo de + Vercel), el frontend usa el dataset embebido (`sample-sales.json`). Ver [`DEMO.md`](./DEMO.md). ### Mock (nginx) diff --git a/frontend/app/components/Dashboard.tsx b/frontend/app/components/Dashboard.tsx index 83614ca..b16ed66 100644 --- a/frontend/app/components/Dashboard.tsx +++ b/frontend/app/components/Dashboard.tsx @@ -27,13 +27,10 @@ import type { DashboardData, Sale } from "../lib/dashboard"; type Props = { initialSales: Sale[] }; const POLL_INTERVAL_MS = 5000; -const MAX_ATTEMPTS = 30; // ~2.5 min, covers a free-tier mock + backend cold start +const MAX_ATTEMPTS = 30; const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); -// Owns the raw sales + filter state and derives every aggregate client-side, so the date -// range and product/customer filters recompute the whole dashboard consistently. Also -// self-heals on a free-tier cold start (re-trigger refresh + poll until rows appear). export default function Dashboard({ initialSales }: Props) { const initialEmpty = initialSales.length === 0; @@ -72,7 +69,6 @@ export default function Dashboard({ initialSales }: Props) { try { await fetch("/api/dashboard", { method: "POST" }); } catch { - // ignore — the GET below decides whether we have data } try { const res = await fetch("/api/dashboard", { cache: "no-store" }); @@ -86,7 +82,6 @@ export default function Dashboard({ initialSales }: Props) { } } } catch { - // keep polling — a cold backend may still be waking up } await delay(POLL_INTERVAL_MS); } @@ -97,13 +92,10 @@ export default function Dashboard({ initialSales }: Props) { }, []); useEffect(() => { - // Mount only: if SSR already delivered data we never warm up. Deferred so the async - // work starts outside the effect body (no synchronous state update on mount). if (!initialEmpty) return; const id = setTimeout(poll, 0); return () => clearTimeout(id); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [initialEmpty, poll]); return ( <> diff --git a/frontend/app/lib/dashboard.ts b/frontend/app/lib/dashboard.ts index 16a7ea0..217e68e 100644 --- a/frontend/app/lib/dashboard.ts +++ b/frontend/app/lib/dashboard.ts @@ -1,25 +1,23 @@ +import sampleSales from "./sample-sales.json"; + export type ProductTotal = { product: string; totalAmount: number }; export type CustomerTotal = { customerId: string; totalAmount: number }; export type Sale = { - date: string; // ISO "YYYY-MM-DD" + date: string; customerId: string; productName: string; quantity: number; amount: number; }; -// The dashboard derives every aggregate client-side from the raw sales (so filters can -// recompute them), so a single fetch of the raw sales is all it needs. + export type DashboardData = { sales: Sale[]; }; -const EMPTY: DashboardData = { sales: [] }; +const SAMPLE: DashboardData = { sales: sampleSales as Sale[] }; const backendUrl = () => process.env.BACKEND_URL ?? "http://localhost:5080"; -// Server-side fetch of both aggregates. Never throws: on a cold/unreachable backend it -// resolves to empty arrays so the page can render and the client can warm the demo up. -// The timeout keeps SSR from hanging on a sleeping free-tier backend. export async function fetchDashboard(timeoutMs = 6000): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); @@ -28,17 +26,16 @@ export async function fetchDashboard(timeoutMs = 6000): Promise { cache: "no-store", signal: controller.signal, }); - if (!res.ok) return EMPTY; - return { sales: await res.json() }; + if (!res.ok) return SAMPLE; + const sales: Sale[] = await res.json(); + return sales.length > 0 ? { sales } : SAMPLE; } catch { - return EMPTY; + return SAMPLE; } finally { clearTimeout(timer); } } -// Triggers a re-ingestion on the backend. Used to self-heal the demo when the store is -// empty (free-tier cold start). Never throws; returns whether the refresh succeeded. export async function triggerRefresh(timeoutMs = 90000): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); diff --git a/frontend/app/lib/sample-sales.json b/frontend/app/lib/sample-sales.json new file mode 100644 index 0000000..d128800 --- /dev/null +++ b/frontend/app/lib/sample-sales.json @@ -0,0 +1,177 @@ +[ + { + "date": "2026-01-02", + "customerId": "C001", + "productName": "Caf\u00e9 Molido", + "quantity": 10, + "amount": 125.5 + }, + { + "date": "2026-01-02", + "customerId": "C002", + "productName": "T\u00e9 Verde", + "quantity": 5, + "amount": 42.75 + }, + { + "date": "2026-01-03", + "customerId": "C001", + "productName": "Galletas", + "quantity": 20, + "amount": 80.0 + }, + { + "date": "2026-01-03", + "customerId": "C003", + "productName": "Jam\u00f3n Serrano", + "quantity": 2, + "amount": 95.2 + }, + { + "date": "2026-01-04", + "customerId": "C002", + "productName": "Caf\u00e9 Molido", + "quantity": 8, + "amount": 100.4 + }, + { + "date": "2026-01-04", + "customerId": "C004", + "productName": "Lim\u00f3n", + "quantity": 15, + "amount": 22.5 + }, + { + "date": "2026-01-05", + "customerId": "C001", + "productName": "Aceite", + "quantity": 6, + "amount": 78.0 + }, + { + "date": "2026-01-05", + "customerId": "C005", + "productName": "T\u00e9 Verde", + "quantity": 10, + "amount": 85.5 + }, + { + "date": "2026-01-06", + "customerId": "C003", + "productName": "Caf\u00e9 Molido", + "quantity": 12, + "amount": 150.6 + }, + { + "date": "2026-01-06", + "customerId": "C002", + "productName": "Galletas", + "quantity": 25, + "amount": 100.0 + }, + { + "date": "2026-01-07", + "customerId": "C004", + "productName": "Jam\u00f3n Serrano", + "quantity": 3, + "amount": 142.8 + }, + { + "date": "2026-01-07", + "customerId": "C001", + "productName": "Lim\u00f3n", + "quantity": 20, + "amount": 30.0 + }, + { + "date": "2026-01-08", + "customerId": "C005", + "productName": "Aceite", + "quantity": 4, + "amount": 52.0 + }, + { + "date": "2026-01-08", + "customerId": "C002", + "productName": "Caf\u00e9 Molido", + "quantity": 6, + "amount": 75.3 + }, + { + "date": "2026-01-09", + "customerId": "C003", + "productName": "T\u00e9 Verde", + "quantity": 8, + "amount": 68.4 + }, + { + "date": "2026-01-09", + "customerId": "C001", + "productName": "Galletas", + "quantity": 15, + "amount": 60.0 + }, + { + "date": "2026-01-10", + "customerId": "C004", + "productName": "Caf\u00e9 Molido", + "quantity": 9, + "amount": 112.95 + }, + { + "date": "2026-01-10", + "customerId": "C005", + "productName": "Lim\u00f3n", + "quantity": 18, + "amount": 27.0 + }, + { + "date": "2026-01-11", + "customerId": "C002", + "productName": "Jam\u00f3n Serrano", + "quantity": 1, + "amount": 47.6 + }, + { + "date": "2026-01-11", + "customerId": "C003", + "productName": "Aceite", + "quantity": 7, + "amount": 91.0 + }, + { + "date": "2026-01-12", + "customerId": "C001", + "productName": "T\u00e9 Verde", + "quantity": 6, + "amount": 51.3 + }, + { + "date": "2026-01-12", + "customerId": "C004", + "productName": "Galletas", + "quantity": 10, + "amount": 40.0 + }, + { + "date": "2026-01-13", + "customerId": "C005", + "productName": "Caf\u00e9 Molido", + "quantity": 11, + "amount": 138.05 + }, + { + "date": "2026-01-13", + "customerId": "C002", + "productName": "Lim\u00f3n", + "quantity": 12, + "amount": 18.0 + }, + { + "date": "2026-01-14", + "customerId": "C003", + "productName": "Aceite", + "quantity": 5, + "amount": 65.0 + } +]