Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions DEMO.md
Original file line number Diff line number Diff line change
@@ -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:** <https://connect-analyzer.vercel.app>
- **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) |
10 changes: 8 additions & 2 deletions DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,18 @@ real de SAP.

## Demo en vivo

| Pieza | URL | Hosting |
|--------------|---------------------------------------------------------|------------|
| Dashboard | <https://connect-analyzer.vercel.app> | Vercel |
| Backend API | <https://connect-analyzer-api.onrender.com> | Render |
| Mock SAP | <https://connect-analyzer-mock.onrender.com> | Render |
**Dashboard:** <https://connect-analyzer.vercel.app> (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

Expand Down Expand Up @@ -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)

Expand Down
12 changes: 2 additions & 10 deletions frontend/app/components/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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" });
Expand All @@ -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);
}
Expand All @@ -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 (
<>
Expand Down
21 changes: 9 additions & 12 deletions frontend/app/lib/dashboard.ts
Original file line number Diff line number Diff line change
@@ -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<DashboardData> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
Expand All @@ -28,17 +26,16 @@ export async function fetchDashboard(timeoutMs = 6000): Promise<DashboardData> {
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<boolean> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
Expand Down
Loading
Loading