Compare commits
23 Commits
6b9a94490b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a81ee50ed8 | |||
| 18d5ed87c8 | |||
| b76c14b780 | |||
| 63a896b017 | |||
| 673c32d0e1 | |||
| 8a98f88047 | |||
| 7c689e034e | |||
| 3e0b286f1a | |||
| e0399ccf3b | |||
| bf8b2aa631 | |||
| afcaf991b5 | |||
| fbfffb28d9 | |||
| 148aefc68f | |||
| 1ee8288c7e | |||
| 3a94e7c003 | |||
| 792dd6794b | |||
| 7278d5d00f | |||
| 8941d1a2c3 | |||
| 95132476ae | |||
| c45a5be99e | |||
| cb7458cded | |||
| 7ec99734c5 | |||
| 8d80cbbc27 |
@@ -50,7 +50,10 @@ public/news/
|
|||||||
public/parts/
|
public/parts/
|
||||||
public/operations-inbox/
|
public/operations-inbox/
|
||||||
public/footage/
|
public/footage/
|
||||||
|
public/team/
|
||||||
|
public/branding/
|
||||||
|
|
||||||
# Local Claude Code / MCP config — agent-specific, not project
|
# Local Claude Code / MCP config — agent-specific, not project
|
||||||
.mcp.json
|
.mcp.json
|
||||||
.claude/
|
.claude/
|
||||||
|
backups/
|
||||||
|
|||||||
@@ -46,6 +46,14 @@ RUN DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy" npx prisma gene
|
|||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy"
|
ENV DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy"
|
||||||
|
|
||||||
|
# NEXT_PUBLIC_* vars are inlined into the client bundle at BUILD time, so the
|
||||||
|
# GA Measurement ID must be present here (not just at runtime). Passed from
|
||||||
|
# docker-compose build.args -> .env. Empty by default = analytics disabled.
|
||||||
|
ARG NEXT_PUBLIC_GA_ID=""
|
||||||
|
ENV NEXT_PUBLIC_GA_ID=$NEXT_PUBLIC_GA_ID
|
||||||
|
ARG NEXT_PUBLIC_GSC_VERIFICATION=""
|
||||||
|
ENV NEXT_PUBLIC_GSC_VERIFICATION=$NEXT_PUBLIC_GSC_VERIFICATION
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ── Stage 4: Production runner ──
|
# ── Stage 4: Production runner ──
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ services:
|
|||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
- flux-net
|
- flux-net
|
||||||
|
# Resource caps so no single container can starve the others (the Nginx
|
||||||
|
# outage earlier was a reminder). VPS has ~11 GB; these leave headroom.
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 2g
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -42,6 +48,12 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
# NEXT_PUBLIC_* are inlined into the client bundle at build time.
|
||||||
|
# Sourced from .env on the host; the fallback is the FLUX GA4 ID so
|
||||||
|
# analytics works out of the box even if .env doesn't override it.
|
||||||
|
NEXT_PUBLIC_GA_ID: ${NEXT_PUBLIC_GA_ID:-G-KQ1JRV3KN7}
|
||||||
|
NEXT_PUBLIC_GSC_VERIFICATION: ${NEXT_PUBLIC_GSC_VERIFICATION:-}
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
@@ -58,6 +70,10 @@ services:
|
|||||||
SMTP_FROM: ${SMTP_FROM}
|
SMTP_FROM: ${SMTP_FROM}
|
||||||
SMTP_SECURE: ${SMTP_SECURE}
|
SMTP_SECURE: ${SMTP_SECURE}
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
# Optional: REDIS_URL enables multi-instance rate limiting. Leave unset
|
||||||
|
# for the current single-container deploy — the in-memory store is used.
|
||||||
|
REDIS_URL: ${REDIS_URL:-}
|
||||||
|
REDIS_TOKEN: ${REDIS_TOKEN:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ./public/footage:/app/public/footage
|
- ./public/footage:/app/public/footage
|
||||||
- ./public/applications:/app/public/applications
|
- ./public/applications:/app/public/applications
|
||||||
@@ -66,10 +82,23 @@ services:
|
|||||||
- ./public/parts:/app/public/parts
|
- ./public/parts:/app/public/parts
|
||||||
- ./public/operations-inbox:/app/public/operations-inbox
|
- ./public/operations-inbox:/app/public/operations-inbox
|
||||||
- ./public/branding:/app/public/branding
|
- ./public/branding:/app/public/branding
|
||||||
|
- ./public/team:/app/public/team
|
||||||
networks:
|
networks:
|
||||||
- flux-net
|
- flux-net
|
||||||
expose:
|
expose:
|
||||||
- "3000"
|
- "3000"
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 1500m
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD-SHELL
|
||||||
|
- "node -e \"fetch('http://localhost:3000/api/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))\""
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
# ── Nginx Reverse Proxy ──
|
# ── Nginx Reverse Proxy ──
|
||||||
nginx:
|
nginx:
|
||||||
@@ -90,10 +119,51 @@ services:
|
|||||||
- ./public/footage:/srv/footage:ro
|
- ./public/footage:/srv/footage:ro
|
||||||
- ./public/operations-inbox:/srv/operations-inbox:ro
|
- ./public/operations-inbox:/srv/operations-inbox:ro
|
||||||
- ./public/branding:/srv/branding:ro
|
- ./public/branding:/srv/branding:ro
|
||||||
|
- ./public/team:/srv/team:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
- app
|
- app
|
||||||
networks:
|
networks:
|
||||||
- flux-net
|
- flux-net
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 256m
|
||||||
|
healthcheck:
|
||||||
|
# Nginx self-health (served directly by the default_server, no upstream).
|
||||||
|
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/nginx-health || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
# ── Automated Postgres backups ──
|
||||||
|
# Nightly pg_dump -> gzip into ./backups on the host, 14-day rotation.
|
||||||
|
# NOTE: this is LOCAL to the VPS. Offsite copy (S3/rsync) is the recommended
|
||||||
|
# next step once the client provides storage credentials.
|
||||||
|
backup:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: always
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DB_USER: ${DB_USER}
|
||||||
|
DB_PASSWORD: ${DB_PASSWORD}
|
||||||
|
DB_NAME: ${DB_NAME}
|
||||||
|
BACKUP_DIR: /backups
|
||||||
|
RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-14}
|
||||||
|
BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400}
|
||||||
|
volumes:
|
||||||
|
- ./backups:/backups
|
||||||
|
- ./scripts/db-backup.sh:/usr/local/bin/db-backup.sh:ro
|
||||||
|
- ./scripts/backup-loop.sh:/usr/local/bin/backup-loop.sh:ro
|
||||||
|
entrypoint: ["/bin/sh", "/usr/local/bin/backup-loop.sh"]
|
||||||
|
networks:
|
||||||
|
- flux-net
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 256m
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
|||||||
@@ -0,0 +1,458 @@
|
|||||||
|
# FLUX SRL — Audit Verification & Corrected Priorities
|
||||||
|
|
||||||
|
**Date:** 2026-06-09
|
||||||
|
**Method:** 10-dimension multi-agent audit (59 agents, adversarial verification) + manual re-verification of every critical/high finding against the running production system.
|
||||||
|
|
||||||
|
> **Why this file exists:** the automated audit (full report below) is thorough but produced one **false-positive CRITICAL** and overstated a few others. This top section is the corrected, ground-truth verdict. Trust this section over the raw report where they disagree.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual verification results (the corrections)
|
||||||
|
|
||||||
|
### ❌ SEC-02 "Auth middleware never runs" — FALSE POSITIVE
|
||||||
|
The audit's #1 critical claimed `src/proxy.ts` is never executed because it exports `proxy` not `middleware`.
|
||||||
|
**Verified in production:** `curl https://www.rf-flux.com/hq-command/dashboard` (no cookie) → **307 redirect to `/hq-command/login`**. The middleware **is running**. Next.js 16 *does* recognize `src/proxy.ts` exporting `proxy` (the rename is real in Next 16; the code comment was correct). The empty `middleware-manifest.json` in a local build is misleading — runtime behavior disproves the claim.
|
||||||
|
**Consequence:** the HQ admin surface **is** protected by the middleware. SEC-03 (below) drops from critical to low.
|
||||||
|
|
||||||
|
### ⚠️ SEC-03 "HQ server actions lack auth" — OVERSTATED (low, not critical)
|
||||||
|
Because the middleware *does* run and its matcher covers `/hq-command/*`, POSTs to those routes (which is how server actions are invoked) are gated — no cookie → redirect before the action executes. Adding an in-action `getSession()` check is still good defense-in-depth, but it is **not** an open door.
|
||||||
|
|
||||||
|
### ✅ SEC-04 "/api/assets + /api/branding/favicon unauthenticated" — REAL (HIGH) — the actual top security issue
|
||||||
|
Verified: neither route checks a session, and the middleware matcher **excludes `/api`**, so the middleware does not protect them. Anyone can `GET/POST/PUT/DELETE` `/api/assets` — list CMS structure, upload files into public scopes, rename/delete content — and POST `/api/branding/favicon`. **This is the real #1.**
|
||||||
|
|
||||||
|
### ✅ DB-01 "ClientUser table never migrated" — REAL (HIGH), impact nuanced
|
||||||
|
Verified: the init migration creates 10 tables; **`ClientUser` is not among them**, and no later migration adds it. Code calls `prisma.clientUser` in `clientAuth.ts` (B2B register/login) and the dashboard.
|
||||||
|
**Real impact:** the **B2B client portal (register/login) is broken** at runtime. The HQ dashboard does *not* crash (its counts run inside a try/catch and silently show 0 clients), so the audit's "undeployable crash" framing was overstated — but the B2B portal genuinely doesn't work.
|
||||||
|
|
||||||
|
### ✅ SEC-05 "Operations email not HTML-escaped" — REAL (MEDIUM-HIGH)
|
||||||
|
Verified: `src/app/actions/operations.ts → generateRichEmailHtml()` interpolates `item.title`, `payload.clientName`, `clientCompany`, etc. straight into HTML with no `escapeHtml()`. Stored-XSS into the team's internal operations inbox from the CartDrawer form. Real.
|
||||||
|
|
||||||
|
### ✅ SEC-01 "Hardcoded secret fallback in proxy" — REAL but MITIGATED (MEDIUM)
|
||||||
|
`src/proxy.ts:13` still has `|| "FLUX_SUPER_SECRET_KEY_2026_ARCHITECTURE"`. Mitigated because signing (`session.ts`) throws without the env var, so tokens can't be forged in normal operation. Should still be removed (the verifier asymmetry is real).
|
||||||
|
|
||||||
|
### ✅ INFRA-03 "Secrets in git" — REAL (known)
|
||||||
|
OpenAI key + Gmail app password are in git history. Rotate; convert tracked `env` → `.env.example`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Corrected priority list — what actually matters
|
||||||
|
|
||||||
|
### 🔴 NOW (real, small, do first)
|
||||||
|
1. **Auth on `/api/assets` + `/api/branding/favicon`** (SEC-04) — add `getAdminSession()` guard at the top of each handler. *Effort: S*
|
||||||
|
2. **Create the `ClientUser` migration** (DB-01) — additive migration; unblocks the B2B portal. *Effort: S*
|
||||||
|
3. **Escape HTML in operations emails** (SEC-05) — reuse `escapeHtml` like consultation/route already does. *Effort: S*
|
||||||
|
4. **Remove the proxy secret fallback** (SEC-01) — throw if `SESSION_SECRET` missing, mirroring `session.ts`. *Effort: S*
|
||||||
|
5. **Whitelist chat context fields** (AI-01) — sanitize `context.section/activeTab` before injecting into the system prompt (prompt-injection hygiene). *Effort: S*
|
||||||
|
6. **Rotate OpenAI key + SMTP password; untrack `env`** (INFRA-03). *Effort: S — needs the client for the key*
|
||||||
|
|
||||||
|
### 🟡 NEXT (operational resilience — all real)
|
||||||
|
- Automated nightly **DB backups** (none today) + offsite. *M*
|
||||||
|
- **Container memory/CPU limits** + Nginx healthcheck in compose. *S*
|
||||||
|
- `buildSystemPrompt()` **try/catch + static fallback** so chat degrades instead of 500 if DB is down (TEST-02). *S*
|
||||||
|
- **Validate `OPENAI_API_KEY`** at startup + wrap `streamText` in try/catch (TEST-03). *S*
|
||||||
|
- **Idempotent consultation** submission (no orphan records on SMTP fail) (TEST-04). *M*
|
||||||
|
- **Monitoring/uptime** (UptimeRobot on `/api/health`) + log aggregation. *M*
|
||||||
|
- Index `GlobalNode.application`; cache the system prompt (PERF-01/03). *S*
|
||||||
|
|
||||||
|
### 🟢 LATER (polish/scale)
|
||||||
|
- Defense-in-depth `getSession()` inside HQ actions (SEC-03). a11y pass (focus trap, labels, skip link, reduced-motion). i18n: number formatting + translate error boundaries. SEO: image/video sitemaps, FAQ/VideoObject schema. Reduce `any`, split oversized files, adopt `log.*` everywhere. Jsonb migration for hot JSON fields. Lazy-load Three.js.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's genuinely strong (verified)
|
||||||
|
CSRF on public endpoints · magic-byte upload validation · JWT signing that throws on missing secret · **the middleware DOES protect HQ** · complete 5-locale parity · the AI translation glossary · hreflang/canonical/breadcrumb SEO · Nginx edge caching + the new canonical-host guard + scanner blocking · structured logger + safe-JSON helpers + escapeHtml exist (gap is adoption consistency) · clean idempotent migrations · bcrypt password hashing · well-designed 9-tool SPIN FluxAI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dimension scorecard (audit, with my adjustments)
|
||||||
|
| Dimension | Audit | Adjusted | Note |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Security | 3.5 | **6.5** | SEC-02 false positive removed; middleware protects HQ. Real gaps: SEC-04/05/01. |
|
||||||
|
| Performance | 6.0 | 6.0 | Solid baseline; prompt rebuilt per message. |
|
||||||
|
| Code quality | 5.5 | 5.5 | `any` usage, logging inconsistency. |
|
||||||
|
| Database | 4.5 | **5.5** | ClientUser migration missing (B2B), but not a dashboard crash. |
|
||||||
|
| FluxAI | 6.0 | 6.0 | Strong tools; add prompt-injection guard + history. |
|
||||||
|
| SEO | 6.5 | 6.5 | Good base; add media sitemaps + FAQ/Video schema. |
|
||||||
|
| i18n | 6.0 | 6.5 | Excellent infra; few hardcoded strings. |
|
||||||
|
| Infra | 4.0 | 4.5 | No backups/monitoring; secrets in git. The real weak spot. |
|
||||||
|
| Accessibility/UX | 4.5 | 5.0 | a11y gaps; dark mode now covered. |
|
||||||
|
| Testing/reliability | 3.0 | 3.5 | One suite; add API integration tests + degradation. |
|
||||||
|
|
||||||
|
**Adjusted overall: ~6.0/10** (audit said 4.8 — the false-positive critical dragged it down). The build is solid; the genuine priorities are a handful of small auth/data fixes plus operational resilience (backups + monitoring).
|
||||||
|
|
||||||
|
---
|
||||||
|
---
|
||||||
|
|
||||||
|
# Appendix — Full automated audit report (raw, uncorrected)
|
||||||
|
|
||||||
|
> The section below is the unedited 10-dimension report. Where it conflicts with the verification above (notably SEC-02), the verification above is authoritative.
|
||||||
|
|
||||||
|
# FLUX SRL — Consolidated Audit Report
|
||||||
|
|
||||||
|
**Project:** rf-flux.com (Flux SRL) — Next.js 16 · Prisma 7 · PostgreSQL · AI SDK 6 · 5 locales · Docker + Nginx on OVH
|
||||||
|
**Audit scope:** 10 dimensions, adversarially verified (each critical/high finding independently re-checked against source)
|
||||||
|
**Date:** 2026-06-09
|
||||||
|
**Auditor:** Lead consolidation pass
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Executive Summary
|
||||||
|
|
||||||
|
Flux SRL is a capably-built marketing + B2B platform with genuinely good bones — multi-stage Docker, CSRF on public endpoints, magic-byte upload validation, a structured logger, a working AI translation glossary, complete 5-locale message parity, and a recently-hardened Nginx (canonical-host guard, scanner blocking). But the audit surfaced a **cluster of authorization failures that, taken together, mean the entire HQ admin surface is currently unprotected**, plus operational gaps (no backups, no monitoring, secrets in git) that make this risky to run in production as-is.
|
||||||
|
|
||||||
|
The single most important finding: the auth middleware **does not execute at all** because the file exports a function named `proxy` instead of `middleware`. Every downstream auth assumption collapses from that one fact — and because the HQ server actions and the `/api/assets` + `/api/branding` routes have *no* internal session checks, they are reachable by anyone.
|
||||||
|
|
||||||
|
### Overall health: **4.8 / 10** (weighted toward the security + infra blockers)
|
||||||
|
|
||||||
|
### Dimension scorecard
|
||||||
|
|
||||||
|
| Dimension | Score | One-line verdict |
|
||||||
|
|---|---|---|
|
||||||
|
| Security | 3.5 | Middleware never runs; HQ actions + asset APIs fully unauthenticated. |
|
||||||
|
| Performance | 6.0 | Solid caching baseline; chat rebuilds prompt + 4 DB queries every message. |
|
||||||
|
| Code quality | 5.5 | 187 `any` usages, inconsistent logging, empty catch blocks; good helpers exist but under-used. |
|
||||||
|
| Database | 4.5 | `ClientUser` table referenced in code but never migrated — runtime crash risk. |
|
||||||
|
| FluxAI | 6.0 | Strong tool architecture; prompt injection via context, no history, plaintext message storage. |
|
||||||
|
| SEO | 6.5 | Good metadata + JSON-LD; no image/video sitemaps for a visual industrial site. |
|
||||||
|
| i18n | 6.0 | Excellent infra and parity; a few components ship hardcoded English. |
|
||||||
|
| Infra | 4.0 | No resource limits, no backups, no monitoring, secrets in tracked `env` file. |
|
||||||
|
| Accessibility/UX | 4.5 | No skip link, no Escape-to-close, no reduced-motion, broken Tailwind class. |
|
||||||
|
| Testing/reliability | 3.0 | One unit-test file; zero integration tests; multiple silent-fail paths. |
|
||||||
|
|
||||||
|
### Top 5 things to fix first
|
||||||
|
|
||||||
|
1. **Make the middleware actually run** (SEC-02). Rename/export `proxy` as `middleware` in `src/middleware.ts`. Until this lands, *nothing else in the auth layer matters* — the page-level guards are dead code.
|
||||||
|
2. **Add session checks inside HQ server actions and the asset/branding APIs** (SEC-03, SEC-04). Defense-in-depth: even with middleware fixed, these must self-verify. They are directly POST-able today.
|
||||||
|
3. **Rotate the leaked credentials and stop tracking `env`** (INFRA-03). The OpenAI key and Gmail app password are in git history. Rotate now; they're compromised regardless of what you do next.
|
||||||
|
4. **Create the missing `ClientUser` migration** (DB-01). Code calls `prisma.clientUser` in the dashboard and B2B auth; the table was never created. This is an active crash, not a hypothetical.
|
||||||
|
5. **Stand up backups + container resource limits** (INFRA-05, INFRA-01). No automated DB backup and no memory caps on an OVH single-VPS is a "lose everything on one bad day" setup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Critical & High Findings (confirmed / partially-confirmed only)
|
||||||
|
|
||||||
|
> Severities below use the **adjusted** verdict from verification. Refuted findings are excluded (see §6).
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
**SEC-02 — Auth middleware never executes (CRITICAL)**
|
||||||
|
`src/proxy.ts:17` — The file exports `async function proxy(...)` plus `config`, but Next.js 16 only invokes a file named `middleware.ts` exporting `middleware`. The `.next/server/middleware-manifest.json` is empty, confirming it was never compiled. A code comment even documents the rename intentionally. **Impact:** every `/hq-command/*` route has zero middleware protection; direct navigation and prefetch hit the dashboard with no auth gate. **Fix:** create `src/middleware.ts` with `export { proxy as middleware }` and re-export `config`; verify the matcher covers `/hq-command`. Then redeploy and confirm the manifest is populated.
|
||||||
|
|
||||||
|
**SEC-03 — HQ server actions lack session verification (CRITICAL)**
|
||||||
|
`src/app/hq-command/dashboard/inbox/actions.ts:11-188` — `getSignals()`, `getClients()`, `approveAccessRequest()`, `updateSignalStatus()`, `resolveAndCleanSignal()`, `deleteSignal()`, `resendSignalEmail()`, `deleteClient()` run raw Prisma mutations with no `getSession()` call. Marked `"use server"` but self-unauthenticated. **Impact:** these are invokable directly via POST (curl/fetch) — approve/delete B2B clients, mutate signals, trigger emails, all without a token. **Fix:** add a `requireAdminSession()` helper and call it at the top of every action: `const s = await getAdminSession(); if (!s) throw new Error('Unauthorized');`. Don't rely on the middleware alone.
|
||||||
|
|
||||||
|
**SEC-04 — `/api/assets` and `/api/branding/favicon` are unauthenticated (CRITICAL, raised from high)**
|
||||||
|
`src/app/api/assets/route.ts`, `src/app/api/branding/favicon/route.ts` — All methods (GET/POST/PUT/DELETE/PATCH) on `/api/assets` and POST on the favicon route have no session check. Path sanitization prevents traversal but not auth. **Impact:** anyone can enumerate CMS structure, upload SVG-with-JS into public scopes, delete branding/content, or regenerate favicons → defacement/XSS/downtime. **Fix:** at the top of each handler, `const s = await getAdminSession(); if (!s) return NextResponse.json({error:'Unauthorized'},{status:401});`.
|
||||||
|
|
||||||
|
**SEC-01 — Hardcoded session-secret fallback in proxy (HIGH, lowered from critical)**
|
||||||
|
`src/proxy.ts:13` — `process.env.SESSION_SECRET || "FLUX_SUPER_SECRET_KEY_2026_ARCHITECTURE"`, used for JWT *verification* at line 35. Verifier confirmed it's mitigated in practice: `session.ts`/`clientAuth.ts` *throw* if the secret is unset at signing time, so tokens can't be forged under normal operation. The danger is the asymmetry — if env loading ever fails at runtime, the verifier would accept tokens signed with the public string. **Fix:** remove the fallback; throw loudly if `SESSION_SECRET` is missing, mirroring `session.ts`.
|
||||||
|
|
||||||
|
**SEC-05 — Missing HTML escaping in operations email templates (HIGH)**
|
||||||
|
`src/app/actions/operations.ts:119-156` — `generateRichEmailHtml()` interpolates `item.title`, `item.sku`, `payload.clientName`, `clientCompany`, `clientEmail`, `clientPhone`, `message` straight into HTML. `escapeHtml()` exists in `src/lib/escapeHtml.ts` but isn't imported here. **Impact:** HTML/script injection into internal operations emails from the CartDrawer form. **Fix:** escape every user field before interpolation, matching the pattern already correct in `src/app/api/consultation/route.ts`.
|
||||||
|
|
||||||
|
### Database
|
||||||
|
|
||||||
|
**DB-01 — `ClientUser` table referenced but never migrated (CRITICAL)**
|
||||||
|
`prisma/schema.prisma:370-387` defines `ClientUser` and the `OperationsSignal.clientId` relation (lines 226-227), but **no migration creates the table** — not the init migration nor any of the 5 later ones. Code actively calls it: `src/app/hq-command/dashboard/page.tsx:67-68` (`prisma.clientUser.count()`), `src/app/actions/clientAuth.ts:25,30,50+` (findUnique/create/update). **Impact:** immediate runtime failure on any B2B auth or dashboard-count path; undeployable. **Fix:** generate a migration creating `ClientUser` (id, email unique, passwordHash, fullName, companyName, phone?, isApproved default false, lastLoginAt?, createdAt, updatedAt) and the FK from `OperationsSignal.clientId`. Run `prisma migrate dev` and verify against a fresh DB.
|
||||||
|
|
||||||
|
**DB-04 — JSON stored as `String` instead of `Jsonb` (HIGH)**
|
||||||
|
`prisma/schema.prisma` — 24+ JSON fields across 12 models (`translationsJson`, `galleryJson`, `sectionsJson`, `payloadJson`, etc.) are `String`. **Impact:** no DB-level JSON queries/indexing/validation; all parse/serialize is manual (and is the root cause of several CQ-03/CQ-04 parse-error findings). **Fix:** migrate query-hot fields first (`payloadJson` on AiEvent, `galleryJson`/`sectionsJson` on Application) to `Jsonb` via add-column → backfill → drop → rename.
|
||||||
|
|
||||||
|
**DB-05 — AI telemetry grows unbounded; no retention policy (HIGH)**
|
||||||
|
`prisma/schema.prisma:322-365` — `AiConversation`/`AiEvent` persist every chat indefinitely. No TTL, cleanup job, or purge anywhere in `src/`. Stores hashed IP + userAgent + full message text → GDPR Art. 5(1)(e) storage-limitation exposure. **Fix:** add a `retentionDays` setting (default 90), a daily cleanup deleting rows older than the window, and document it. Consider archival before delete.
|
||||||
|
|
||||||
|
**DB-03 — `GlobalNode.application` has no referential integrity (MEDIUM, lowered from high)**
|
||||||
|
`prisma/schema.prisma:40` — plain `String`, validated only as non-empty in `network/actions.ts:34`. Verifier downgraded: orphaned nodes persist and are simply excluded from filtered queries (deterministic, not data loss); UI dropdown prevents most bad input. **Fix:** validate `Application.findUnique({where:{slug}})` exists before create, or migrate to an `applicationId` FK.
|
||||||
|
|
||||||
|
**DB-02 — `PageContent` missing `createdAt` (MEDIUM, lowered from high)**
|
||||||
|
`prisma/schema.prisma:266` — has `updatedAt` only; every other model has both (except `SiteSetting`). **Impact:** no creation audit trail for page content. **Fix:** add `createdAt DateTime @default(now())`.
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
|
||||||
|
**INFRA-01 — No container resource limits (CRITICAL)**
|
||||||
|
`docker-compose.yml:41-116` — neither `app` (with `restart: always`) nor `nginx` nor postgres define memory/CPU limits. **Impact:** a runaway/leaking process consumes all VPS RAM and OOM-kills siblings → documented downtime. **Fix:** `app` mem_limit 1G (reservation 512M), nginx 256M/128M, cpus 1.0 each; load-test.
|
||||||
|
|
||||||
|
**INFRA-03 — Secrets committed in tracked `env` file (CRITICAL)**
|
||||||
|
`env:29-40` — real `OPENAI_API_KEY` (sk-proj-…), `SMTP_USER`, and a 16-char Gmail app password, tracked since first commit `fc24313`. `.gitignore` pattern `.env*` doesn't match the bare `env` filename. **Fix (in order):** (1) **rotate the OpenAI key and Gmail app password now** — they're already compromised; (2) `git rm --cached env`, add `env` to `.gitignore`; (3) commit `.env.example` with placeholders; (4) move to GitHub Secrets / a secrets manager. History scrub is optional but the rotation is not.
|
||||||
|
|
||||||
|
**INFRA-05 — No automated database backups (CRITICAL)**
|
||||||
|
`src/app/hq-command/dashboard/health/actions.ts:31-52` — backup is a manual HTTP export of unencrypted JSON covering only 10 of 16 tables (omits HeroSlide, SiteSetting, AiConversation, AiEvent, ClientUser, TeamMember). No cron, no offsite, no retention, no WAL/PITR. **Impact:** total loss if the PG volume corrupts. **Fix:** nightly `pg_dump | gzip` to offsite (S3, encrypted), 30-day retention; enable WAL archiving for PITR; document + test RTO/RPO monthly.
|
||||||
|
|
||||||
|
**INFRA-02 — Nginx has no healthcheck; unconditional `depends_on` (HIGH, lowered from critical)**
|
||||||
|
`docker-compose.yml:93-116` — nginx lacks a healthcheck and depends on `app` with no `condition`, while `app` correctly uses `condition: service_healthy` for postgres. Verifier softened the "deadlock" claim: it's a startup race, not a hang — nginx proxies to the unready app and returns 502/503 during the ~40s start window. **Fix:** add an nginx healthcheck (tcp:80) and `depends_on: app: condition: service_healthy`.
|
||||||
|
|
||||||
|
**INFRA-04 — Deploy script has no error handling or rollback (HIGH, lowered from critical)**
|
||||||
|
`.github/workflows/deploy.yml:28-51` — the health check at line 49 uses `curl -sf … || echo`, swallowing failure so `script_stop` is bypassed; migrations (line 42) run post-live with no error handling; no snapshot, no rollback. **Impact:** a failed migration or post-deploy crash leaves prod broken with manual-only recovery. **Fix:** make the health check fail the deploy (`curl -sf … || exit 1`, retried 3× with backoff); take a pre-deploy snapshot; restore on failure.
|
||||||
|
|
||||||
|
**INFRA-06 — No monitoring, alerting, or log aggregation (HIGH, lowered from critical)**
|
||||||
|
`docker-compose.yml` — structured logger + `/api/health` exist, but nothing ships metrics/logs to any platform (no Prometheus/Grafana/Sentry/Loki). **Impact:** blind operations; degradation invisible until users complain. **Fix:** expose `/metrics` (prom-client), ship structured logs to Loki/CloudWatch, alert on mem >80% / error-rate >1% / p99 >500ms.
|
||||||
|
|
||||||
|
**INFRA-07 — Missing OCSP stapling (HIGH)**
|
||||||
|
`nginx/conf.d/flux.conf:68-82` — no `ssl_stapling`. **Impact:** slower TLS handshakes; visitor IPs leak to Let's Encrypt. **Fix:** add `ssl_stapling on; ssl_stapling_verify on; ssl_trusted_certificate …/chain.pem; resolver 1.1.1.1 8.8.8.8 valid=300s;`.
|
||||||
|
|
||||||
|
**INFRA-11 — CSP allows `unsafe-inline` + `unsafe-eval` (MEDIUM, lowered from high)**
|
||||||
|
`nginx/conf.d/flux.conf:87` — present in `script-src` for Next.js hydration. Verifier noted strong compensating controls (Zod validation, `escapeHtml`, magic-byte checks, minimal `dangerouslySetInnerHTML`), so it's real technical debt rather than an active hole. **Fix:** move to nonce-based CSP; run `Report-Only` first.
|
||||||
|
|
||||||
|
**INFRA-08 — Nginx cache-poisoning surface via hidden `Set-Cookie` (MEDIUM, lowered from high)**
|
||||||
|
`nginx/conf.d/flux.conf:282-286` — `proxy_ignore_headers`/`proxy_hide_header Set-Cookie` on public pages. Verifier refuted the "session cookies lost" and "sensitive header disclosure" parts (authenticated requests bypass cache via `$cookie_flux_session`); the residual risk is poisoning public pages with a non-sensitive injected cookie. **Fix:** refine cache-bypass logic rather than blanket-hiding `Set-Cookie`.
|
||||||
|
|
||||||
|
**INFRA-09 — Large `client_max_body_size` without explicit body timeout (MEDIUM, lowered from high)**
|
||||||
|
`nginx/conf.d/flux.conf:71,135,148` — 500M limit; global block lacks `client_body_timeout`. Verifier softened: nginx's 60s default applies and upload endpoints set proxy timeouts. **Fix:** add `client_body_timeout 60s;` to the http block; reduce 500M where not genuinely needed.
|
||||||
|
|
||||||
|
**INFRA-10 — No logrotate (MEDIUM, lowered from high)**
|
||||||
|
`nginx/nginx.conf:39` — logs unbounded, but verifier noted `/var/log/nginx` isn't a mounted volume, so logs live in ephemeral container storage (purged on restart). Residual risk: log loss on restart and container-layer growth on long uptimes. **Fix:** add logrotate (daily, compress, keep 7, SIGHUP).
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
**PERF-01 — System prompt rebuilt with 4 DB queries on every chat request (HIGH)**
|
||||||
|
`src/app/api/chat/route.ts:42-281` — `buildSystemPrompt()` runs `application.findMany` + 2× `globalNode.count` + `sparePart.count` (Promise.all) on every POST, with no caching layer (the `promptCacheKey` is a no-op). **Impact:** 4 round-trips per message; 400+/min at 100 concurrent users. **Fix:** cache the built prompt in-memory with a 30–60s TTL, invalidated on CMS write.
|
||||||
|
|
||||||
|
**PERF-03 — Missing index on `GlobalNode.application` (HIGH)**
|
||||||
|
`prisma/schema.prisma` — queries filter on `(application, isActive)`, `(nodeType, isActive, application)`, and `(application, nodeType, isActive)` (applications page 136-142; chat 503, 621) but there's no `application` index. **Impact:** full table scans on the synchronous FluxAI search path. **Fix:** add `@@index([application])`, `@@index([application, isActive])`, `@@index([application, nodeType])`; migrate.
|
||||||
|
|
||||||
|
**PERF-02 — Synchronous `fs.readdirSync` in SSR paths (MEDIUM, lowered from high)**
|
||||||
|
`src/app/[locale]/page.tsx:102`, `src/app/[locale]/applications/[slug]/page.tsx:32` — real blocking calls, but verifier downgraded: they're fallback paths inside try/catch on small dirs, hit during 60s ISR regen, not per-request. **Fix:** switch to `fs.promises.readdir`, or remove the fallback once HeroSlide/CMS migration is complete.
|
||||||
|
|
||||||
|
### FluxAI
|
||||||
|
|
||||||
|
**AI-01 — Prompt injection via unvalidated context fields (HIGH)**
|
||||||
|
`src/app/api/chat/route.ts:215-216` — `context.section` and `context.activeTab` come from `req.json()` (TS types only, no runtime validation) and are interpolated into `contextNote`, concatenated to the system prompt at line 287. A manipulated frontend store can inject instructions overriding FluxAI's personality/tool limits. **Fix:** whitelist `context.section`/`activeTab` against the known section set; reject anything else (Zod).
|
||||||
|
|
||||||
|
**AI-03 — User message text stored unencrypted (HIGH)**
|
||||||
|
`src/app/api/chat/route.ts:268` — full user text (≤8000 chars) persisted as plaintext in `AiEvent.payloadJson` (`String`). Customer questions about volumes/processes sit in cleartext. **Impact:** breach exposes competitive intel; GDPR Art. 32 (encryption at rest). **Fix:** encrypt sensitive payloads (pgcrypto/KMS) or store only industry label + tool names; pair with the DB-05 retention policy.
|
||||||
|
|
||||||
|
**AI-02 — No conversation history / resume (HIGH)**
|
||||||
|
`src/components/ai/SilentObserver.tsx:76-78` — `useChat` initializes fresh with no `initialMessages`; backend persists everything but the UI never fetches it. **Impact:** multi-turn B2B sales context lost on refresh, undermining the funnel-tracking investment. **Fix:** add `/api/chat/history?sessionId`, hydrate `initialMessages` on mount, offer "continue previous conversation?".
|
||||||
|
|
||||||
|
**AI-06 — Telemetry write errors silently swallowed (HIGH)**
|
||||||
|
`src/app/api/chat/route.ts:272-274, 370-371` — telemetry writes are wrapped in try/catch with only `log.warn()`; the `onFinish` writes can fail after the response streamed, losing conversation records. **Impact:** undercounted funnel analytics. **Fix:** circuit-breaker + alert on >5 failures/hour; buffer-and-retry unsent events.
|
||||||
|
|
||||||
|
### SEO
|
||||||
|
|
||||||
|
**SEO-02 — No image or video sitemaps (HIGH)**
|
||||||
|
`src/app/sitemap.ts:1-106` — only page URLs. Rich media (news `coverImage`/`galleryJson`, application galleries, heritage `mediaUrl` videos) is invisible to Google Image/Video Search; `robots.ts` declares only `/sitemap.xml`. **Fix:** add `sitemap-images.xml` (`<image:image>` per article cover/gallery + app hero) and `sitemap-videos.xml` (heritage videos with title/description/duration); declare both in robots.
|
||||||
|
|
||||||
|
### i18n
|
||||||
|
|
||||||
|
**I18N-01 — Hardcoded English in EnergySavingsCalculator (HIGH)**
|
||||||
|
`src/components/ai/EnergySavingsCalculator.tsx:61,118,146-149,156,164-178` — 13+ literal strings ("Annual Savings", "CO2 Reduced", "Payback", "Request Detailed Engineering Study", …); component never imports `useTranslations`, and no `EnergySavingsCalculator` namespace exists in any of the 5 locale files. **Impact:** IT/ES/DE/VEC users see English in a customer-facing calculator. **Fix:** add the namespace to `en.json`, translate to all 4 locales, wire `useTranslations("EnergySavingsCalculator")`.
|
||||||
|
|
||||||
|
**I18N-02 — Hardcoded alert in CartDrawer (HIGH)**
|
||||||
|
`src/components/layout/CartDrawer.tsx:43` — `alert("You must accept the privacy policy.")` bypasses the otherwise-used translation system; a modal blocker shown in English to all locales. **Fix:** move to a translated key and prefer the Toast component over native `alert()`.
|
||||||
|
|
||||||
|
### Accessibility / UX
|
||||||
|
|
||||||
|
**A11Y-02 — Broken Tailwind class breaks nav styling (HIGH)**
|
||||||
|
`src/components/layout/NavBar.tsx:190` — `"text-[#86868B hover:text-[#1D1D1F]"` is missing a `]`. **Impact:** the color class doesn't apply to inactive nav links in light mode. **Fix:** `"text-[#86868B] hover:text-[#1D1D1F]"`.
|
||||||
|
|
||||||
|
**A11Y-03 — Icon-only buttons missing `aria-label` (HIGH)**
|
||||||
|
`src/components/layout/NavBar.tsx:229,278,294` — theme toggle, cart, mobile-menu announce only "button". **Fix:** add descriptive `aria-label`s (e.g., `aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}`).
|
||||||
|
|
||||||
|
**A11Y-04 — Modal can't be closed with Escape (HIGH)**
|
||||||
|
`src/components/ui/CaseStudyModal.tsx:232-237` — no keydown handler; only a click-able close button (WCAG 2.1.1). **Fix:** `useEffect` keydown listener → `if (e.key==='Escape') onClose()`.
|
||||||
|
|
||||||
|
**A11Y-05 — No skip-to-main-content link (HIGH)**
|
||||||
|
`src/app/[locale]/layout.tsx:145-204` — ~13 interactive elements before main; main div has no `id` and isn't a `<main>` (WCAG 2.4.1). **Fix:** add `<a href="#main-content" class="sr-only focus:not-sr-only">Skip to main content</a>` and an `id`/`<main>` target.
|
||||||
|
|
||||||
|
**A11Y-08 — No `prefers-reduced-motion` handling (HIGH)**
|
||||||
|
`src/app/globals.css` — 260+ framer-motion animations, zero reduced-motion checks. **Impact:** vestibular-disorder risk. **Fix:** add `@media (prefers-reduced-motion: reduce){*{animation:none!important;transition:none!important}}` and gate heavy motion via `useReducedMotion()`.
|
||||||
|
|
||||||
|
**A11Y-09 — Hero videos lack captions (MEDIUM, lowered from high)**
|
||||||
|
`src/components/sections/HeroReel.tsx:71-83` — no `<track>`; verifier noted videos are muted/decorative with overlaid text, softening impact (WCAG 1.2.2). **Fix:** add captions where videos carry meaning, or mark decorative explicitly.
|
||||||
|
|
||||||
|
**A11Y-01 — Empty `alt` on cart product images (MEDIUM, lowered from high)**
|
||||||
|
`src/components/layout/CartDrawer.tsx:148` — `alt=""`; verifier noted adjacent visible title+SKU text mitigates. **Fix:** `alt={item.title}` for defense-in-depth.
|
||||||
|
|
||||||
|
### Code quality
|
||||||
|
|
||||||
|
**CQ-01 — 187 `any` usages across 48 files (HIGH)**
|
||||||
|
Concentrated in `ApplicationClient.tsx` (1208 lines), `chat/route.ts` (920 lines), `SilentObserver.tsx` (22 instances), despite `strict: true`. **Fix:** use existing `cms.ts` types (`AppFull`, `NodeFull`); add a `model-viewer.d.ts` for the web-component casts; chip away by file.
|
||||||
|
|
||||||
|
**CQ-02 — Inconsistent logging: 50 `console.error` vs 9 `log.error` (HIGH)**
|
||||||
|
A structured JSON logger exists (`src/lib/logger.ts`) but is bypassed in `sitemap.ts:75,102`, `heritage/page.tsx:210`, `mailer.ts:96`, `imageOptimizer.ts:117`, etc. **Impact:** unstructured output breaks Loki/CloudWatch parsing. **Fix:** replace `console.*` with `log.*` in server code; add a lint rule / pre-commit hook.
|
||||||
|
|
||||||
|
**CQ-04 — Empty catch blocks suppress JSON parse errors (HIGH)**
|
||||||
|
`ApplicationClient.tsx:618,826-829`, `news/[slug]/page.tsx:259`, `parts/page.tsx:55` — silent `catch(e){}` on dimensions/media/specs parsing. **Fix:** at minimum `catch(e){ log.warn('parse_failed', e) }`.
|
||||||
|
|
||||||
|
**CQ-03 — Unguarded `JSON.parse` for sections/advantages (MEDIUM, lowered from high)**
|
||||||
|
`ApplicationClient.tsx:1026-1027` — `data.sectionsJson`/`advantagesJson` parsed with no try/catch (verifier confirmed *this* pair; refuted the claims about lines 600/609/CaseStudyModal which do have guards). **Fix:** use the existing `parseJsonField` helper from `cms.ts`.
|
||||||
|
|
||||||
|
**CQ-07 — `any`-typed Prisma access in chat (MEDIUM, lowered from high)**
|
||||||
|
`chat/route.ts:413-414` — `(app: any)` access with inconsistent null-handling; verifier refuted the line-450 "undefined .score" claim (it's always initialized). Real issue is type-safety, not a live crash. **Fix:** use Prisma `select` with explicit types instead of `any`.
|
||||||
|
|
||||||
|
### Testing / reliability
|
||||||
|
|
||||||
|
**TEST-01 — No integration/e2e tests for critical API routes (HIGH)**
|
||||||
|
Only `tests/ai/golden.test.mjs` (17 unit tests). Zero coverage of `/api/consultation`, `/api/chat`, `/api/health`, `/api/public-upload`. **Fix:** add integration tests (Node test runner) with mocked Prisma/nodemailer; aim >80% on `src/app/api`.
|
||||||
|
|
||||||
|
**TEST-02 — `buildSystemPrompt()` has no DB-failure handling (HIGH)**
|
||||||
|
`chat/route.ts:42-72` — 4 parallel Prisma queries, no try/catch; called at line 281 before `streamText()`. If Postgres is down, the whole chat 500s instead of degrading. Asymmetric with the telemetry try/catch. **Fix:** wrap in try/catch → fall back to a static `DEFAULT_SYSTEM_PROMPT` (keeps personality/tools, omits dynamic counts).
|
||||||
|
|
||||||
|
**TEST-03 — OpenAI key never validated at startup or in health check (HIGH)**
|
||||||
|
`chat/route.ts`, `health/route.ts` — `openai('gpt-4o')` is called with no key validation; `/api/health` only does `SELECT 1`. **Impact:** invalid/missing key surfaces mid-stream after headers are sent. **Fix:** validate `OPENAI_API_KEY` at startup; wrap `streamText()` in try/catch returning a structured error (with `retryAfterSec` on 429).
|
||||||
|
|
||||||
|
**TEST-04 — Consultation email isn't idempotent; orphaned records on SMTP failure (HIGH)**
|
||||||
|
`api/consultation/route.ts:80-155` — signal created (line 105) before email send (line 141); on SMTP failure the client gets a 500 with no `ticketId`, retries duplicate, and the fallback route isn't re-attempted. **Fix:** add an idempotency key, return 202 with `ticketId` + `emailError` on send failure, and add a background retry with backoff.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Medium / Low / Info
|
||||||
|
|
||||||
|
**Security**
|
||||||
|
- SEC-06 (med) — `operations.ts:129-132`: file links built from unvalidated `fileUrl` → validate it starts with `/`.
|
||||||
|
- SEC-07 (low, downgraded) — `parts/page.tsx`: client-side gate not SSR redirect; data isn't leaked → optional `redirect()` for cleanliness.
|
||||||
|
- SEC-08 (med) — HQ actions have no rate limiting → rate-limit by admin id + action.
|
||||||
|
- SEC-09 (low) — `rateLimit.ts:144`: trusts `X-Forwarded-For` unvalidated → validate single-IP / trust only Nginx.
|
||||||
|
- SEC-10 (low) — `chat/route.ts:225`: `SESSION_SECRET` reused as telemetry HMAC salt → use a separate `VISITOR_HASH_SECRET`.
|
||||||
|
|
||||||
|
**Database**
|
||||||
|
- DB-06 (med) — `schema.prisma:230`: `AiConversation→OperationsSignal` no `onDelete` → orphans; document or cascade.
|
||||||
|
- DB-07 (med) — add `@@index([type, status, createdAt(sort: Desc)])` on OperationsSignal.
|
||||||
|
- DB-08 (low) — Application.order non-unique → validate uniqueness per `isActive` group.
|
||||||
|
- DB-09 (low) — NewsArticle.publishedAt has no future-date guard → filter `publishedAt <= now()`.
|
||||||
|
|
||||||
|
**Performance**
|
||||||
|
- PERF-04 (med) — `chat/route.ts:519`: JS keyword filter post-query → use Prisma `contains`/`mode:'insensitive'`.
|
||||||
|
- PERF-05 (med) — lazy-load `GlobalOperations` (Three.js ~300KB) via `dynamic({ssr:false})`.
|
||||||
|
- PERF-06 (med) — 48 `use client` components; push pure-display ones back to server components.
|
||||||
|
- PERF-07 / INFRA-12 (med) — Prisma pool `max=10` hardcoded → make `DB_POOL_MAX` env-configurable (20–30 prod).
|
||||||
|
- PERF-08/10 (low) — ISR `revalidate=60` × 40 renders/min → consider 300–600s or on-demand revalidation.
|
||||||
|
- PERF-09 (med) — large Docker build context → audit with `--progress=plain`, exclude heavy media.
|
||||||
|
- INFRA-13 (med) — Nginx upstream `keepalive=32` → raise to 128–256.
|
||||||
|
|
||||||
|
**FluxAI**
|
||||||
|
- AI-04 (med) — no golden eval for tool selection → build 20–30 query eval set, run monthly.
|
||||||
|
- AI-05 (low) — `stepCountIs(5)` may truncate → bump to 7–8, monitor traces.
|
||||||
|
- AI-07 (low) — no per-tool cost tracking → log `(conversationId, toolName, tokensIn/Out)`.
|
||||||
|
- AI-08 (med) — `SilentObserver.tsx:232,237`: tool failures silently omitted → render "no results" card.
|
||||||
|
- AI-09 (low) — duplicate of PERF-01 (prompt caching).
|
||||||
|
- AI-10 (med) — `useChat` has no `onError` → add error state + Retry button.
|
||||||
|
|
||||||
|
**SEO**
|
||||||
|
- SEO-01 (med) — OG images lack width/height/type → add `{width:1200,height:630,type:'image/jpeg'}`.
|
||||||
|
- SEO-03 (med) — no FAQPage schema → add `faqPageSchema()`.
|
||||||
|
- SEO-04 (med) — no VideoObject schema on heritage videos → add `videoObjectSchema()`.
|
||||||
|
- SEO-05/06 (low) — Product schema lacks AggregateRating/Offer; Article lacks keywords/commentCount.
|
||||||
|
- SEO-07 (med) — markdown parser allows empty `alt` → fallback `alt || 'Article image'` + CMS validation.
|
||||||
|
- SEO-08 (low) — add breadcrumb schema to home/team/heritage/news-hub.
|
||||||
|
- SEO-09 (low) — LocalBusiness hours lack timezone → add `Europe/Rome`.
|
||||||
|
- SEO-10 (med) — GlobalNode case studies lack structured data → add `caseStudySchema()`.
|
||||||
|
- SEO-11 (low) — add Twitter `creator`/`site` handles.
|
||||||
|
- SEO-12 (info) — no Core Web Vitals monitoring → add `web-vitals` + dashboard.
|
||||||
|
|
||||||
|
**i18n**
|
||||||
|
- I18N-03 (med) — error boundaries hardcoded English → translate `[locale]/error.tsx` at minimum.
|
||||||
|
- I18N-04 (med) — `formatNumber()` calls `toLocaleString()` without locale → pass active locale / `Intl.NumberFormat`.
|
||||||
|
- I18N-05 (med) — register `EnergySavingsCalculator` namespace in all 5 locale files (pairs with I18N-01).
|
||||||
|
- I18N-08 (low) — `getLocalizedData` fallback is English-only → optional locale-chain fallback.
|
||||||
|
|
||||||
|
**Accessibility / UX**
|
||||||
|
- A11Y-06 (med) — `ConsultationScheduler.tsx:394`: errors lack `aria-live`/`role=alert`.
|
||||||
|
- A11Y-07 (med) — form inputs lack `<label htmlFor>` associations.
|
||||||
|
- A11Y-10 (med) — 71 buttons default to `type=submit` → add `type=button` to non-submit buttons.
|
||||||
|
- A11Y-11 (med) — AI chat modal has no focus trap.
|
||||||
|
- A11Y-12 (med) — language dropdown lacks arrow-key navigation.
|
||||||
|
- A11Y-13 (med) — opacity-reduced nav text may fail 4.5:1 contrast → test + use solid colors.
|
||||||
|
- A11Y-14 (med) — success message lacks `role=status`/`aria-live`.
|
||||||
|
- A11Y-15 (low) — verify dark-mode coverage on newer pages + HQ panel.
|
||||||
|
|
||||||
|
**Code quality**
|
||||||
|
- CQ-05 (med) — oversized files (ApplicationClient 1208, chat/route 920, AssetBucketBrowser 874) → extract sub-components/tools.
|
||||||
|
- CQ-06 (med) — model-viewer `as any` casts → proper `.d.ts` type.
|
||||||
|
- CQ-08 (med) — inconsistent error-handling pattern across layers → standardize log-then-return/throw.
|
||||||
|
- CQ-09 (low) — `privacy/page.tsx:12`: hardcoded `privacy@rf-flux.com` (TODO unconfirmed) → env/SiteSettings + confirm with FLUX legal.
|
||||||
|
- CQ-10 (low) — repeated `as unknown as` casts on AI SDK responses → define interfaces.
|
||||||
|
- CQ-11 (low) — `i18nHelper.ts:27`: `console.error` → `log.error`.
|
||||||
|
|
||||||
|
**Infra / testing**
|
||||||
|
- INFRA-14 (med) — backup/restore lacks HMAC integrity → sign exports, verify on restore.
|
||||||
|
- INFRA-15 (med) — deploy doesn't verify commit signatures → `git verify-commit HEAD`.
|
||||||
|
- TEST-05 (med) — no i18n parity/fallback tests.
|
||||||
|
- TEST-06 (med) — error boundaries log to console, not the structured logger.
|
||||||
|
- TEST-07 (med) — server-component DB errors bubble ungracefully → per-query try/catch + degraded UI.
|
||||||
|
- TEST-08 (med) — no retry/backoff for transient email/Prisma/OpenAI failures.
|
||||||
|
- TEST-09 (med) — in-memory rate limit multiplies across replicas → require Redis for multi-instance.
|
||||||
|
- TEST-10 (med) — health check doesn't verify migrations/SMTP/OpenAI/env.
|
||||||
|
- TEST-11/12/13 (low) — telemetry truncation unlogged; no polyglot-file upload tests; `restoreDatabase()` partial-failure handling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. What's Already Strong
|
||||||
|
|
||||||
|
Credit where due — several things are done well and should be preserved:
|
||||||
|
|
||||||
|
- **Public-endpoint security baseline.** CSRF is correctly implemented on public endpoints, rate limiting covers chat, file uploads use magic-byte validation, and the consultation route already escapes HTML correctly (the model SEC-05 should copy). Recent commits added a **canonical-host guard and scanner-probe blocking** in Nginx — good hardening.
|
||||||
|
- **JWT signing discipline.** `session.ts`/`clientAuth.ts` correctly *throw* when `SESSION_SECRET` is missing rather than falling back — this is what saved SEC-01 from being critical.
|
||||||
|
- **i18n infrastructure.** Complete top-level key parity across all 5 locales (18 sections), correct next-intl integration, preserved `{count}`/`{app}` placeholders, and a well-built **AI translation glossary** that masks/restores protected brand/technical terms (FLUX, Radio Frequency, solid-state, RF, MHz/kHz/GHz, kW/kWh/MW). Pluralization (`componentFound`/`componentsFound`) is handled correctly.
|
||||||
|
- **SEO foundations.** Correct hreflang alternates, canonical URLs, breadcrumb JSON-LD on detail pages, and core schemas (Organization, LocalBusiness, WebSite, Article, Product). robots.txt disallow rules are correct; sitemap covers all public routes.
|
||||||
|
- **Caching & DB pooling.** Nginx edge caching, ISR, image optimization, and a real Prisma connection pool are all in place — a solid performance baseline.
|
||||||
|
- **Architecture awareness.** A structured JSON logger, safe-JSON helpers (`parseJsonField`), `escapeHtml`, and proper `cms.ts` types all exist — the gaps are *adoption consistency*, not missing infrastructure. bcrypt is used for password hashing; migrations are clean and idempotent (`IF NOT EXISTS` guards). FluxAI's 9-tool SPIN-funnel architecture is well-designed. Semantic HTML (`main`/`nav`/`header`/`footer`) and dark-mode coverage are largely in place.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Prioritized Remediation Roadmap
|
||||||
|
|
||||||
|
### NOW — blockers; do before any further production traffic
|
||||||
|
|
||||||
|
| Item | Finding | Effort |
|
||||||
|
|---|---|---|
|
||||||
|
| Rename/export middleware so auth actually runs | SEC-02 | **S** |
|
||||||
|
| Add session checks inside HQ server actions | SEC-03 | **M** |
|
||||||
|
| Add auth to `/api/assets` + `/api/branding` | SEC-04 | **S** |
|
||||||
|
| Rotate leaked OpenAI key + Gmail password; untrack `env` | INFRA-03 | **S** |
|
||||||
|
| Create the missing `ClientUser` migration | DB-01 | **S** |
|
||||||
|
| Escape HTML in operations email templates | SEC-05 | **S** |
|
||||||
|
| Whitelist chat context fields (prompt injection) | AI-01 | **S** |
|
||||||
|
| Remove the proxy session-secret fallback | SEC-01 | **S** |
|
||||||
|
|
||||||
|
### NEXT — operational resilience + data integrity
|
||||||
|
|
||||||
|
| Item | Finding | Effort |
|
||||||
|
|---|---|---|
|
||||||
|
| Automated nightly offsite encrypted backups + WAL/PITR | INFRA-05 | **M** |
|
||||||
|
| Container memory/CPU limits | INFRA-01 | **S** |
|
||||||
|
| Nginx healthcheck + `condition: service_healthy` | INFRA-02 | **S** |
|
||||||
|
| Deploy: failing health check + snapshot/rollback | INFRA-04 | **M** |
|
||||||
|
| AI telemetry retention policy + cleanup job | DB-05 | **M** |
|
||||||
|
| Encrypt / minimize stored chat message text | AI-03 | **M** |
|
||||||
|
| `buildSystemPrompt()` try/catch + fallback prompt | TEST-02 | **S** |
|
||||||
|
| Validate OpenAI key at startup + wrap `streamText` | TEST-03 | **S** |
|
||||||
|
| Idempotent consultation submission | TEST-04 | **M** |
|
||||||
|
| Cache system prompt (TTL) | PERF-01 | **S** |
|
||||||
|
| Add `GlobalNode.application` indexes | PERF-03 | **S** |
|
||||||
|
| Integration tests for the 4 critical API routes | TEST-01 | **L** |
|
||||||
|
| Monitoring + log aggregation + alerts | INFRA-06 | **M** |
|
||||||
|
| Fix `aria-label`s, skip link, Escape-to-close, reduced-motion, broken nav class | A11Y-02/03/04/05/08 | **M** |
|
||||||
|
| i18n: EnergySavingsCalculator + CartDrawer alert | I18N-01/02/05 | **M** |
|
||||||
|
|
||||||
|
### LATER — hardening, polish, scale
|
||||||
|
|
||||||
|
| Item | Finding | Effort |
|
||||||
|
|---|---|---|
|
||||||
|
| Migrate hot JSON `String` fields → `Jsonb` | DB-04 | **L** |
|
||||||
|
| Nonce-based CSP (drop unsafe-inline/eval) | INFRA-11 | **M** |
|
||||||
|
| OCSP stapling, logrotate, body timeout, keepalive tuning | INFRA-07/09/10/13 | **S** |
|
||||||
|
| Image + video sitemaps; VideoObject/FAQ/CaseStudy schema | SEO-02/03/04/10 | **M** |
|
||||||
|
| Reduce `any` usage; extract oversized files; standardize logging | CQ-01/02/04/05 | **L** |
|
||||||
|
| Lazy-load Three.js; trim `use client`; configurable pool | PERF-05/06/07 | **M** |
|
||||||
|
| Conversation history/resume; tool eval set; useChat onError | AI-02/04/10 | **M** |
|
||||||
|
| Remaining a11y (focus trap, labels, contrast, button types) | A11Y-06/07/10/11/12/13/14 | **M** |
|
||||||
|
| Locale-aware number formatting; translate error boundaries | I18N-03/04 | **S** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. False Positives Considered (refuted / materially downgraded)
|
||||||
|
|
||||||
|
These were checked and either refuted or substantially softened during verification — listed so you know they were examined, not missed:
|
||||||
|
|
||||||
|
- **SEC-07 (B2B `/parts` not redirecting)** — *refuted as a security issue.* The page returns 200 but never exposes parts data (empty array), and shows a proper "Access Restricted" locked-state UI. A style/pattern preference, not a leak. Downgraded high → low.
|
||||||
|
- **CQ-07 line-450 "undefined `.score`"** — *refuted.* The `scored` array always initializes `score` (line 415, returned 443); `.score` can't be undefined there. The surrounding `any`-typing concern remains valid; the crash claim does not.
|
||||||
|
- **CQ-03 (lines 600/609 + CaseStudyModal "silent failure")** — *partially refuted.* Lines 600/609 are inside a try/catch with `.w/.h/.d` validation, and CaseStudyModal:189-197 has real try/catch. Only the `sectionsJson`/`advantagesJson` parse at lines 1026-1027 is genuinely unguarded.
|
||||||
|
- **INFRA-08 (cache poisoning)** — *partially refuted.* "Session cookies lost for users" and "sensitive header disclosure" are false (authenticated requests bypass cache via `$cookie_flux_session`). Residual risk is limited to poisoning public pages with non-sensitive cookies.
|
||||||
|
- **INFRA-02 / INFRA-04 (nginx + deploy)** — *softened, not refuted.* The "deadlock"/"no-recovery" framing was overstated (startup race + manual recovery, not hangs); both remain real high-severity gaps.
|
||||||
|
- **INFRA-09 / INFRA-10 (body timeout / logrotate)** — *softened.* nginx's 60s default timeout and ephemeral (unmounted) log storage reduce the blast radius; both downgraded high → medium.
|
||||||
|
- **DB-02 / DB-03 / PERF-02 / A11Y-01 / A11Y-09 / INFRA-11** — *confirmed but downgraded* (see §2) where verification found mitigating context (other models' parity, dropdown-constrained input, ISR-only timing, adjacent visible text, muted decorative video, compensating input-validation controls).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Bottom line, David: the build quality is real, but the auth layer is currently a no-op and the platform has no backups, no monitoring, and live secrets in git. The "NOW" block is mostly small, surgical changes — SEC-02 alone is a one-file fix that re-activates a security layer you already wrote. Land that block first, then the operational resilience in "NEXT," and this moves from "risky to run" to "production-ready" quickly.*
|
||||||
@@ -0,0 +1,639 @@
|
|||||||
|
# FLUX SRL — Website Engineering Report
|
||||||
|
|
||||||
|
**Project:** rf-flux.com platform
|
||||||
|
**Iteration:** Security hardening + FluxAI conversation analytics
|
||||||
|
**Date:** May 2026
|
||||||
|
**Prepared by:** DreamHouse Studios
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
This iteration delivers two parallel outcomes for `rf-flux.com`:
|
||||||
|
|
||||||
|
1. **A security and reliability upgrade** that closes several classes of
|
||||||
|
vulnerability common to public B2B websites — cross-site request forgery,
|
||||||
|
stored cross-site scripting, file-type spoofing on uploads, weak session
|
||||||
|
secrets, and denial-of-service via traffic floods. The site now meets the
|
||||||
|
baseline expected of an enterprise property.
|
||||||
|
|
||||||
|
2. **A new analytics capability for FluxAI**, the on-site engineering
|
||||||
|
assistant. Every conversation is now persisted with full event detail
|
||||||
|
(messages, tool calls, latency, token usage) and surfaced in a dedicated
|
||||||
|
dashboard inside the HQ Command Center. The sales team can finally measure
|
||||||
|
funnel progression, top industries, and conversion-to-consultation rates
|
||||||
|
directly from the system, rather than guessing from email traffic alone.
|
||||||
|
|
||||||
|
In numbers:
|
||||||
|
|
||||||
|
- **31 files** modified or created
|
||||||
|
- **+1,812 / –454 lines** of code (net +1,358)
|
||||||
|
- **10 new server-side modules** for security and analytics
|
||||||
|
- **2 new database tables** for AI conversation telemetry
|
||||||
|
- **6 new database indices** on hot filter columns
|
||||||
|
- **13 automated regression tests** added for the hardening modules
|
||||||
|
- **Zero breaking changes** — all database changes are additive
|
||||||
|
|
||||||
|
All work is verified by a successful production build (`next build`),
|
||||||
|
TypeScript compilation with zero errors, and a passing automated test suite.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Security Hardening
|
||||||
|
|
||||||
|
### 1.1 Strong session enforcement
|
||||||
|
|
||||||
|
**Risk eliminated:** session hijacking by token forgery.
|
||||||
|
|
||||||
|
The previous code allowed the server to start with a hard-coded fallback
|
||||||
|
secret (`"FLUX_SUPER_SECRET_KEY_2026_ARCHITECTURE"`) if the `SESSION_SECRET`
|
||||||
|
environment variable failed to load. Because that fallback string was visible
|
||||||
|
in the source tree, any attacker who read the public repository could mint
|
||||||
|
valid 7-day admin JWTs and walk into the HQ Command Center as any user.
|
||||||
|
|
||||||
|
The application now refuses to start without a `SESSION_SECRET` of at least
|
||||||
|
32 characters. A weak or missing value is a fatal error, surfaced at boot
|
||||||
|
time rather than silently accepted. The same protection is applied to the
|
||||||
|
B2B client portal authentication path (`clientAuth.ts`).
|
||||||
|
|
||||||
|
**Operational note:** the production VPS must have a strong secret in its
|
||||||
|
`.env` file before the next deploy. The recommended generator is
|
||||||
|
`openssl rand -base64 48`.
|
||||||
|
|
||||||
|
### 1.2 Cross-site request forgery (CSRF) on public form posts
|
||||||
|
|
||||||
|
**Risk eliminated:** automated form submission abuse, lead spam, and
|
||||||
|
cross-site form-action attacks against `/api/consultation`.
|
||||||
|
|
||||||
|
The consultation form endpoint was previously accepting any POST request
|
||||||
|
with a valid JSON body. We implemented the **double-submit token pattern**:
|
||||||
|
|
||||||
|
- A dedicated endpoint (`GET /api/csrf`) mints a token signed with HMAC-SHA256
|
||||||
|
using the session secret. The token is delivered both as a cookie and in
|
||||||
|
the JSON response body. It expires after one hour.
|
||||||
|
- The form's submission code copies the token into the `X-CSRF-Token` header.
|
||||||
|
- The consultation endpoint verifies that cookie and header match and that
|
||||||
|
the HMAC is valid before processing any data.
|
||||||
|
|
||||||
|
Stateless verification means no database lookup is required. Tokens cannot
|
||||||
|
be forged or replayed.
|
||||||
|
|
||||||
|
### 1.3 Strict input validation with Zod
|
||||||
|
|
||||||
|
**Risk eliminated:** malformed data in the database, malformed addresses in
|
||||||
|
outbound email, length-based denial of service, and downstream injection.
|
||||||
|
|
||||||
|
Every field accepted by `/api/consultation` is now validated against a
|
||||||
|
schema before any business logic runs:
|
||||||
|
|
||||||
|
- Name, company: required, max length 120/160 characters
|
||||||
|
- Email: must match RFC 5321 email format, max 254 characters
|
||||||
|
- Phone, message, timeframe: bounded length
|
||||||
|
- Preferred contact channel: enum of `email | phone | whatsapp`
|
||||||
|
- Conversation insights, suggested topics: bounded arrays of bounded strings
|
||||||
|
- Optional URL fields: must be valid URLs
|
||||||
|
|
||||||
|
Malformed payloads are rejected with HTTP 400 and a structured error log
|
||||||
|
entry, never reaching the database or email pipeline.
|
||||||
|
|
||||||
|
### 1.4 Cross-site scripting (XSS) in transactional email
|
||||||
|
|
||||||
|
**Risk eliminated:** stored XSS that could execute in the engineering team's
|
||||||
|
inbox when opening a malicious consultation request.
|
||||||
|
|
||||||
|
The consultation email template was concatenating client-supplied strings
|
||||||
|
(name, company, email, message, AI-detected industry labels) directly into
|
||||||
|
raw HTML. An attacker submitting a name like `<script>...</script>` would
|
||||||
|
have that markup rendered as live HTML when the email was opened in any
|
||||||
|
permissive client.
|
||||||
|
|
||||||
|
We introduced a small escape library (`src/lib/escapeHtml.ts`) and applied
|
||||||
|
it to every interpolated value in the template. Mail-to links are validated
|
||||||
|
with a strict regex and URL-encoded before reaching the `href` attribute.
|
||||||
|
|
||||||
|
### 1.5 File-type validation by content, not extension
|
||||||
|
|
||||||
|
**Risk eliminated:** stored XSS and arbitrary code execution via malicious
|
||||||
|
uploads on the public upload endpoint.
|
||||||
|
|
||||||
|
Previously, `/api/public-upload` trusted the file extension provided by the
|
||||||
|
client. A user could rename `payload.html` to `image.png` and the server
|
||||||
|
would save it as-is. Browsers reading the file later might still interpret
|
||||||
|
it as HTML, depending on response headers — a classic vector.
|
||||||
|
|
||||||
|
We added a magic-byte detector (`src/lib/fileType.ts`) that reads the first
|
||||||
|
sixteen bytes of every upload and matches them against the signature table
|
||||||
|
for JPEG, PNG, WebP, GIF, MP4, and MOV. Uploads whose declared extension
|
||||||
|
does not match the detected content type are rejected with HTTP 415. The
|
||||||
|
verification happens **before** the buffer is written to disk.
|
||||||
|
|
||||||
|
### 1.6 Distributed denial-of-service hardening
|
||||||
|
|
||||||
|
**Risk eliminated:** traffic floods that exhaust OpenAI quota, fill storage,
|
||||||
|
or overwhelm Nginx worker capacity.
|
||||||
|
|
||||||
|
The previous rate limit was tied to a per-process in-memory map. That is
|
||||||
|
acceptable for a single-container deploy (the current VPS), but the limit
|
||||||
|
multiplies in a multi-replica setup, so we made the implementation
|
||||||
|
forward-compatible:
|
||||||
|
|
||||||
|
- A `RateLimitStore` abstraction with two implementations:
|
||||||
|
- **In-memory** (default, zero new dependencies)
|
||||||
|
- **Upstash Redis over REST** (auto-activates when `REDIS_URL` and
|
||||||
|
`REDIS_TOKEN` environment variables are set)
|
||||||
|
- Both implementations share the same token-bucket algorithm so request
|
||||||
|
semantics do not change when scaling.
|
||||||
|
|
||||||
|
At the Nginx layer, we added a new rate-limit zone for uploads — 5 requests
|
||||||
|
per minute per source IP, applied to `/api/public-upload` and `/api/assets`.
|
||||||
|
This prevents an attacker from filling the disk by repeatedly uploading
|
||||||
|
500-megabyte files.
|
||||||
|
|
||||||
|
### 1.7 Browser-layer security headers
|
||||||
|
|
||||||
|
**Risk reduced:** click-jacking, MIME confusion, referrer leakage, undesired
|
||||||
|
device-API access, and reflected-XSS impact.
|
||||||
|
|
||||||
|
Nginx now emits a complete set of security response headers on every
|
||||||
|
HTTPS response:
|
||||||
|
|
||||||
|
| Header | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `Content-Security-Policy` | Restricts which origins can serve scripts, styles, images, fonts, and network connections |
|
||||||
|
| `Strict-Transport-Security` | Pre-existing; forces HTTPS for two years |
|
||||||
|
| `X-Frame-Options: DENY` | Prevents the site from being embedded in iframes (click-jacking defense) |
|
||||||
|
| `X-Content-Type-Options: nosniff` | Disables MIME sniffing |
|
||||||
|
| `Referrer-Policy: strict-origin-when-cross-origin` | Prevents leaking the full URL to third-party links |
|
||||||
|
| `Permissions-Policy` | Blocks camera, microphone, and geolocation APIs |
|
||||||
|
|
||||||
|
The Content Security Policy allow-lists only `api.openai.com` and the
|
||||||
|
Upstash REST endpoint for outbound connections. Inline scripts and styles
|
||||||
|
remain permitted for now because Next.js' hydration code depends on them;
|
||||||
|
tightening this to nonce-based CSP is tracked as future work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Code Quality and Performance
|
||||||
|
|
||||||
|
### 2.1 Dead code removal
|
||||||
|
|
||||||
|
`GlobalOperations_old.tsx` (310 lines, no references) was removed. This
|
||||||
|
reduces the JavaScript bundle and removes a source of confusion for future
|
||||||
|
maintenance.
|
||||||
|
|
||||||
|
### 2.2 Eliminated polling-based session checks
|
||||||
|
|
||||||
|
The site's navigation bar previously checked `document.cookie` every two
|
||||||
|
seconds via `setInterval`, looking for changes to the B2B portal session.
|
||||||
|
Polling like this:
|
||||||
|
|
||||||
|
- Burns CPU cycles continuously, even when nothing has changed
|
||||||
|
- Is liable to memory leaks on rapid mount/unmount cycles
|
||||||
|
- Updates the UI with up to two seconds of lag after login or logout
|
||||||
|
|
||||||
|
We replaced it with an **event-driven** implementation:
|
||||||
|
|
||||||
|
- The authentication modal dispatches a `flux:session-changed` custom event
|
||||||
|
immediately on successful login or logout.
|
||||||
|
- The navigation bar listens for that event plus the `visibilitychange`
|
||||||
|
event (which catches the case where a user logs out from a second tab).
|
||||||
|
- No interval, no polling, no lag.
|
||||||
|
|
||||||
|
### 2.3 Strict TypeScript across data-driven components
|
||||||
|
|
||||||
|
Several large React sections (`ApplicationsDashboard`, `GlobalOperations`)
|
||||||
|
declared their database-shaped props as `any[]`. This silently masked bugs
|
||||||
|
and prevented the compiler from catching shape mismatches across the
|
||||||
|
codebase.
|
||||||
|
|
||||||
|
We introduced `src/types/cms.ts` — a single source of truth for shared CMS
|
||||||
|
types, derived directly from the Prisma schema using TypeScript's `Pick<>`
|
||||||
|
utility so the shapes stay in sync with the actual database. Component
|
||||||
|
props were updated to use these named types. JSON-string fields (`galleryJson`,
|
||||||
|
`dashboardMetricsJson`, etc.) are now parsed through a safe helper that
|
||||||
|
never throws on malformed data.
|
||||||
|
|
||||||
|
### 2.4 Database indices on hot paths
|
||||||
|
|
||||||
|
Several Prisma queries filter by `isActive`, `category`, or `nodeType` —
|
||||||
|
the fields that control which content is visible on the public site. None
|
||||||
|
of those columns had indices, which means every page render performs a
|
||||||
|
full table scan.
|
||||||
|
|
||||||
|
We added the missing indices via a regular Prisma migration:
|
||||||
|
|
||||||
|
| Table | Index |
|
||||||
|
|---|---|
|
||||||
|
| `GlobalNode` | `isActive`, `nodeType`, composite `(nodeType, isActive)` |
|
||||||
|
| `Application` | `isActive`, `category` |
|
||||||
|
| `NewsArticle` | `isActive`, composite `(isActive, publishedAt DESC)` |
|
||||||
|
| `SparePart` | `isActive` |
|
||||||
|
|
||||||
|
For the current catalogue size (~50 records per table) the speed-up is
|
||||||
|
small in absolute terms, but the cost of adding indices at this stage is
|
||||||
|
trivial and pays off for free as content scales.
|
||||||
|
|
||||||
|
### 2.5 Structured JSON logging
|
||||||
|
|
||||||
|
The codebase had `console.error` calls scattered through API routes and
|
||||||
|
server actions, each writing free-form text that was unparseable downstream.
|
||||||
|
We introduced `src/lib/logger.ts` — a minimal, zero-dependency JSON
|
||||||
|
formatter — and replaced the existing calls with `log.info`, `log.warn`,
|
||||||
|
and `log.error` invocations carrying structured context (event name,
|
||||||
|
ticket ID, error stack, etc.).
|
||||||
|
|
||||||
|
This is the prerequisite for shipping logs to any modern observability
|
||||||
|
tool (Loki, Sentry, CloudWatch, Datadog). Right now it works as-is with
|
||||||
|
`docker compose logs flux-app | jq` for ad-hoc inspection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. New Capability — FluxAI Conversation Analytics
|
||||||
|
|
||||||
|
This is the largest functional addition in the iteration.
|
||||||
|
|
||||||
|
### 3.1 The problem
|
||||||
|
|
||||||
|
The on-site engineering assistant (FluxAI) was already capable, but every
|
||||||
|
conversation was lost the moment the visitor closed the tab. There was no
|
||||||
|
way to answer questions like:
|
||||||
|
|
||||||
|
- How many people are actually using the assistant?
|
||||||
|
- Which industries are they coming from?
|
||||||
|
- What fraction of conversations lead to a consultation request?
|
||||||
|
- Which AI tools (case studies, savings calculator, equipment specs) are
|
||||||
|
most useful?
|
||||||
|
- How long does a typical conversation last?
|
||||||
|
- Are visitors getting stuck at any particular point?
|
||||||
|
|
||||||
|
This iteration adds full persistence and a dedicated dashboard.
|
||||||
|
|
||||||
|
### 3.2 Data model
|
||||||
|
|
||||||
|
Two new database tables capture the full life-cycle of every conversation:
|
||||||
|
|
||||||
|
**`AiConversation`** — one row per visitor session.
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| `sessionId` | Stable identifier generated on the client, kept in localStorage |
|
||||||
|
| `visitorIp` | One-way hashed (SHA-256 + secret salt) for pseudonymous analytics; the raw IP is never stored |
|
||||||
|
| `locale` | Visitor's language (`it`, `en`, `es`, `fr`, `de`) |
|
||||||
|
| `pageUrl` | Entry page (e.g. `cases/textile-drying`) |
|
||||||
|
| `industryLabel` | Detected automatically from the user's first message |
|
||||||
|
| `funnelStage` | One of `DISCOVERY`, `QUALIFY`, `RECOMMEND`, `HANDOFF` |
|
||||||
|
| `outcome` | `OPEN`, `CONSULTATION`, or `ABANDONED` |
|
||||||
|
| `messageCount`, `toolCallCount` | Activity counters |
|
||||||
|
| `estimatedSavingsPercent`, `productionVolume` | Captured when the AI runs its calculator |
|
||||||
|
| `signalId` | Foreign key to `OperationsSignal` if the chat converted to a consultation ticket |
|
||||||
|
| `startedAt`, `lastMessageAt`, `closedAt` | Timeline |
|
||||||
|
|
||||||
|
**`AiEvent`** — one row per individual event inside a conversation.
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| `type` | `user_msg`, `ai_msg`, `tool_call`, `tool_result`, `error` |
|
||||||
|
| `payloadJson` | The serialized content, truncated to 8 KB |
|
||||||
|
| `toolName` | Which AI tool was invoked (when applicable) |
|
||||||
|
| `latencyMs` | Wall-clock time the AI took to respond |
|
||||||
|
| `tokensIn`, `tokensOut`, `cachedTokens` | OpenAI cost tracking |
|
||||||
|
| `createdAt` | Timestamp |
|
||||||
|
|
||||||
|
Both tables are extensively indexed for the dashboard queries below.
|
||||||
|
|
||||||
|
### 3.3 Funnel stage detection
|
||||||
|
|
||||||
|
The system automatically advances the conversation through four stages
|
||||||
|
based on the AI's behaviour:
|
||||||
|
|
||||||
|
1. **DISCOVERY** — initial state, before any industry is identified.
|
||||||
|
2. **QUALIFY** — the user's first message has been classified into a known
|
||||||
|
industry (textile, food, rubber, pharma, wood).
|
||||||
|
3. **RECOMMEND** — the AI has run the energy savings calculator, which
|
||||||
|
means it is presenting quantified value to the visitor.
|
||||||
|
4. **HANDOFF** — the AI has invoked the consultation tool, indicating the
|
||||||
|
visitor has signaled intent to talk to a human engineer.
|
||||||
|
|
||||||
|
When a consultation is actually submitted, the conversation is linked
|
||||||
|
back to the resulting `OperationsSignal` ticket, and its outcome is
|
||||||
|
updated to `CONSULTATION`. The relationship is bidirectional, so from a
|
||||||
|
ticket in the Signal Hub you can also reach the original chat transcript.
|
||||||
|
|
||||||
|
### 3.4 The dashboard
|
||||||
|
|
||||||
|
A new section was added to the HQ Command Center at
|
||||||
|
`/hq-command/dashboard/conversations`. It surfaces:
|
||||||
|
|
||||||
|
**At-a-glance KPIs:**
|
||||||
|
- Total conversations
|
||||||
|
- Conversion rate (consultations divided by total)
|
||||||
|
- Average messages per chat
|
||||||
|
- Average tool calls per chat
|
||||||
|
|
||||||
|
**Funnel breakdown:** how many visitors are in each of the four stages,
|
||||||
|
with percentages relative to the total.
|
||||||
|
|
||||||
|
**Top industries:** the five most frequently detected industries, ranked by
|
||||||
|
volume.
|
||||||
|
|
||||||
|
**Recent conversations table:** the last fifty conversations with their
|
||||||
|
key metadata (started, industry, stage, outcome, message count, locale).
|
||||||
|
|
||||||
|
**Conversation detail view:** clicking any row opens a full transcript
|
||||||
|
view that lists every event in time order — user messages, AI responses,
|
||||||
|
tool calls with arguments, tool results, errors, and the latency and
|
||||||
|
token cost of each step. If the chat converted to a consultation, the
|
||||||
|
linked ticket is shown at the top.
|
||||||
|
|
||||||
|
### 3.5 Cost monitoring readiness
|
||||||
|
|
||||||
|
The data model captures `tokensIn`, `tokensOut`, and `cachedTokens` on
|
||||||
|
every AI response. Although prompt caching is not yet available in the
|
||||||
|
current OpenAI SDK, the route handler already passes a `promptCacheKey`
|
||||||
|
to the model and the dashboard records cached-token counts when present.
|
||||||
|
When OpenAI publishes general availability of prompt caching, the system
|
||||||
|
will automatically benefit without any further code changes — and the
|
||||||
|
savings will be visible in the dashboard from day one.
|
||||||
|
|
||||||
|
### 3.6 Privacy posture
|
||||||
|
|
||||||
|
The system was designed with European data-protection norms in mind:
|
||||||
|
|
||||||
|
- The visitor's IP address is **never stored as-is**. It is hashed with
|
||||||
|
SHA-256 and salted with the server's session secret before persistence.
|
||||||
|
- Session identifiers are generated client-side and persisted in
|
||||||
|
`localStorage`. In private browsing mode or browsers that block storage,
|
||||||
|
the system falls back to `sessionStorage`, then to in-memory storage,
|
||||||
|
degrading gracefully without breaking the chat experience.
|
||||||
|
- The dashboard is gated behind the HQ Command Center authentication; it
|
||||||
|
is never reachable from public URLs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Infrastructure Improvements
|
||||||
|
|
||||||
|
### 4.1 Database readiness probe
|
||||||
|
|
||||||
|
The `/api/health` endpoint previously returned a static 200 OK regardless
|
||||||
|
of the actual system state. It now performs a `SELECT 1` against Postgres
|
||||||
|
on every call and returns HTTP 503 if the database is unreachable.
|
||||||
|
|
||||||
|
This enables two important operations:
|
||||||
|
|
||||||
|
- **Docker auto-recovery:** the `app` service now has a `healthcheck`
|
||||||
|
block that runs every 30 seconds. Docker will restart the container if
|
||||||
|
the check fails repeatedly.
|
||||||
|
- **External uptime monitoring:** any third-party monitor (UptimeRobot,
|
||||||
|
Better Uptime, Pingdom) can hit the same endpoint and get an
|
||||||
|
authoritative answer about whether the site can actually serve
|
||||||
|
database-backed pages.
|
||||||
|
|
||||||
|
### 4.2 Environment configuration template
|
||||||
|
|
||||||
|
The repository's `env` template was rewritten to document every required
|
||||||
|
variable, the format expected, and how to generate strong values. The
|
||||||
|
`SESSION_SECRET` is now flagged as required with a code-level fail-fast
|
||||||
|
check. Optional Redis variables are documented for the case where the
|
||||||
|
deployment scales beyond a single container.
|
||||||
|
|
||||||
|
### 4.3 Docker Compose health check
|
||||||
|
|
||||||
|
A health check block was added to the `app` service in `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "node -e \"fetch('http://localhost:3000/api/health')...\""]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
```
|
||||||
|
|
||||||
|
This lets Docker (and any orchestrator above it) automatically recycle
|
||||||
|
the container if the application loses its database connection or hangs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Quality Assurance
|
||||||
|
|
||||||
|
### 5.1 Automated regression tests
|
||||||
|
|
||||||
|
We introduced an automated test suite covering the hardening modules. The
|
||||||
|
suite is run via `npm run test:ai` and uses Node.js' built-in test runner
|
||||||
|
— no new dependencies are added to the project. Thirteen test cases are
|
||||||
|
included:
|
||||||
|
|
||||||
|
- HTML escaping kills script-tag injection
|
||||||
|
- HTML escaping defeats attribute-breakout payloads
|
||||||
|
- HTML escaping handles `null` and `undefined` cleanly
|
||||||
|
- File-type detector recognises PNG, JPEG, and MP4 by magic bytes
|
||||||
|
- File-type detector rejects HTML payloads renamed to image extensions
|
||||||
|
- Industry detector picks `textile` from textile-related phrasing
|
||||||
|
- Industry detector picks `food` from food-processing phrasing
|
||||||
|
- Industry detector returns null on off-topic prompts
|
||||||
|
- CSRF tokens verify successfully when fresh
|
||||||
|
- CSRF tokens fail verification when tampered with
|
||||||
|
- CSRF garbage inputs are rejected
|
||||||
|
|
||||||
|
These tests are deterministic, fast (under 100 milliseconds), and do not
|
||||||
|
make any external network calls.
|
||||||
|
|
||||||
|
### 5.2 Production build verification
|
||||||
|
|
||||||
|
The full Next.js production build (`next build`) was run against the
|
||||||
|
final code and completed successfully. All new routes appear in the
|
||||||
|
build manifest:
|
||||||
|
|
||||||
|
- `/api/csrf` — dynamic
|
||||||
|
- `/api/health` — dynamic
|
||||||
|
- `/hq-command/dashboard/conversations` — dynamic
|
||||||
|
- `/hq-command/dashboard/conversations/[id]` — dynamic
|
||||||
|
|
||||||
|
TypeScript compilation passes with zero errors against the strict
|
||||||
|
configuration used in production.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Deployment and Operations
|
||||||
|
|
||||||
|
### 6.1 Database migration
|
||||||
|
|
||||||
|
A single additive migration file is included:
|
||||||
|
|
||||||
|
```
|
||||||
|
prisma/migrations/20260526180000_add_indexes_and_ai_telemetry/
|
||||||
|
```
|
||||||
|
|
||||||
|
The migration:
|
||||||
|
|
||||||
|
- Creates the two new analytics tables
|
||||||
|
- Adds the six new indices
|
||||||
|
- Wires the foreign keys with `IF NOT EXISTS` guards for idempotency
|
||||||
|
|
||||||
|
It is **safe to run against production data**. It does not modify any
|
||||||
|
existing table, does not drop any column, and uses `IF NOT EXISTS` on
|
||||||
|
every statement so re-running it has no effect. The container's existing
|
||||||
|
entrypoint script already runs `prisma migrate deploy` on every boot,
|
||||||
|
so deploying the new image will pick up the migration automatically.
|
||||||
|
|
||||||
|
### 6.2 Required environment variables
|
||||||
|
|
||||||
|
Before deploying to the VPS, confirm the following:
|
||||||
|
|
||||||
|
| Variable | Required | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `SESSION_SECRET` | Yes | At least 32 characters. Generated via `openssl rand -base64 48`. The app will refuse to start without it. |
|
||||||
|
| `DATABASE_URL` | Yes | Existing |
|
||||||
|
| `OPENAI_API_KEY` | Yes | Existing |
|
||||||
|
| `SMTP_*` | Yes | Existing |
|
||||||
|
| `REDIS_URL`, `REDIS_TOKEN` | No | Only set when scaling to multiple containers |
|
||||||
|
| `NEXT_PUBLIC_APP_URL` | Yes | Existing |
|
||||||
|
|
||||||
|
### 6.3 Verification checklist after deploy
|
||||||
|
|
||||||
|
The following commands can be used to verify a successful deploy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Container health
|
||||||
|
docker compose ps # app status should be "healthy"
|
||||||
|
docker compose logs --tail=100 app # no SESSION_SECRET errors
|
||||||
|
|
||||||
|
# Endpoint smoke tests
|
||||||
|
curl -s https://www.rf-flux.com/api/health
|
||||||
|
# expected: {"ok":true,"db":"up","latencyMs":N,"ts":"..."}
|
||||||
|
|
||||||
|
curl -I https://www.rf-flux.com/
|
||||||
|
# expected security headers: Content-Security-Policy, X-Frame-Options:DENY,
|
||||||
|
# X-Content-Type-Options:nosniff, Referrer-Policy, Permissions-Policy
|
||||||
|
|
||||||
|
# Database migration applied
|
||||||
|
docker compose exec postgres psql -U flux_user -d flux_db -c "\d AiConversation"
|
||||||
|
# expected: table description with all columns
|
||||||
|
|
||||||
|
# AI conversations populating
|
||||||
|
# After someone uses the chat:
|
||||||
|
docker compose exec postgres psql -U flux_user -d flux_db \
|
||||||
|
-c "SELECT \"sessionId\", \"funnelStage\", \"outcome\", \"messageCount\" FROM \"AiConversation\" ORDER BY \"startedAt\" DESC LIMIT 5;"
|
||||||
|
```
|
||||||
|
|
||||||
|
The new dashboard is reachable at:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://www.rf-flux.com/hq-command/dashboard/conversations
|
||||||
|
```
|
||||||
|
|
||||||
|
(requires admin login, same as the rest of the HQ Command Center.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Known Limitations and Recommendations
|
||||||
|
|
||||||
|
### 7.1 Items intentionally deferred
|
||||||
|
|
||||||
|
- **Content Security Policy nonces.** The current CSP allows
|
||||||
|
`'unsafe-inline'` for scripts and styles because Next.js hydration
|
||||||
|
depends on them. Migrating to nonce-based CSP would require changes to
|
||||||
|
`next.config.ts` and the build pipeline. This is a known follow-up.
|
||||||
|
|
||||||
|
- **Prompt caching for the AI.** The OpenAI SDK does not yet expose
|
||||||
|
prompt caching to consumers. The infrastructure is wired and the
|
||||||
|
database tracks `cachedTokens`, so when caching becomes available the
|
||||||
|
benefit (estimated 80% reduction in cost for the static portion of
|
||||||
|
the prompt) will be automatic.
|
||||||
|
|
||||||
|
- **Email sequence automation, lead scoring, CRM integration.** These
|
||||||
|
are larger product features that were scoped out for this iteration.
|
||||||
|
|
||||||
|
### 7.2 Recommended next steps
|
||||||
|
|
||||||
|
1. **Rotate the OpenAI API key.** The current key is present in earlier
|
||||||
|
commits of the public repository. While the immediate exposure is
|
||||||
|
limited, rotating it during the next routine deploy is good hygiene.
|
||||||
|
2. **Rotate the SMTP password.** Same reasoning as above.
|
||||||
|
3. **Move the `env` file out of version control.** A follow-up commit
|
||||||
|
should convert `env` into `.env.example` (containing only placeholders)
|
||||||
|
and add `env` to the `.gitignore`. The real `.env` is already
|
||||||
|
gitignored, so this is the final step in eliminating secrets from
|
||||||
|
the repository.
|
||||||
|
4. **Consider Sentry or equivalent error aggregation.** The structured
|
||||||
|
logger introduced in this iteration is the prerequisite. Wiring it
|
||||||
|
to a hosted aggregation service is a half-day task and dramatically
|
||||||
|
improves time-to-detection for production errors.
|
||||||
|
5. **Schedule a 30-day review of the conversation dashboard data.** The
|
||||||
|
analytics will be most useful after a month of real traffic. At that
|
||||||
|
point we can identify the highest-impact funnel-stage improvements
|
||||||
|
based on actual visitor behaviour.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix A — Files Modified or Created
|
||||||
|
|
||||||
|
**New files (10):**
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `src/lib/csrf.ts` | CSRF token issuance and verification |
|
||||||
|
| `src/lib/escapeHtml.ts` | HTML escaping helpers |
|
||||||
|
| `src/lib/fileType.ts` | Magic-byte file-type detection |
|
||||||
|
| `src/lib/logger.ts` | Structured JSON logger |
|
||||||
|
| `src/lib/aiSessionId.ts` | Client-side session ID with privacy fallbacks |
|
||||||
|
| `src/types/cms.ts` | Shared CMS type definitions |
|
||||||
|
| `src/app/api/csrf/route.ts` | CSRF token issuance endpoint |
|
||||||
|
| `src/app/api/health/route.ts` | Database readiness probe |
|
||||||
|
| `src/app/hq-command/dashboard/conversations/page.tsx` | Analytics dashboard |
|
||||||
|
| `src/app/hq-command/dashboard/conversations/[id]/page.tsx` | Conversation detail view |
|
||||||
|
| `prisma/migrations/20260526180000_add_indexes_and_ai_telemetry/migration.sql` | Additive database migration |
|
||||||
|
| `tests/ai/golden.test.mjs` | Regression test suite |
|
||||||
|
|
||||||
|
**Modified files (19):**
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `src/lib/session.ts` | Fail-fast on missing or weak `SESSION_SECRET` |
|
||||||
|
| `src/lib/rateLimit.ts` | Pluggable backend (in-memory or Redis) |
|
||||||
|
| `src/app/actions/clientAuth.ts` | Same fail-fast as `session.ts` |
|
||||||
|
| `src/app/api/chat/route.ts` | AI telemetry persistence and prompt cache key |
|
||||||
|
| `src/app/api/consultation/route.ts` | CSRF + Zod + escapeHtml |
|
||||||
|
| `src/app/api/public-upload/route.ts` | Magic-byte validation |
|
||||||
|
| `src/components/layout/NavBar.tsx` | Event-driven session check |
|
||||||
|
| `src/components/ai/SilentObserver.tsx` | Sends sessionId in transport body |
|
||||||
|
| `src/components/ai/ConsultationScheduler.tsx` | Sends CSRF token in form post |
|
||||||
|
| `src/components/sections/ApplicationsDashboard.tsx` | Strict types replace `any[]` |
|
||||||
|
| `src/components/sections/GlobalOperations.tsx` | Strict types replace `any[]` |
|
||||||
|
| `src/app/[locale]/parts/_components/AuthModal.tsx` | Dispatches session-changed event |
|
||||||
|
| `src/app/hq-command/dashboard/page.tsx` | Tile for the new conversations dashboard |
|
||||||
|
| `prisma/schema.prisma` | New models, indices, back-reference on `OperationsSignal` |
|
||||||
|
| `nginx/conf.d/flux.conf` | Security headers, upload rate-limit zone |
|
||||||
|
| `docker-compose.yml` | Health check, optional Redis env vars |
|
||||||
|
| `package.json` | `npm run test:ai` script |
|
||||||
|
| `env` | Documented `SESSION_SECRET` requirement and Redis variables |
|
||||||
|
|
||||||
|
**Removed files (1):**
|
||||||
|
|
||||||
|
| File | Reason |
|
||||||
|
|---|---|
|
||||||
|
| `src/components/sections/GlobalOperations_old.tsx` | Unreferenced legacy code (310 lines) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix B — Quick Reference for the Sales Team
|
||||||
|
|
||||||
|
For team members who want to use the new analytics without engineering help:
|
||||||
|
|
||||||
|
1. Log in to the HQ Command Center at `https://www.rf-flux.com/hq-command`.
|
||||||
|
2. From the main dashboard, click the **FluxAI Conversations** tile (cyan
|
||||||
|
sparkle icon, last position in the grid).
|
||||||
|
3. The top four cards show overall numbers: total conversations,
|
||||||
|
conversion rate, average messages, average tool calls.
|
||||||
|
4. The two panels below show the funnel breakdown and the most common
|
||||||
|
industries.
|
||||||
|
5. The table lists the last fifty conversations. Click **Open** on any
|
||||||
|
row to see the full transcript.
|
||||||
|
6. Conversations that converted to a consultation ticket display the
|
||||||
|
ticket ID in green at the top of the detail view.
|
||||||
|
|
||||||
|
The data updates in real time — no refresh needed between visits.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*End of report.*
|
||||||
@@ -5,8 +5,26 @@
|
|||||||
#:xDATABASE_URL="postgresql://flux_user:STRONG_PASSWORD@postgres:5432/flux_db?schema=public"
|
#:xDATABASE_URL="postgresql://flux_user:STRONG_PASSWORD@postgres:5432/flux_db?schema=public"
|
||||||
DATABASE_URL="postgresql://flux_user:dev_password@localhost:5432/flux_db?schema=public"
|
DATABASE_URL="postgresql://flux_user:dev_password@localhost:5432/flux_db?schema=public"
|
||||||
|
|
||||||
#FLUX SECRET Esto no se que hace
|
# SESSION_SECRET (REQUIRED, min 32 chars).
|
||||||
SESSION_SECRET="FLUX_SUPER_SECRET_KEY_2026_ARCHITECTURE"
|
# Used to sign 7-day admin JWTs in src/lib/session.ts and CSRF tokens in
|
||||||
|
# src/lib/csrf.ts. The app refuses to boot without it. Generate with:
|
||||||
|
# openssl rand -base64 48
|
||||||
|
SESSION_SECRET="CHANGE_ME_openssl_rand_base64_48_min_32_chars"
|
||||||
|
|
||||||
|
# Optional: multi-instance rate limiting via Upstash Redis REST API.
|
||||||
|
# Leave both unset to use the in-memory bucket store (fine for single VPS).
|
||||||
|
#REDIS_URL="https://xxx.upstash.io"
|
||||||
|
#REDIS_TOKEN="xxxxx"
|
||||||
|
|
||||||
|
# Google Analytics 4 Measurement ID (format: G-XXXXXXXXXX).
|
||||||
|
# Leave empty to disable analytics entirely — the site loads no Google
|
||||||
|
# scripts and the consent banner stays hidden until this is set.
|
||||||
|
# This is a PUBLIC value (it ships in the page HTML), safe to commit.
|
||||||
|
NEXT_PUBLIC_GA_ID="G-KQ1JRV3KN7"
|
||||||
|
|
||||||
|
# Google Search Console verification token (the content="" value from the
|
||||||
|
# HTML-tag verification method). Leave empty if you verify via DNS or GA.
|
||||||
|
NEXT_PUBLIC_GSC_VERIFICATION=""
|
||||||
|
|
||||||
# OPEN AI KEY
|
# OPEN AI KEY
|
||||||
OPENAI_API_KEY=sk-proj-dsdsdsds-kaDAHhZXPYsK6-4uw0UWJZ0YuLnQfVoEA
|
OPENAI_API_KEY=sk-proj-dsdsdsds-kaDAHhZXPYsK6-4uw0UWJZ0YuLnQfVoEA
|
||||||
|
|||||||
+25
-2
@@ -1,10 +1,25 @@
|
|||||||
{
|
{
|
||||||
|
"Consent": {
|
||||||
|
"title": "Wir schätzen Ihre Privatsphäre",
|
||||||
|
"body": "Wir verwenden Analyse-Cookies, um zu verstehen, wie Besucher unsere Website nutzen, und um sie zu verbessern. Es werden keine Daten erfasst, bis Sie zustimmen.",
|
||||||
|
"learnMore": "Datenschutzerklärung",
|
||||||
|
"accept": "Akzeptieren",
|
||||||
|
"decline": "Ablehnen"
|
||||||
|
},
|
||||||
"Navigation": {
|
"Navigation": {
|
||||||
"applications": "Anwendungen",
|
"applications": "Anwendungen",
|
||||||
"globalMap": "Weltkarte",
|
"globalMap": "Weltkarte",
|
||||||
"ourStory": "Unsere Geschichte",
|
"ourStory": "Unsere Geschichte",
|
||||||
"parts": "Ersatzteile",
|
"parts": "Ersatzteile",
|
||||||
"insideFlux": "Inside Flux"
|
"insideFlux": "Inside Flux",
|
||||||
|
"team": "Team"
|
||||||
|
},
|
||||||
|
"TeamPage": {
|
||||||
|
"eyebrow": "Unser Team",
|
||||||
|
"title1": "Die Köpfe hinter",
|
||||||
|
"title2": "der Leistung.",
|
||||||
|
"description": "Vier Jahrzehnte RF-Ingenieurskompetenz, verkörpert von den Menschen, die jedes FLUX-System entwerfen, bauen und betreuen.",
|
||||||
|
"empty": "Die Profile unseres Teams sind in Kürze verfügbar."
|
||||||
},
|
},
|
||||||
"HeroReel": {
|
"HeroReel": {
|
||||||
"title1": "Innovation,",
|
"title1": "Innovation,",
|
||||||
@@ -144,7 +159,8 @@
|
|||||||
"eventOverview": "Veranstaltungsübersicht",
|
"eventOverview": "Veranstaltungsübersicht",
|
||||||
"projectChronicle": "Projektchronik",
|
"projectChronicle": "Projektchronik",
|
||||||
"pendingData": "[ Chronikdaten für diesen Knoten ausstehend ]",
|
"pendingData": "[ Chronikdaten für diesen Knoten ausstehend ]",
|
||||||
"mediaGallery": "Mediengalerie"
|
"mediaGallery": "Mediengalerie",
|
||||||
|
"viewFullCase": "Vollständige Fallstudie ansehen"
|
||||||
},
|
},
|
||||||
"Footer": {
|
"Footer": {
|
||||||
"madeInItaly": "Hergestellt in Italien",
|
"madeInItaly": "Hergestellt in Italien",
|
||||||
@@ -184,6 +200,13 @@
|
|||||||
"page": "Seite",
|
"page": "Seite",
|
||||||
"of": "von"
|
"of": "von"
|
||||||
},
|
},
|
||||||
|
"ArticlePage": {
|
||||||
|
"backToNewsHub": "Zurück zum News Hub",
|
||||||
|
"backToNews": "Zurück zu Nachrichten",
|
||||||
|
"mediaGallery": "Mediengalerie",
|
||||||
|
"joinLinkedIn": "Diskussion auf LinkedIn beitreten",
|
||||||
|
"internalRelease": "Interne Unternehmensmitteilung"
|
||||||
|
},
|
||||||
"AuthModal": {
|
"AuthModal": {
|
||||||
"b2bPortal": "B2B-Portal",
|
"b2bPortal": "B2B-Portal",
|
||||||
"signIn": "Anmelden",
|
"signIn": "Anmelden",
|
||||||
|
|||||||
+25
-2
@@ -1,10 +1,25 @@
|
|||||||
{
|
{
|
||||||
|
"Consent": {
|
||||||
|
"title": "We value your privacy",
|
||||||
|
"body": "We use analytics cookies to understand how visitors use our site and to improve it. No data is collected until you accept.",
|
||||||
|
"learnMore": "Privacy Policy",
|
||||||
|
"accept": "Accept",
|
||||||
|
"decline": "Decline"
|
||||||
|
},
|
||||||
"Navigation": {
|
"Navigation": {
|
||||||
"applications": "Applications",
|
"applications": "Applications",
|
||||||
"globalMap": "Global Map",
|
"globalMap": "Global Map",
|
||||||
"ourStory": "Our Story",
|
"ourStory": "Our Story",
|
||||||
"parts": "Spare Parts",
|
"parts": "Spare Parts",
|
||||||
"insideFlux": "Inside Flux"
|
"insideFlux": "Inside Flux",
|
||||||
|
"team": "Team"
|
||||||
|
},
|
||||||
|
"TeamPage": {
|
||||||
|
"eyebrow": "Our Team",
|
||||||
|
"title1": "The minds behind",
|
||||||
|
"title2": "the power.",
|
||||||
|
"description": "Four decades of RF engineering expertise, embodied by the people who design, build and support every FLUX system.",
|
||||||
|
"empty": "Our team profiles are coming soon."
|
||||||
},
|
},
|
||||||
"HeroReel": {
|
"HeroReel": {
|
||||||
"title1": "Innovation,",
|
"title1": "Innovation,",
|
||||||
@@ -144,7 +159,8 @@
|
|||||||
"eventOverview": "Event Overview",
|
"eventOverview": "Event Overview",
|
||||||
"projectChronicle": "Project Chronicle",
|
"projectChronicle": "Project Chronicle",
|
||||||
"pendingData": "[ Chronicle data pending for this node ]",
|
"pendingData": "[ Chronicle data pending for this node ]",
|
||||||
"mediaGallery": "Media Gallery"
|
"mediaGallery": "Media Gallery",
|
||||||
|
"viewFullCase": "View full case study"
|
||||||
},
|
},
|
||||||
"Footer": {
|
"Footer": {
|
||||||
"madeInItaly": "Made in Italy",
|
"madeInItaly": "Made in Italy",
|
||||||
@@ -184,6 +200,13 @@
|
|||||||
"page": "Page",
|
"page": "Page",
|
||||||
"of": "of"
|
"of": "of"
|
||||||
},
|
},
|
||||||
|
"ArticlePage": {
|
||||||
|
"backToNewsHub": "Back to News Hub",
|
||||||
|
"backToNews": "Back to News",
|
||||||
|
"mediaGallery": "Media Gallery",
|
||||||
|
"joinLinkedIn": "Join the conversation on LinkedIn",
|
||||||
|
"internalRelease": "Internal Corporate Release"
|
||||||
|
},
|
||||||
"AuthModal": {
|
"AuthModal": {
|
||||||
"b2bPortal": "B2B Portal",
|
"b2bPortal": "B2B Portal",
|
||||||
"signIn": "Sign In",
|
"signIn": "Sign In",
|
||||||
|
|||||||
+25
-2
@@ -1,10 +1,25 @@
|
|||||||
{
|
{
|
||||||
|
"Consent": {
|
||||||
|
"title": "Respetamos tu privacidad",
|
||||||
|
"body": "Usamos cookies analíticas para entender cómo los visitantes usan nuestro sitio y mejorarlo. No se recopila ningún dato hasta que aceptes.",
|
||||||
|
"learnMore": "Política de privacidad",
|
||||||
|
"accept": "Aceptar",
|
||||||
|
"decline": "Rechazar"
|
||||||
|
},
|
||||||
"Navigation": {
|
"Navigation": {
|
||||||
"applications": "Aplicaciones",
|
"applications": "Aplicaciones",
|
||||||
"globalMap": "Mapa Global",
|
"globalMap": "Mapa Global",
|
||||||
"ourStory": "Nuestra Historia",
|
"ourStory": "Nuestra Historia",
|
||||||
"parts": "Repuestos",
|
"parts": "Repuestos",
|
||||||
"insideFlux": "Inside Flux"
|
"insideFlux": "Inside Flux",
|
||||||
|
"team": "Equipo"
|
||||||
|
},
|
||||||
|
"TeamPage": {
|
||||||
|
"eyebrow": "Nuestro Equipo",
|
||||||
|
"title1": "Las mentes detrás",
|
||||||
|
"title2": "de la potencia.",
|
||||||
|
"description": "Cuatro décadas de experiencia en ingeniería de RF, encarnadas por las personas que diseñan, construyen y dan soporte a cada sistema FLUX.",
|
||||||
|
"empty": "Los perfiles de nuestro equipo estarán disponibles pronto."
|
||||||
},
|
},
|
||||||
"HeroReel": {
|
"HeroReel": {
|
||||||
"title1": "Innovación,",
|
"title1": "Innovación,",
|
||||||
@@ -144,7 +159,8 @@
|
|||||||
"eventOverview": "Resumen del Evento",
|
"eventOverview": "Resumen del Evento",
|
||||||
"projectChronicle": "Crónica del Proyecto",
|
"projectChronicle": "Crónica del Proyecto",
|
||||||
"pendingData": "[ Datos de crónica pendientes para este nodo ]",
|
"pendingData": "[ Datos de crónica pendientes para este nodo ]",
|
||||||
"mediaGallery": "Galería de Medios"
|
"mediaGallery": "Galería de Medios",
|
||||||
|
"viewFullCase": "Ver el caso completo"
|
||||||
},
|
},
|
||||||
"Footer": {
|
"Footer": {
|
||||||
"madeInItaly": "Hecho en Italia",
|
"madeInItaly": "Hecho en Italia",
|
||||||
@@ -184,6 +200,13 @@
|
|||||||
"page": "Página",
|
"page": "Página",
|
||||||
"of": "de"
|
"of": "de"
|
||||||
},
|
},
|
||||||
|
"ArticlePage": {
|
||||||
|
"backToNewsHub": "Volver al News Hub",
|
||||||
|
"backToNews": "Volver a Noticias",
|
||||||
|
"mediaGallery": "Galería de Medios",
|
||||||
|
"joinLinkedIn": "Únete a la conversación en LinkedIn",
|
||||||
|
"internalRelease": "Comunicado Corporativo Interno"
|
||||||
|
},
|
||||||
"AuthModal": {
|
"AuthModal": {
|
||||||
"b2bPortal": "Portal B2B",
|
"b2bPortal": "Portal B2B",
|
||||||
"signIn": "Iniciar Sesión",
|
"signIn": "Iniciar Sesión",
|
||||||
|
|||||||
+25
-2
@@ -1,10 +1,25 @@
|
|||||||
{
|
{
|
||||||
|
"Consent": {
|
||||||
|
"title": "Rispettiamo la tua privacy",
|
||||||
|
"body": "Utilizziamo cookie analitici per capire come i visitatori usano il nostro sito e per migliorarlo. Nessun dato viene raccolto finché non accetti.",
|
||||||
|
"learnMore": "Informativa sulla privacy",
|
||||||
|
"accept": "Accetta",
|
||||||
|
"decline": "Rifiuta"
|
||||||
|
},
|
||||||
"Navigation": {
|
"Navigation": {
|
||||||
"applications": "Applicazioni",
|
"applications": "Applicazioni",
|
||||||
"globalMap": "Mappa Globale",
|
"globalMap": "Mappa Globale",
|
||||||
"ourStory": "La nostra Storia",
|
"ourStory": "La nostra Storia",
|
||||||
"parts": "Ricambi",
|
"parts": "Ricambi",
|
||||||
"insideFlux": "Inside Flux"
|
"insideFlux": "Inside Flux",
|
||||||
|
"team": "Team"
|
||||||
|
},
|
||||||
|
"TeamPage": {
|
||||||
|
"eyebrow": "Il nostro Team",
|
||||||
|
"title1": "Le menti dietro",
|
||||||
|
"title2": "la potenza.",
|
||||||
|
"description": "Quattro decenni di competenza ingegneristica RF, incarnati dalle persone che progettano, costruiscono e supportano ogni sistema FLUX.",
|
||||||
|
"empty": "I profili del nostro team saranno disponibili a breve."
|
||||||
},
|
},
|
||||||
"HeroReel": {
|
"HeroReel": {
|
||||||
"title1": "Innovazione,",
|
"title1": "Innovazione,",
|
||||||
@@ -144,7 +159,8 @@
|
|||||||
"eventOverview": "Panoramica Evento",
|
"eventOverview": "Panoramica Evento",
|
||||||
"projectChronicle": "Cronaca del Progetto",
|
"projectChronicle": "Cronaca del Progetto",
|
||||||
"pendingData": "[ Dati cronaca in attesa per questo nodo ]",
|
"pendingData": "[ Dati cronaca in attesa per questo nodo ]",
|
||||||
"mediaGallery": "Galleria Media"
|
"mediaGallery": "Galleria Media",
|
||||||
|
"viewFullCase": "Vedi il caso completo"
|
||||||
},
|
},
|
||||||
"Footer": {
|
"Footer": {
|
||||||
"madeInItaly": "Made in Italy",
|
"madeInItaly": "Made in Italy",
|
||||||
@@ -184,6 +200,13 @@
|
|||||||
"page": "Pagina",
|
"page": "Pagina",
|
||||||
"of": "di"
|
"of": "di"
|
||||||
},
|
},
|
||||||
|
"ArticlePage": {
|
||||||
|
"backToNewsHub": "Torna al News Hub",
|
||||||
|
"backToNews": "Torna alle Notizie",
|
||||||
|
"mediaGallery": "Galleria Media",
|
||||||
|
"joinLinkedIn": "Partecipa alla conversazione su LinkedIn",
|
||||||
|
"internalRelease": "Comunicato Aziendale Interno"
|
||||||
|
},
|
||||||
"AuthModal": {
|
"AuthModal": {
|
||||||
"b2bPortal": "Portale B2B",
|
"b2bPortal": "Portale B2B",
|
||||||
"signIn": "Accedi",
|
"signIn": "Accedi",
|
||||||
|
|||||||
+25
-2
@@ -1,10 +1,25 @@
|
|||||||
{
|
{
|
||||||
|
"Consent": {
|
||||||
|
"title": "Tegnémo cara ła to privacy",
|
||||||
|
"body": "Doperémo cookie analitici par capir come che i visitadori i dòpara el nostro sito e par mejorarlo. Nissun dato vien racolto fin che no te aceti.",
|
||||||
|
"learnMore": "Informativa privacy",
|
||||||
|
"accept": "Aceta",
|
||||||
|
"decline": "Refuda"
|
||||||
|
},
|
||||||
"Navigation": {
|
"Navigation": {
|
||||||
"applications": "Applicaçion",
|
"applications": "Applicaçion",
|
||||||
"globalMap": "Mapa del Mondo",
|
"globalMap": "Mapa del Mondo",
|
||||||
"ourStory": "La Nostra Storia",
|
"ourStory": "La Nostra Storia",
|
||||||
"parts": "Pessi de Ricambio",
|
"parts": "Pessi de Ricambio",
|
||||||
"insideFlux": "Drento FLUX"
|
"insideFlux": "Drento FLUX",
|
||||||
|
"team": "Squadra"
|
||||||
|
},
|
||||||
|
"TeamPage": {
|
||||||
|
"eyebrow": "La nostra Squadra",
|
||||||
|
"title1": "Le menti drio",
|
||||||
|
"title2": "ła potensa.",
|
||||||
|
"description": "Quatro deceni de esperiensa inzegnierìstica RF, incarnài da łe persone che projeta, costruise e suporta ogni sistema FLUX.",
|
||||||
|
"empty": "I profiłi de ła nostra squadra i rivarà presto."
|
||||||
},
|
},
|
||||||
"HeroReel": {
|
"HeroReel": {
|
||||||
"title1": "Inovaçion,",
|
"title1": "Inovaçion,",
|
||||||
@@ -144,7 +159,8 @@
|
|||||||
"eventOverview": "Detaji de l'evento",
|
"eventOverview": "Detaji de l'evento",
|
||||||
"projectChronicle": "Storia del projeto",
|
"projectChronicle": "Storia del projeto",
|
||||||
"pendingData": "[ Dati de la storia drio rivar par sto nodo ]",
|
"pendingData": "[ Dati de la storia drio rivar par sto nodo ]",
|
||||||
"mediaGallery": "Gałeria de foto"
|
"mediaGallery": "Gałeria de foto",
|
||||||
|
"viewFullCase": "Varda el caso completo"
|
||||||
},
|
},
|
||||||
"Footer": {
|
"Footer": {
|
||||||
"madeInItaly": "Fato in Itaia",
|
"madeInItaly": "Fato in Itaia",
|
||||||
@@ -184,6 +200,13 @@
|
|||||||
"page": "Pagina",
|
"page": "Pagina",
|
||||||
"of": "de"
|
"of": "de"
|
||||||
},
|
},
|
||||||
|
"ArticlePage": {
|
||||||
|
"backToNewsHub": "Torna al News Hub",
|
||||||
|
"backToNews": "Torna a łe Notissie",
|
||||||
|
"mediaGallery": "Gałeria Media",
|
||||||
|
"joinLinkedIn": "Parteçipa a ła conversassion su LinkedIn",
|
||||||
|
"internalRelease": "Comunicato Aziendałe Interno"
|
||||||
|
},
|
||||||
"AuthModal": {
|
"AuthModal": {
|
||||||
"b2bPortal": "Portal par ditte",
|
"b2bPortal": "Portal par ditte",
|
||||||
"signIn": "Entra chive",
|
"signIn": "Entra chive",
|
||||||
|
|||||||
@@ -1,11 +1,53 @@
|
|||||||
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
|
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
|
||||||
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/s;
|
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/s;
|
||||||
|
# Slow zone for media uploads: 5 requests per minute per IP.
|
||||||
|
limit_req_zone $binary_remote_addr zone=upload:10m rate=5r/m;
|
||||||
|
|
||||||
upstream nextjs {
|
upstream nextjs {
|
||||||
server app:3000;
|
server app:3000;
|
||||||
keepalive 32;
|
keepalive 32;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
# CANONICAL-HOST GUARD (default_server for ports 80 + 443)
|
||||||
|
# Catches every request NOT addressed to rf-flux.com / www.rf-flux.com —
|
||||||
|
# raw-IP access (135.125.53.234), SSRF probes (Host: 169.254.169.254,
|
||||||
|
# localhost, metadata.google.internal) and the bulk of bot scans that hit
|
||||||
|
# the bare IP. Returns 444 (drop the connection, send nothing).
|
||||||
|
#
|
||||||
|
# Legitimate traffic is unaffected: the rf-flux.com server blocks below win
|
||||||
|
# because an exact server_name match always beats default_server.
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# Nginx self-health endpoint (served directly, no upstream) — used by the
|
||||||
|
# docker-compose healthcheck. Reachable on 127.0.0.1 inside the container
|
||||||
|
# (no Host match needed, so it lands here on the default_server).
|
||||||
|
location = /nginx-health { return 200 "ok\n"; access_log off; }
|
||||||
|
|
||||||
|
# Keep ACME HTTP-01 working so certbot can still renew on any host.
|
||||||
|
location /.well-known/acme-challenge/ { root /var/www/certbot; }
|
||||||
|
|
||||||
|
location / { return 444; }
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl default_server;
|
||||||
|
http2 on;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# A cert is required to complete the TLS handshake before the Host is
|
||||||
|
# known; reuse the rf-flux.com cert, then drop. Bots hitting the IP get
|
||||||
|
# a cert-name mismatch and a closed connection — nothing is proxied.
|
||||||
|
ssl_certificate /etc/letsencrypt/live/rf-flux.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/rf-flux.com/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
|
||||||
|
return 444;
|
||||||
|
}
|
||||||
|
|
||||||
# Legacy domain redirect — anyone landing on lethepowerflux.com lands on
|
# Legacy domain redirect — anyone landing on lethepowerflux.com lands on
|
||||||
# the canonical https://www.rf-flux.com host instead. SEO-safe 301.
|
# the canonical https://www.rf-flux.com host instead. SEO-safe 301.
|
||||||
server {
|
server {
|
||||||
@@ -44,6 +86,25 @@ server {
|
|||||||
ssl_session_tickets off;
|
ssl_session_tickets off;
|
||||||
|
|
||||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
|
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
|
||||||
|
# ── Security headers ────────────────────────────────────────────────
|
||||||
|
# 'unsafe-inline' / 'unsafe-eval' on script-src are required by Next.js
|
||||||
|
# for hydration. Tightening to nonces is tracked as future work.
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; media-src 'self' blob: https:; font-src 'self' data:; connect-src 'self' https://api.openai.com https://*.upstash.io https://www.googletagmanager.com https://www.google-analytics.com https://*.google-analytics.com https://*.analytics.google.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
|
||||||
|
# ── Scanner / exploit-probe blocking ────────────────────────────────
|
||||||
|
# This is a Next.js app — it has no PHP, no .env/.git served over HTTP,
|
||||||
|
# no wp-admin. Any request for those paths is a bot probing for secrets
|
||||||
|
# or exploits. Drop them cheaply with 444 before they touch the app.
|
||||||
|
# The patterns are scanner-specific and never match real assets
|
||||||
|
# (.jpg/.png/.webp/.mp4/.glb/.pdf/.svg) or app routes.
|
||||||
|
location ~* (?:\.(?:php|phtml|asp|aspx|jsp|cgi|env|sql|bak|ini|sh|yml|yaml|conf)$|/\.(?:git|env|aws|ssh|svn|hg|idea|vscode)|/(?:wp-admin|wp-login|wordpress|phpmyadmin|xmlrpc)) {
|
||||||
|
return 444;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
# Next.js bundles use content hashing — safe to cache forever
|
# Next.js bundles use content hashing — safe to cache forever
|
||||||
location /_next/static/ {
|
location /_next/static/ {
|
||||||
@@ -75,6 +136,7 @@ server {
|
|||||||
|
|
||||||
# Asset uploads (large files, long timeout)
|
# Asset uploads (large files, long timeout)
|
||||||
location /api/assets {
|
location /api/assets {
|
||||||
|
limit_req zone=upload burst=10 nodelay;
|
||||||
client_max_body_size 500M;
|
client_max_body_size 500M;
|
||||||
proxy_pass http://nextjs;
|
proxy_pass http://nextjs;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -87,6 +149,7 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /api/public-upload {
|
location /api/public-upload {
|
||||||
|
limit_req zone=upload burst=10 nodelay;
|
||||||
client_max_body_size 500M;
|
client_max_body_size 500M;
|
||||||
proxy_pass http://nextjs;
|
proxy_pass http://nextjs;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -178,6 +241,12 @@ server {
|
|||||||
access_log off;
|
access_log off;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /team/ {
|
||||||
|
alias /srv/team/;
|
||||||
|
add_header Cache-Control "public, max-age=300, must-revalidate" always;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://nextjs;
|
proxy_pass http://nextjs;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
+2
-1
@@ -6,7 +6,8 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"test:ai": "node --test tests/ai/golden.test.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/openai": "^3.0.41",
|
"@ai-sdk/openai": "^3.0.41",
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
-- ─────────────────────────────────────────────────────────────────────────
|
||||||
|
-- ADDITIVE MIGRATION — adds analytics tables + indices on hot filter columns.
|
||||||
|
-- Nothing in this file modifies or drops existing data. Safe to `migrate
|
||||||
|
-- deploy` in production. Idempotent: every CREATE uses IF NOT EXISTS.
|
||||||
|
-- ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- ── Indices on existing tables (speed up isActive/category filters) ──────
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "GlobalNode_isActive_idx" ON "GlobalNode" ("isActive");
|
||||||
|
CREATE INDEX IF NOT EXISTS "GlobalNode_nodeType_idx" ON "GlobalNode" ("nodeType");
|
||||||
|
CREATE INDEX IF NOT EXISTS "GlobalNode_nodeType_isActive_idx" ON "GlobalNode" ("nodeType", "isActive");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "Application_isActive_idx" ON "Application" ("isActive");
|
||||||
|
CREATE INDEX IF NOT EXISTS "Application_category_idx" ON "Application" ("category");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "NewsArticle_isActive_idx" ON "NewsArticle" ("isActive");
|
||||||
|
CREATE INDEX IF NOT EXISTS "NewsArticle_isActive_publishedAt_idx" ON "NewsArticle" ("isActive", "publishedAt" DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "SparePart_isActive_idx" ON "SparePart" ("isActive");
|
||||||
|
|
||||||
|
-- ── FluxAI telemetry ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "AiConversation" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"sessionId" TEXT NOT NULL,
|
||||||
|
"visitorIp" TEXT,
|
||||||
|
"userAgent" TEXT,
|
||||||
|
"locale" TEXT,
|
||||||
|
"pageUrl" TEXT,
|
||||||
|
"industryLabel" TEXT,
|
||||||
|
"funnelStage" TEXT NOT NULL DEFAULT 'DISCOVERY',
|
||||||
|
"outcome" TEXT NOT NULL DEFAULT 'OPEN',
|
||||||
|
"messageCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"toolCallCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"estimatedSavingsPercent" DOUBLE PRECISION,
|
||||||
|
"productionVolume" TEXT,
|
||||||
|
"signalId" TEXT,
|
||||||
|
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"lastMessageAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"closedAt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "AiConversation_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "AiConversation_sessionId_key" ON "AiConversation" ("sessionId");
|
||||||
|
CREATE INDEX IF NOT EXISTS "AiConversation_funnelStage_idx" ON "AiConversation" ("funnelStage");
|
||||||
|
CREATE INDEX IF NOT EXISTS "AiConversation_outcome_idx" ON "AiConversation" ("outcome");
|
||||||
|
CREATE INDEX IF NOT EXISTS "AiConversation_startedAt_idx" ON "AiConversation" ("startedAt" DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS "AiConversation_industryLabel_idx" ON "AiConversation" ("industryLabel");
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "AiEvent" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"conversationId" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"payloadJson" TEXT NOT NULL,
|
||||||
|
"toolName" TEXT,
|
||||||
|
"latencyMs" INTEGER,
|
||||||
|
"tokensIn" INTEGER,
|
||||||
|
"tokensOut" INTEGER,
|
||||||
|
"cachedTokens" INTEGER,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "AiEvent_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "AiEvent_conversationId_createdAt_idx" ON "AiEvent" ("conversationId", "createdAt");
|
||||||
|
CREATE INDEX IF NOT EXISTS "AiEvent_type_idx" ON "AiEvent" ("type");
|
||||||
|
CREATE INDEX IF NOT EXISTS "AiEvent_toolName_idx" ON "AiEvent" ("toolName");
|
||||||
|
|
||||||
|
-- ── Foreign keys (added separately so missing references don't break load) ──
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "AiConversation"
|
||||||
|
ADD CONSTRAINT "AiConversation_signalId_fkey"
|
||||||
|
FOREIGN KEY ("signalId") REFERENCES "OperationsSignal"("id")
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "AiEvent"
|
||||||
|
ADD CONSTRAINT "AiEvent_conversationId_fkey"
|
||||||
|
FOREIGN KEY ("conversationId") REFERENCES "AiConversation"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- ─────────────────────────────────────────────────────────────────────────
|
||||||
|
-- ADDITIVE MIGRATION — adds the TeamMember table for the public team page.
|
||||||
|
-- Nothing here modifies or drops existing data. Idempotent via IF NOT EXISTS.
|
||||||
|
-- ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "TeamMember" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"role" TEXT NOT NULL,
|
||||||
|
"bio" TEXT,
|
||||||
|
"photoUrl" TEXT,
|
||||||
|
"email" TEXT,
|
||||||
|
"linkedinUrl" TEXT,
|
||||||
|
"xUrl" TEXT,
|
||||||
|
"websiteUrl" TEXT,
|
||||||
|
"order" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"translationsJson" TEXT DEFAULT '{}',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "TeamMember_isActive_order_idx" ON "TeamMember" ("isActive", "order");
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- ─────────────────────────────────────────────────────────────────────────
|
||||||
|
-- ADDITIVE MIGRATION — adds a manual `order` column to Application so the
|
||||||
|
-- editor can drag-to-reorder applications on the public site (same pattern
|
||||||
|
-- as HeroSlide). Existing rows default to 0 and keep their creation order
|
||||||
|
-- as a tiebreaker. Safe for `migrate deploy`. Idempotent.
|
||||||
|
-- ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ALTER TABLE "Application" ADD COLUMN IF NOT EXISTS "order" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "Application_isActive_order_idx" ON "Application" ("isActive", "order");
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
-- ─────────────────────────────────────────────────────────────────────────
|
||||||
|
-- ADDITIVE MIGRATION — creates the ClientUser table (B2B client portal) and
|
||||||
|
-- wires OperationsSignal.clientId to it. The Prisma schema has defined this
|
||||||
|
-- model + relation for a while, but no migration ever created the table, so
|
||||||
|
-- the B2B register/login flow (src/app/actions/clientAuth.ts) and the
|
||||||
|
-- dashboard client counts were failing at runtime. This backfills it.
|
||||||
|
--
|
||||||
|
-- Idempotent: IF NOT EXISTS / duplicate-object guards make it safe to re-run
|
||||||
|
-- and safe for `migrate deploy` against the existing production DB.
|
||||||
|
-- ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "ClientUser" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"passwordHash" TEXT NOT NULL,
|
||||||
|
"fullName" TEXT NOT NULL,
|
||||||
|
"companyName" TEXT NOT NULL,
|
||||||
|
"phone" TEXT,
|
||||||
|
"isApproved" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"lastLoginAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "ClientUser_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "ClientUser_email_key" ON "ClientUser" ("email");
|
||||||
|
|
||||||
|
-- OperationsSignal.clientId — the FK column the schema references. Add it if
|
||||||
|
-- a prior schema state never created it (nullable, so existing rows are fine).
|
||||||
|
ALTER TABLE "OperationsSignal" ADD COLUMN IF NOT EXISTS "clientId" TEXT;
|
||||||
|
|
||||||
|
-- Foreign key OperationsSignal.clientId -> ClientUser.id
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "OperationsSignal"
|
||||||
|
ADD CONSTRAINT "OperationsSignal_clientId_fkey"
|
||||||
|
FOREIGN KEY ("clientId") REFERENCES "ClientUser"("id")
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- ─────────────────────────────────────────────────────────────────────────
|
||||||
|
-- ADDITIVE MIGRATION — index GlobalNode(application, isActive).
|
||||||
|
-- The application detail page queries case studies by application slug +
|
||||||
|
-- isActive (the GlobalNode.application -> Application.slug join). Without an
|
||||||
|
-- index this is a full table scan on every application page render.
|
||||||
|
-- Idempotent. Safe for `migrate deploy`.
|
||||||
|
-- ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "GlobalNode_application_isActive_idx"
|
||||||
|
ON "GlobalNode" ("application", "isActive");
|
||||||
@@ -60,6 +60,13 @@ model GlobalNode {
|
|||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([isActive])
|
||||||
|
@@index([nodeType])
|
||||||
|
@@index([nodeType, isActive])
|
||||||
|
// Case studies on an application page filter by application slug + isActive
|
||||||
|
// (src/app/[locale]/applications/[slug]/page.tsx). Back this join with an index.
|
||||||
|
@@index([application, isActive])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------
|
// ------------------------------------------------------
|
||||||
@@ -85,11 +92,18 @@ model Application {
|
|||||||
dashboardMetricsJson String? @default("[]")
|
dashboardMetricsJson String? @default("[]")
|
||||||
isActive Boolean @default(true) // 🔥 NUEVO: Para poder ocultarlas
|
isActive Boolean @default(true) // 🔥 NUEVO: Para poder ocultarlas
|
||||||
|
|
||||||
|
// 🔥 NUEVO: Orden manual para drag-to-reorder en el frontend (como HeroSlide)
|
||||||
|
order Int @default(0)
|
||||||
|
|
||||||
// 🌍 MOTOR DE TRADUCCIONES
|
// 🌍 MOTOR DE TRADUCCIONES
|
||||||
translationsJson String? @default("{}")
|
translationsJson String? @default("{}")
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([isActive])
|
||||||
|
@@index([category])
|
||||||
|
@@index([isActive, order])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------
|
// ------------------------------------------------------
|
||||||
@@ -134,6 +148,9 @@ model NewsArticle {
|
|||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([isActive])
|
||||||
|
@@index([isActive, publishedAt(sort: Desc)])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------
|
// ------------------------------------------------------
|
||||||
@@ -177,6 +194,8 @@ model SparePart {
|
|||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([isActive])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------
|
// ------------------------------------------------------
|
||||||
@@ -210,6 +229,9 @@ model OperationsSignal {
|
|||||||
clientId String?
|
clientId String?
|
||||||
client ClientUser? @relation(fields: [clientId], references: [id])
|
client ClientUser? @relation(fields: [clientId], references: [id])
|
||||||
|
|
||||||
|
// FluxAI telemetry back-ref: which AI conversations converted into this ticket.
|
||||||
|
conversations AiConversation[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -293,6 +315,58 @@ model SiteSetting {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------
|
||||||
|
// 13b. FLUXAI TELEMETRY (Conversaciones + eventos del chat IA)
|
||||||
|
// ------------------------------------------------------
|
||||||
|
// Persiste cada conversación con FluxAI para análisis de funnel B2B.
|
||||||
|
// Una conversación se identifica por sessionId (UUID generado en cliente,
|
||||||
|
// persistido en localStorage). Los eventos individuales (mensajes,
|
||||||
|
// tool calls, errores) viven en AiEvent.
|
||||||
|
model AiConversation {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
sessionId String @unique
|
||||||
|
visitorIp String? // sha256(ip + SESSION_SECRET) — pseudonymous
|
||||||
|
userAgent String?
|
||||||
|
locale String? // "it","en","es","fr","de"
|
||||||
|
pageUrl String? // entry page
|
||||||
|
industryLabel String? // SPIN-detected: "textile","food", etc.
|
||||||
|
funnelStage String @default("DISCOVERY") // DISCOVERY|QUALIFY|RECOMMEND|HANDOFF
|
||||||
|
outcome String @default("OPEN") // OPEN|CONSULTATION|ABANDONED
|
||||||
|
messageCount Int @default(0)
|
||||||
|
toolCallCount Int @default(0)
|
||||||
|
estimatedSavingsPercent Float?
|
||||||
|
productionVolume String?
|
||||||
|
signalId String?
|
||||||
|
signal OperationsSignal? @relation(fields: [signalId], references: [id])
|
||||||
|
startedAt DateTime @default(now())
|
||||||
|
lastMessageAt DateTime @default(now())
|
||||||
|
closedAt DateTime?
|
||||||
|
events AiEvent[]
|
||||||
|
|
||||||
|
@@index([funnelStage])
|
||||||
|
@@index([outcome])
|
||||||
|
@@index([startedAt(sort: Desc)])
|
||||||
|
@@index([industryLabel])
|
||||||
|
}
|
||||||
|
|
||||||
|
model AiEvent {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
conversationId String
|
||||||
|
conversation AiConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||||
|
type String // "user_msg" | "ai_msg" | "tool_call" | "tool_result" | "error"
|
||||||
|
payloadJson String // truncated to 8KB at write time
|
||||||
|
toolName String?
|
||||||
|
latencyMs Int?
|
||||||
|
tokensIn Int?
|
||||||
|
tokensOut Int?
|
||||||
|
cachedTokens Int? // populated when OpenAI returns cached_tokens
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([conversationId, createdAt])
|
||||||
|
@@index([type])
|
||||||
|
@@index([toolName])
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------
|
// ------------------------------------------------------
|
||||||
// 13. CLIENT PORTAL (Usuarios B2B Aprobados)
|
// 13. CLIENT PORTAL (Usuarios B2B Aprobados)
|
||||||
// ------------------------------------------------------
|
// ------------------------------------------------------
|
||||||
@@ -314,3 +388,35 @@ model ClientUser {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------
|
||||||
|
// 14. THE TEAM (Equipo de FLUX — página pública + CMS)
|
||||||
|
// ------------------------------------------------------
|
||||||
|
// Minimal LinkedIn-style profiles. Editable in the HQ Command Center with
|
||||||
|
// drag-to-reorder (same pattern as HeroSlide). Name stays as written; role
|
||||||
|
// and bio are translatable through the AI translation engine. Social links
|
||||||
|
// are all optional — only the ones filled in render on the public card.
|
||||||
|
model TeamMember {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String // Proper name — never translated
|
||||||
|
role String // Job title, e.g. "Founder & CEO" — translatable
|
||||||
|
bio String? // Short biography (Markdown allowed) — translatable
|
||||||
|
photoUrl String? // Portrait, served from /team/ bucket
|
||||||
|
|
||||||
|
// Optional social links — render only when present
|
||||||
|
email String?
|
||||||
|
linkedinUrl String?
|
||||||
|
xUrl String? // X / Twitter
|
||||||
|
websiteUrl String?
|
||||||
|
|
||||||
|
order Int @default(0) // Drag-to-reorder
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
|
||||||
|
// 🌍 Translation engine — holds localized role + bio per locale
|
||||||
|
translationsJson String? @default("{}")
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([isActive, order])
|
||||||
|
}
|
||||||
Executable
+15
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Backup service entrypoint. Runs one backup immediately on start, then loops
|
||||||
|
# every BACKUP_INTERVAL_SECONDS (default 24h). A loop (vs cron) inherits the
|
||||||
|
# container environment cleanly and survives restarts without lost schedules.
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
INTERVAL="${BACKUP_INTERVAL_SECONDS:-86400}"
|
||||||
|
echo "[backup] service started; interval=${INTERVAL}s, retention=${RETENTION_DAYS:-14}d"
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
/usr/local/bin/db-backup.sh || echo "[backup] cycle failed; will retry next interval"
|
||||||
|
sleep "$INTERVAL"
|
||||||
|
done
|
||||||
Executable
+31
@@ -0,0 +1,31 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Single Postgres backup: pg_dump -> gzip -> N-day rotation.
|
||||||
|
# Run by scripts/backup-loop.sh inside the `backup` compose service.
|
||||||
|
# Env: DB_USER, DB_PASSWORD, DB_NAME, BACKUP_DIR, RETENTION_DAYS
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-/backups}"
|
||||||
|
RETENTION_DAYS="${RETENTION_DAYS:-14}"
|
||||||
|
TS=$(date -u +%Y%m%d_%H%M%S)
|
||||||
|
OUT="${BACKUP_DIR}/flux_db_${TS}.sql.gz"
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
export PGPASSWORD="$DB_PASSWORD"
|
||||||
|
|
||||||
|
echo "[backup] $(date -u +%Y-%m-%dT%H:%M:%SZ) starting pg_dump -> ${OUT}"
|
||||||
|
|
||||||
|
# --no-owner/--no-privileges keep the dump portable across roles on restore.
|
||||||
|
if pg_dump -h postgres -U "$DB_USER" -d "$DB_NAME" --no-owner --no-privileges | gzip -9 > "$OUT"; then
|
||||||
|
SIZE=$(du -h "$OUT" | cut -f1)
|
||||||
|
echo "[backup] OK: ${OUT} (${SIZE})"
|
||||||
|
else
|
||||||
|
echo "[backup] FAILED: pg_dump returned non-zero; removing partial file"
|
||||||
|
rm -f "$OUT"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Rotation — drop dumps older than RETENTION_DAYS.
|
||||||
|
DELETED=$(find "$BACKUP_DIR" -name 'flux_db_*.sql.gz' -mtime +"$RETENTION_DAYS" -print -delete 2>/dev/null | wc -l || echo 0)
|
||||||
|
echo "[backup] rotation: kept last ${RETENTION_DAYS} days, pruned ${DELETED} old dump(s)"
|
||||||
@@ -9,6 +9,8 @@ import Script from "next/script";
|
|||||||
import { ArrowLeft, CheckCircle2, Zap, LayoutDashboard, Cpu, PencilRuler, Factory, MapPin, ChevronDown, Play, FileText, Box, Loader2, Maximize, X, ChevronLeft, ChevronRight } from "lucide-react";
|
import { ArrowLeft, CheckCircle2, Zap, LayoutDashboard, Cpu, PencilRuler, Factory, MapPin, ChevronDown, Play, FileText, Box, Loader2, Maximize, X, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import BreathingField from "@/components/visuals/BreathingField";
|
import BreathingField from "@/components/visuals/BreathingField";
|
||||||
import AutoPlayVideo from "@/components/AutoPlayVideo";
|
import AutoPlayVideo from "@/components/AutoPlayVideo";
|
||||||
|
import Breadcrumbs from "@/components/seo/Breadcrumbs";
|
||||||
|
import type { BreadcrumbItem } from "@/components/seo/Breadcrumbs";
|
||||||
|
|
||||||
// 🔥 EL TRUCO DEFINITIVO PARA TYPESCRIPT Y WEB COMPONENTS 🔥
|
// 🔥 EL TRUCO DEFINITIVO PARA TYPESCRIPT Y WEB COMPONENTS 🔥
|
||||||
// Al asignar el string a una variable con 'as any', TypeScript deja de
|
// Al asignar el string a una variable con 'as any', TypeScript deja de
|
||||||
@@ -904,7 +906,7 @@ function ExpandedCaseStudy({ node }: { node: any }) {
|
|||||||
const fullImgSrc = `/cases/${nodeSlug}/${img}`;
|
const fullImgSrc = `/cases/${nodeSlug}/${img}`;
|
||||||
return (
|
return (
|
||||||
<div key={`g-${idx}`} className="relative w-full aspect-video rounded-2xl md:rounded-3xl overflow-hidden border border-black/10 dark:border-white/10 shadow-lg group bg-white">
|
<div key={`g-${idx}`} className="relative w-full aspect-video rounded-2xl md:rounded-3xl overflow-hidden border border-black/10 dark:border-white/10 shadow-lg group bg-white">
|
||||||
<Image src={fullImgSrc} alt="Installation" fill className="object-cover group-hover:scale-105 transition-transform duration-700" />
|
<Image src={fullImgSrc} alt="Installation" fill sizes="(max-width: 768px) 100vw, 50vw" className="object-cover group-hover:scale-105 transition-transform duration-700" />
|
||||||
<button
|
<button
|
||||||
onClick={() => openLightbox(fullImgSrc)}
|
onClick={() => openLightbox(fullImgSrc)}
|
||||||
className="absolute inset-0 w-full h-full bg-black/0 hover:bg-black/30 active:bg-black/40 transition-all flex items-center justify-center opacity-0 group-hover:opacity-100 backdrop-blur-[0px] hover:backdrop-blur-sm touch-manipulation"
|
className="absolute inset-0 w-full h-full bg-black/0 hover:bg-black/30 active:bg-black/40 transition-all flex items-center justify-center opacity-0 group-hover:opacity-100 backdrop-blur-[0px] hover:backdrop-blur-sm touch-manipulation"
|
||||||
@@ -999,9 +1001,24 @@ function ExpandedCaseStudy({ node }: { node: any }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- COMPONENTE PRINCIPAL ---
|
// --- COMPONENTE PRINCIPAL ---
|
||||||
export default function ApplicationClient({ data, realCases, images }: { data: any, realCases: any[], images: any }) {
|
export default function ApplicationClient({ data, realCases, images, breadcrumbs }: { data: any, realCases: any[], images: any, breadcrumbs?: BreadcrumbItem[] }) {
|
||||||
const [expandedCase, setExpandedCase] = useState<string | null>(null);
|
const [expandedCase, setExpandedCase] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Deep-link from the Global Map: a "#case-<id>" hash opens the matching
|
||||||
|
// case study, expands it, and scrolls to it. This is the bridge that
|
||||||
|
// connects a node's modal on the 3D globe to its full write-up here.
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const hash = window.location.hash;
|
||||||
|
if (!hash.startsWith("#case-")) return;
|
||||||
|
const id = decodeURIComponent(hash.slice("#case-".length));
|
||||||
|
setExpandedCase(id);
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
document.getElementById(`case-${id}`)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
}, 350);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [mainLightboxOpen, setMainLightboxOpen] = useState(false);
|
const [mainLightboxOpen, setMainLightboxOpen] = useState(false);
|
||||||
const [mainLightboxImages, setMainLightboxImages] = useState<string[]>([]);
|
const [mainLightboxImages, setMainLightboxImages] = useState<string[]>([]);
|
||||||
const [mainLightboxInitialIndex, setMainLightboxInitialIndex] = useState(0);
|
const [mainLightboxInitialIndex, setMainLightboxInitialIndex] = useState(0);
|
||||||
@@ -1045,7 +1062,7 @@ export default function ApplicationClient({ data, realCases, images }: { data: a
|
|||||||
|
|
||||||
<section className="relative w-full h-[50vh] md:h-[70vh] flex items-end justify-start overflow-hidden">
|
<section className="relative w-full h-[50vh] md:h-[70vh] flex items-end justify-start overflow-hidden">
|
||||||
{heroImage ? (
|
{heroImage ? (
|
||||||
<Image src={heroImage} alt={data.title} fill className="object-cover object-center scale-105 animate-slow-zoom" priority />
|
<Image src={heroImage} alt={data.title} fill sizes="100vw" className="object-cover object-center scale-105 animate-slow-zoom" priority />
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-[#0066CC]/20 to-black/80" />
|
<div className="absolute inset-0 bg-gradient-to-br from-[#0066CC]/20 to-black/80" />
|
||||||
)}
|
)}
|
||||||
@@ -1053,6 +1070,7 @@ export default function ApplicationClient({ data, realCases, images }: { data: a
|
|||||||
|
|
||||||
<div className="relative z-10 max-w-4xl mx-auto px-6 pb-12 md:pb-16 w-full">
|
<div className="relative z-10 max-w-4xl mx-auto px-6 pb-12 md:pb-16 w-full">
|
||||||
<header>
|
<header>
|
||||||
|
{breadcrumbs && <Breadcrumbs items={breadcrumbs} />}
|
||||||
<div className="inline-flex items-center gap-2 text-[#0066CC] dark:text-[#00F0FF] mb-3 bg-white/80 dark:bg-black/80 backdrop-blur-sm px-3 py-1.5 rounded-full border border-black/5 dark:border-white/10">
|
<div className="inline-flex items-center gap-2 text-[#0066CC] dark:text-[#00F0FF] mb-3 bg-white/80 dark:bg-black/80 backdrop-blur-sm px-3 py-1.5 rounded-full border border-black/5 dark:border-white/10">
|
||||||
<LayoutDashboard size={14} />
|
<LayoutDashboard size={14} />
|
||||||
<span className="text-[10px] md:text-xs font-semibold uppercase tracking-widest">{data.category}</span>
|
<span className="text-[10px] md:text-xs font-semibold uppercase tracking-widest">{data.category}</span>
|
||||||
@@ -1144,12 +1162,12 @@ export default function ApplicationClient({ data, realCases, images }: { data: a
|
|||||||
{realCases.map((node) => {
|
{realCases.map((node) => {
|
||||||
const isExpanded = expandedCase === node.id;
|
const isExpanded = expandedCase === node.id;
|
||||||
return (
|
return (
|
||||||
<div key={node.id} className="bg-white/80 dark:bg-[#111]/90 backdrop-blur-2xl border border-black/10 dark:border-white/10 rounded-2xl md:rounded-[2.5rem] overflow-hidden transition-all duration-500 shadow-xl">
|
<div key={node.id} id={`case-${node.id}`} className="scroll-mt-28 bg-white/80 dark:bg-[#111]/90 backdrop-blur-2xl border border-black/10 dark:border-white/10 rounded-2xl md:rounded-[2.5rem] overflow-hidden transition-all duration-500 shadow-xl target:ring-2 target:ring-[#0066CC] dark:target:ring-[#00F0FF]">
|
||||||
<div onClick={() => setExpandedCase(isExpanded ? null : node.id)} className="p-5 md:p-8 cursor-pointer flex flex-col md:flex-row items-start md:items-center justify-between gap-5 group hover:bg-black/5 dark:hover:bg-white/[0.02] transition-colors">
|
<div onClick={() => setExpandedCase(isExpanded ? null : node.id)} className="p-5 md:p-8 cursor-pointer flex flex-col md:flex-row items-start md:items-center justify-between gap-5 group hover:bg-black/5 dark:hover:bg-white/[0.02] transition-colors">
|
||||||
<div className="flex items-center gap-5 flex-1">
|
<div className="flex items-center gap-5 flex-1">
|
||||||
<div className="w-16 h-16 md:w-20 md:h-20 rounded-2xl md:rounded-3xl bg-black/5 dark:bg-black/50 border border-black/10 dark:border-white/5 flex items-center justify-center shrink-0 overflow-hidden relative shadow-inner">
|
<div className="w-16 h-16 md:w-20 md:h-20 rounded-2xl md:rounded-3xl bg-black/5 dark:bg-black/50 border border-black/10 dark:border-white/5 flex items-center justify-center shrink-0 overflow-hidden relative shadow-inner">
|
||||||
{node.mediaFileName ? (
|
{node.mediaFileName ? (
|
||||||
<Image src={`/cases/${nodeToSlug(node.title)}/${node.mediaFileName}`} alt={node.title} fill className="object-cover opacity-90 group-hover:scale-110 transition-transform duration-700" />
|
<Image src={`/cases/${nodeToSlug(node.title)}/${node.mediaFileName}`} alt={node.title} fill sizes="100px" className="object-cover opacity-90 group-hover:scale-110 transition-transform duration-700" />
|
||||||
) : (
|
) : (
|
||||||
<Cpu size={28} className="text-[#0066CC]/50 dark:text-[#00F0FF]/50" />
|
<Cpu size={28} className="text-[#0066CC]/50 dark:text-[#00F0FF]/50" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -158,24 +158,28 @@ export default async function ApplicationPage({ params }: { params: Promise<{ sl
|
|||||||
const images = getApplicationImages(slug);
|
const images = getApplicationImages(slug);
|
||||||
|
|
||||||
// 4. JSON-LD structured data — wrapped to never break the render.
|
// 4. JSON-LD structured data — wrapped to never break the render.
|
||||||
|
const appTitle = data?.title || "FLUX Application";
|
||||||
|
const appUrl = `${baseUrl()}/${locale}/applications/${slug}`;
|
||||||
|
const crumbs = [
|
||||||
|
{ name: "Home", url: `/${locale}` },
|
||||||
|
{ name: "Applications", url: `/${locale}#applications-deep` },
|
||||||
|
{ name: appTitle, url: `/${locale}/applications/${slug}` },
|
||||||
|
];
|
||||||
|
|
||||||
let jsonLd: object[] = [];
|
let jsonLd: object[] = [];
|
||||||
try {
|
try {
|
||||||
const url = `${baseUrl()}/${locale}/applications/${slug}`;
|
|
||||||
const title = data?.title || "FLUX Application";
|
|
||||||
const description = data?.shortDescription || data?.subtitle || "";
|
const description = data?.shortDescription || data?.subtitle || "";
|
||||||
jsonLd = [
|
jsonLd = [
|
||||||
productSchema({
|
productSchema({
|
||||||
name: title,
|
name: appTitle,
|
||||||
description,
|
description,
|
||||||
imageUrl: images.heroImage || undefined,
|
imageUrl: images.heroImage || undefined,
|
||||||
category: data?.category || "RF Industrial",
|
category: data?.category || "RF Industrial",
|
||||||
url,
|
url: appUrl,
|
||||||
}),
|
}),
|
||||||
breadcrumbSchema([
|
breadcrumbSchema(
|
||||||
{ name: "Home", url: `${baseUrl()}/${locale}` },
|
crumbs.map((c) => ({ name: c.name, url: `${baseUrl()}${c.url}` }))
|
||||||
{ name: "Applications", url: `${baseUrl()}/${locale}#applications-deep` },
|
),
|
||||||
{ name: title, url },
|
|
||||||
]),
|
|
||||||
];
|
];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[applications/${slug}] JSON-LD build failed:`, error);
|
console.error(`[applications/${slug}] JSON-LD build failed:`, error);
|
||||||
@@ -184,7 +188,7 @@ export default async function ApplicationPage({ params }: { params: Promise<{ sl
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{jsonLd.length > 0 && <JsonLd data={jsonLd} />}
|
{jsonLd.length > 0 && <JsonLd data={jsonLd} />}
|
||||||
<ApplicationClient data={data} realCases={realCases} images={images} />
|
<ApplicationClient data={data} realCases={realCases} images={images} breadcrumbs={crumbs} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ export default async function HeritagePage({ params }: { params: Promise<{ local
|
|||||||
|
|
||||||
{sec.type === 'image' && sec.mediaUrl && (
|
{sec.type === 'image' && sec.mediaUrl && (
|
||||||
<div className="relative w-full h-80 md:h-[500px] rounded-3xl overflow-hidden border border-black/10 dark:border-white/10 shadow-2xl">
|
<div className="relative w-full h-80 md:h-[500px] rounded-3xl overflow-hidden border border-black/10 dark:border-white/10 shadow-2xl">
|
||||||
<Image src={`/heritage/${sec.mediaUrl}`} alt={sec.title || "Heritage Image"} fill className="object-cover grayscale hover:grayscale-0 transition-all duration-1000" />
|
<Image src={`/heritage/${sec.mediaUrl}`} alt={sec.title || "Heritage Image"} fill sizes="100vw" className="object-cover grayscale hover:grayscale-0 transition-all duration-1000" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import NavigationManager from "@/components/layout/NavigationManager";
|
|||||||
import SilentObserver from "@/components/ai/SilentObserver";
|
import SilentObserver from "@/components/ai/SilentObserver";
|
||||||
import Footer from "@/components/layout/Footer";
|
import Footer from "@/components/layout/Footer";
|
||||||
import CartDrawer from "@/components/layout/CartDrawer";
|
import CartDrawer from "@/components/layout/CartDrawer";
|
||||||
|
import GoogleAnalytics from "@/components/analytics/GoogleAnalytics";
|
||||||
|
import PageViewTracker from "@/components/analytics/PageViewTracker";
|
||||||
|
import ConsentBanner from "@/components/analytics/ConsentBanner";
|
||||||
|
|
||||||
import { NextIntlClientProvider } from 'next-intl';
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
import { getMessages, setRequestLocale } from 'next-intl/server';
|
import { getMessages, setRequestLocale } from 'next-intl/server';
|
||||||
@@ -20,7 +23,7 @@ export function generateStaticParams() {
|
|||||||
return routing.locales.map((locale) => ({ locale }));
|
return routing.locales.map((locale) => ({ locale }));
|
||||||
}
|
}
|
||||||
import { getBranding, getSocialLinks } from '@/lib/siteSettings';
|
import { getBranding, getSocialLinks } from '@/lib/siteSettings';
|
||||||
import { organizationSchema, websiteSchema } from '@/lib/seo';
|
import { organizationSchema, websiteSchema, localBusinessSchema } from '@/lib/seo';
|
||||||
import JsonLd from '@/components/seo/JsonLd';
|
import JsonLd from '@/components/seo/JsonLd';
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] });
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
@@ -76,12 +79,17 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
apple: branding.appleTouchIconUrl,
|
apple: branding.appleTouchIconUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Google Search Console verification (HTML-tag method). Emits
|
||||||
|
// <meta name="google-site-verification" content="..."> when set.
|
||||||
|
const gscToken = process.env.NEXT_PUBLIC_GSC_VERIFICATION;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
metadataBase: new URL(APP_BASE_URL),
|
metadataBase: new URL(APP_BASE_URL),
|
||||||
title: "FLUX | Energy, Directed.",
|
title: "FLUX | Energy, Directed.",
|
||||||
description: "Advanced Radio Frequency Solutions by Patrizio Grando.",
|
description: "Advanced Radio Frequency Solutions by Patrizio Grando.",
|
||||||
icons,
|
icons,
|
||||||
manifest: "/manifest.webmanifest",
|
manifest: "/manifest.webmanifest",
|
||||||
|
...(gscToken ? { verification: { google: gscToken } } : {}),
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "FLUX | Energy, Directed.",
|
title: "FLUX | Energy, Directed.",
|
||||||
description: "Advanced Radio Frequency Solutions by Patrizio Grando.",
|
description: "Advanced Radio Frequency Solutions by Patrizio Grando.",
|
||||||
@@ -154,6 +162,10 @@ export default async function RootLayout({
|
|||||||
logoUrl: branding.logoUrl,
|
logoUrl: branding.logoUrl,
|
||||||
sameAs: sameAs.length ? sameAs : undefined,
|
sameAs: sameAs.length ? sameAs : undefined,
|
||||||
}),
|
}),
|
||||||
|
localBusinessSchema({
|
||||||
|
logoUrl: branding.logoUrl,
|
||||||
|
sameAs: sameAs.length ? sameAs : undefined,
|
||||||
|
}),
|
||||||
websiteSchema(),
|
websiteSchema(),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -169,15 +181,24 @@ export default async function RootLayout({
|
|||||||
<NavigationManager />
|
<NavigationManager />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
|
{/* Analytics — page-view tracker needs Suspense (useSearchParams) */}
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<PageViewTracker />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
<div className="flex-grow w-full flex flex-col relative">
|
<div className="flex-grow w-full flex flex-col relative">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Footer locale={locale} />
|
<Footer locale={locale} />
|
||||||
<SilentObserver />
|
<SilentObserver />
|
||||||
|
<ConsentBanner />
|
||||||
|
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
|
|
||||||
|
{/* GA4 loader (Consent Mode v2). No-ops when NEXT_PUBLIC_GA_ID unset. */}
|
||||||
|
<GoogleAnalytics />
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { ArrowLeft, Calendar, Tag, Linkedin } from "lucide-react";
|
import { ArrowLeft, Calendar, Tag, Linkedin } from "lucide-react";
|
||||||
import BreathingField from "@/components/visuals/BreathingField";
|
import BreathingField from "@/components/visuals/BreathingField";
|
||||||
|
|
||||||
import { setRequestLocale } from "next-intl/server";
|
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||||
import { getLocalizedData } from "@/lib/i18nHelper";
|
import { getLocalizedData } from "@/lib/i18nHelper";
|
||||||
|
import Breadcrumbs from "@/components/seo/Breadcrumbs";
|
||||||
import {
|
import {
|
||||||
buildPageMetadata,
|
buildPageMetadata,
|
||||||
articleSchema,
|
articleSchema,
|
||||||
@@ -227,6 +228,7 @@ export default async function ArticlePage({ params }: { params: Promise<{ slug:
|
|||||||
const resolvedParams = await params;
|
const resolvedParams = await params;
|
||||||
const { slug, locale } = resolvedParams;
|
const { slug, locale } = resolvedParams;
|
||||||
setRequestLocale(locale);
|
setRequestLocale(locale);
|
||||||
|
const t = await getTranslations({ locale, namespace: "ArticlePage" });
|
||||||
|
|
||||||
let rawArticle: any = null;
|
let rawArticle: any = null;
|
||||||
try {
|
try {
|
||||||
@@ -259,6 +261,12 @@ export default async function ArticlePage({ params }: { params: Promise<{ slug:
|
|||||||
const articleUrl = `${baseUrl()}/${locale}/news/${slug}`;
|
const articleUrl = `${baseUrl()}/${locale}/news/${slug}`;
|
||||||
const cover = article.coverImage ? `/news/${slug}/${article.coverImage}` : undefined;
|
const cover = article.coverImage ? `/news/${slug}/${article.coverImage}` : undefined;
|
||||||
|
|
||||||
|
const crumbs = [
|
||||||
|
{ name: "Home", url: `/${locale}` },
|
||||||
|
{ name: "News", url: `/${locale}/news` },
|
||||||
|
{ name: article?.title || "Article", url: `/${locale}/news/${slug}` },
|
||||||
|
];
|
||||||
|
|
||||||
let jsonLd: object[] = [];
|
let jsonLd: object[] = [];
|
||||||
try {
|
try {
|
||||||
jsonLd = [
|
jsonLd = [
|
||||||
@@ -270,11 +278,9 @@ export default async function ArticlePage({ params }: { params: Promise<{ slug:
|
|||||||
publishedAt: article?.publishedAt || new Date(),
|
publishedAt: article?.publishedAt || new Date(),
|
||||||
updatedAt: article?.updatedAt || new Date(),
|
updatedAt: article?.updatedAt || new Date(),
|
||||||
}),
|
}),
|
||||||
breadcrumbSchema([
|
breadcrumbSchema(
|
||||||
{ name: "Home", url: `${baseUrl()}/${locale}` },
|
crumbs.map((c) => ({ name: c.name, url: `${baseUrl()}${c.url}` }))
|
||||||
{ name: "News", url: `${baseUrl()}/${locale}/news` },
|
),
|
||||||
{ name: article?.title || "Article", url: articleUrl },
|
|
||||||
]),
|
|
||||||
];
|
];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[news/${slug}] JSON-LD build failed:`, error);
|
console.error(`[news/${slug}] JSON-LD build failed:`, error);
|
||||||
@@ -288,20 +294,23 @@ export default async function ArticlePage({ params }: { params: Promise<{ slug:
|
|||||||
<div className="fixed top-24 left-6 z-50 hidden md:block">
|
<div className="fixed top-24 left-6 z-50 hidden md:block">
|
||||||
{/* EL BOTÓN DE VOLVER AHORA RESPETA EL IDIOMA */}
|
{/* EL BOTÓN DE VOLVER AHORA RESPETA EL IDIOMA */}
|
||||||
<Link href={`/${locale}/news`} className="inline-flex items-center gap-2 text-sm font-medium text-[#86868B] hover:text-[#0066CC] dark:hover:text-[#4DA6FF] transition-colors py-2 px-4 bg-white/50 dark:bg-black/50 backdrop-blur-md rounded-full group shadow-sm border border-black/5 dark:border-white/10">
|
<Link href={`/${locale}/news`} className="inline-flex items-center gap-2 text-sm font-medium text-[#86868B] hover:text-[#0066CC] dark:hover:text-[#4DA6FF] transition-colors py-2 px-4 bg-white/50 dark:bg-black/50 backdrop-blur-md rounded-full group shadow-sm border border-black/5 dark:border-white/10">
|
||||||
<ArrowLeft size={16} className="group-hover:-translate-x-1 transition-transform" /> Back to News Hub
|
<ArrowLeft size={16} className="group-hover:-translate-x-1 transition-transform" /> {t("backToNewsHub")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="relative w-full h-[50vh] md:h-[70vh] flex items-end justify-center overflow-hidden bg-[#1D1D1F]">
|
<section className="relative w-full h-[50vh] md:h-[70vh] flex items-end justify-center overflow-hidden bg-[#1D1D1F]">
|
||||||
{article.coverImage && (
|
{article.coverImage && (
|
||||||
<Image src={`/news/${slug}/${article.coverImage}`} alt={article.title} fill className="object-cover object-center opacity-60" priority />
|
<Image src={`/news/${slug}/${article.coverImage}`} alt={article.title} fill sizes="100vw" className="object-cover object-center opacity-60" priority />
|
||||||
)}
|
)}
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-white via-white/80 dark:from-[#0A0A0C] dark:via-[#0A0A0C]/80 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-white via-white/80 dark:from-[#0A0A0C] dark:via-[#0A0A0C]/80 to-transparent" />
|
||||||
|
|
||||||
<div className="relative z-10 max-w-4xl w-full mx-auto px-6 pb-12 md:pb-20 text-center">
|
<div className="relative z-10 max-w-4xl w-full mx-auto px-6 pb-12 md:pb-20 text-center">
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<Breadcrumbs items={crumbs} />
|
||||||
|
</div>
|
||||||
<div className="flex flex-wrap items-center justify-center gap-4 text-xs font-semibold uppercase tracking-widest text-[#0066CC] dark:text-[#4DA6FF] mb-6">
|
<div className="flex flex-wrap items-center justify-center gap-4 text-xs font-semibold uppercase tracking-widest text-[#0066CC] dark:text-[#4DA6FF] mb-6">
|
||||||
<span className="flex items-center gap-1.5 bg-[#0066CC]/10 dark:bg-[#4DA6FF]/10 px-3 py-1.5 rounded-full"><Tag size={14}/> {article.category}</span>
|
<span className="flex items-center gap-1.5 bg-[#0066CC]/10 dark:bg-[#4DA6FF]/10 px-3 py-1.5 rounded-full"><Tag size={14}/> {article.category}</span>
|
||||||
<span className="flex items-center gap-1.5 text-[#86868B]"><Calendar size={14}/> {new Date(article.publishedAt).toLocaleDateString(locale === 'en' ? 'en-US' : locale, { month: 'long', day: 'numeric', year: 'numeric' })}</span>
|
<time dateTime={new Date(article.publishedAt).toISOString()} className="flex items-center gap-1.5 text-[#86868B]"><Calendar size={14}/> {new Date(article.publishedAt).toLocaleDateString(locale === 'en' ? 'en-US' : locale, { month: 'long', day: 'numeric', year: 'numeric' })}</time>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-4xl md:text-6xl font-medium text-[#1D1D1F] dark:text-[#F5F5F7] tracking-tight mb-6 leading-tight">
|
<h1 className="text-4xl md:text-6xl font-medium text-[#1D1D1F] dark:text-[#F5F5F7] tracking-tight mb-6 leading-tight">
|
||||||
{article.title}
|
{article.title}
|
||||||
@@ -312,7 +321,7 @@ export default async function ArticlePage({ params }: { params: Promise<{ slug:
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="relative z-10 max-w-3xl mx-auto px-6 mt-12 md:mt-20">
|
<article className="relative z-10 max-w-3xl mx-auto px-6 mt-12 md:mt-20">
|
||||||
|
|
||||||
{/* 🔥 EL MARKDOWN TRADUCIDO AL VUELO 🔥 */}
|
{/* 🔥 EL MARKDOWN TRADUCIDO AL VUELO 🔥 */}
|
||||||
<div className="max-w-none mb-16">
|
<div className="max-w-none mb-16">
|
||||||
@@ -321,11 +330,11 @@ export default async function ArticlePage({ params }: { params: Promise<{ slug:
|
|||||||
|
|
||||||
{gallery.length > 0 && (
|
{gallery.length > 0 && (
|
||||||
<div className="mt-16 pt-16 border-t border-black/5 dark:border-white/5">
|
<div className="mt-16 pt-16 border-t border-black/5 dark:border-white/5">
|
||||||
<h3 className="text-xl font-medium mb-8 text-[#1D1D1F] dark:text-white">Media Gallery</h3>
|
<h3 className="text-xl font-medium mb-8 text-[#1D1D1F] dark:text-white">{t("mediaGallery")}</h3>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
{gallery.map((imgSrc: string, idx: number) => (
|
{gallery.map((imgSrc: string, idx: number) => (
|
||||||
<div key={idx} className={`relative rounded-3xl overflow-hidden bg-[#1D1D1F] border border-black/10 dark:border-white/10 ${idx === 0 && gallery.length % 2 !== 0 ? 'sm:col-span-2 h-64 md:h-96' : 'h-48 md:h-64'}`}>
|
<div key={idx} className={`relative rounded-3xl overflow-hidden bg-[#1D1D1F] border border-black/10 dark:border-white/10 ${idx === 0 && gallery.length % 2 !== 0 ? 'sm:col-span-2 h-64 md:h-96' : 'h-48 md:h-64'}`}>
|
||||||
<Image src={`/news/${slug}/gallery/${imgSrc}`} alt={`Gallery image ${idx + 1}`} fill className="object-cover hover:scale-105 transition-transform duration-700" />
|
<Image src={`/news/${slug}/gallery/${imgSrc}`} alt={`Gallery image ${idx + 1}`} fill sizes="(max-width: 640px) 100vw, 50vw" className="object-cover hover:scale-105 transition-transform duration-700" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -334,7 +343,7 @@ export default async function ArticlePage({ params }: { params: Promise<{ slug:
|
|||||||
|
|
||||||
<div className="mt-16 pt-8 border-t border-black/10 dark:border-white/10 flex justify-between items-center">
|
<div className="mt-16 pt-8 border-t border-black/10 dark:border-white/10 flex justify-between items-center">
|
||||||
<Link href={`/${locale}/news`} className="text-[#0066CC] dark:text-[#4DA6FF] font-medium flex items-center gap-2 hover:underline md:hidden">
|
<Link href={`/${locale}/news`} className="text-[#0066CC] dark:text-[#4DA6FF] font-medium flex items-center gap-2 hover:underline md:hidden">
|
||||||
<ArrowLeft size={16} /> Back to News
|
<ArrowLeft size={16} /> {t("backToNews")}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{article.linkedinUrl ? (
|
{article.linkedinUrl ? (
|
||||||
@@ -344,15 +353,15 @@ export default async function ArticlePage({ params }: { params: Promise<{ slug:
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-2 text-sm font-semibold text-white bg-[#0A66C2] px-6 py-3 rounded-full hover:bg-[#004182] transition-all shadow-lg shadow-[#0A66C2]/20 ml-auto md:ml-0"
|
className="flex items-center gap-2 text-sm font-semibold text-white bg-[#0A66C2] px-6 py-3 rounded-full hover:bg-[#004182] transition-all shadow-lg shadow-[#0A66C2]/20 ml-auto md:ml-0"
|
||||||
>
|
>
|
||||||
<Linkedin size={16} /> Join the conversation on LinkedIn
|
<Linkedin size={16} /> {t("joinLinkedIn")}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-xs text-[#86868B] italic hidden md:block">
|
<div className="text-xs text-[#86868B] italic hidden md:block">
|
||||||
Internal Corporate Release
|
{t("internalRelease")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import BreathingField from "@/components/visuals/BreathingField";
|
|||||||
|
|
||||||
import { getLocalizedData } from "@/lib/i18nHelper";
|
import { getLocalizedData } from "@/lib/i18nHelper";
|
||||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||||
import { buildPageMetadata } from "@/lib/seo";
|
import { buildPageMetadata, collectionPageSchema, baseUrl } from "@/lib/seo";
|
||||||
|
import JsonLd from "@/components/seo/JsonLd";
|
||||||
|
|
||||||
// ISR: revalidate every 60s — see /[locale]/page.tsx for the full rationale.
|
// ISR: revalidate every 60s — see /[locale]/page.tsx for the full rationale.
|
||||||
export const revalidate = 60;
|
export const revalidate = 60;
|
||||||
@@ -49,8 +50,22 @@ export default async function NewsHub({ params }: { params: Promise<{ locale: st
|
|||||||
const heroArticle = articles.length > 0 ? articles[0] : null;
|
const heroArticle = articles.length > 0 ? articles[0] : null;
|
||||||
const gridArticles = articles.length > 1 ? articles.slice(1) : [];
|
const gridArticles = articles.length > 1 ? articles.slice(1) : [];
|
||||||
|
|
||||||
|
const collectionSchema = articles.length > 0
|
||||||
|
? collectionPageSchema({
|
||||||
|
name: `${t("title1")} ${t("title2")} — FLUX`,
|
||||||
|
description: t("description"),
|
||||||
|
url: `${baseUrl()}/${locale}/news`,
|
||||||
|
items: articles.map((a: any, idx: number) => ({
|
||||||
|
name: a.title,
|
||||||
|
url: `${baseUrl()}/${locale}/news/${a.slug}`,
|
||||||
|
position: idx + 1,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="relative min-h-screen pt-32 pb-24">
|
<main className="relative min-h-screen pt-32 pb-24">
|
||||||
|
{collectionSchema && <JsonLd data={collectionSchema} />}
|
||||||
<BreathingField />
|
<BreathingField />
|
||||||
|
|
||||||
<div className="relative z-10 max-w-7xl mx-auto px-6">
|
<div className="relative z-10 max-w-7xl mx-auto px-6">
|
||||||
@@ -76,10 +91,11 @@ export default async function NewsHub({ params }: { params: Promise<{ locale: st
|
|||||||
|
|
||||||
{/* HERO ARTICLE */}
|
{/* HERO ARTICLE */}
|
||||||
{heroArticle && (
|
{heroArticle && (
|
||||||
|
<article>
|
||||||
<Link href={`/${locale}/news/${heroArticle.slug}`} className="group relative w-full rounded-[2rem] overflow-hidden bg-white/60 dark:bg-[#1D1D1F]/40 backdrop-blur-2xl border border-black/5 dark:border-white/10 flex flex-col md:flex-row shadow-lg hover:shadow-xl transition-all duration-500 hover:-translate-y-1">
|
<Link href={`/${locale}/news/${heroArticle.slug}`} className="group relative w-full rounded-[2rem] overflow-hidden bg-white/60 dark:bg-[#1D1D1F]/40 backdrop-blur-2xl border border-black/5 dark:border-white/10 flex flex-col md:flex-row shadow-lg hover:shadow-xl transition-all duration-500 hover:-translate-y-1">
|
||||||
<div className="w-full md:w-1/2 h-56 md:h-[400px] relative overflow-hidden bg-[#1D1D1F]">
|
<div className="w-full md:w-1/2 h-56 md:h-[400px] relative overflow-hidden bg-[#1D1D1F]">
|
||||||
{heroArticle.coverImage ? (
|
{heroArticle.coverImage ? (
|
||||||
<Image src={`/news/${heroArticle.slug}/${heroArticle.coverImage}`} alt={heroArticle.title} fill className="object-cover transition-transform duration-700 group-hover:scale-105" />
|
<Image src={`/news/${heroArticle.slug}/${heroArticle.coverImage}`} alt={heroArticle.title} fill sizes="(max-width: 768px) 100vw, 50vw" className="object-cover transition-transform duration-700 group-hover:scale-105" />
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-[#0066CC]/20 to-[#0A0A0C] flex items-center justify-center"><Newspaper size={64} className="text-white/10" /></div>
|
<div className="absolute inset-0 bg-gradient-to-br from-[#0066CC]/20 to-[#0A0A0C] flex items-center justify-center"><Newspaper size={64} className="text-white/10" /></div>
|
||||||
)}
|
)}
|
||||||
@@ -87,7 +103,7 @@ export default async function NewsHub({ params }: { params: Promise<{ locale: st
|
|||||||
<div className="w-full md:w-1/2 p-6 md:p-10 flex flex-col justify-center">
|
<div className="w-full md:w-1/2 p-6 md:p-10 flex flex-col justify-center">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<span className="text-[9px] font-bold uppercase tracking-widest text-[#0066CC] dark:text-[#4DA6FF] bg-[#0066CC]/10 dark:bg-[#4DA6FF]/10 px-2.5 py-1 rounded-full">{heroArticle.category}</span>
|
<span className="text-[9px] font-bold uppercase tracking-widest text-[#0066CC] dark:text-[#4DA6FF] bg-[#0066CC]/10 dark:bg-[#4DA6FF]/10 px-2.5 py-1 rounded-full">{heroArticle.category}</span>
|
||||||
<span className="text-[10px] font-medium text-[#86868B] flex items-center gap-1"><Calendar size={12}/> {new Date(heroArticle.publishedAt).toLocaleDateString(locale === 'en' ? 'en-US' : locale, { month: 'short', day: 'numeric', year: 'numeric' })}</span>
|
<time dateTime={new Date(heroArticle.publishedAt).toISOString()} className="text-[10px] font-medium text-[#86868B] flex items-center gap-1"><Calendar size={12}/> {new Date(heroArticle.publishedAt).toLocaleDateString(locale === 'en' ? 'en-US' : locale, { month: 'short', day: 'numeric', year: 'numeric' })}</time>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl md:text-3xl font-medium text-[#1D1D1F] dark:text-white mb-3 leading-tight group-hover:text-[#0066CC] dark:group-hover:text-[#4DA6FF] transition-colors">{heroArticle.title}</h2>
|
<h2 className="text-2xl md:text-3xl font-medium text-[#1D1D1F] dark:text-white mb-3 leading-tight group-hover:text-[#0066CC] dark:group-hover:text-[#4DA6FF] transition-colors">{heroArticle.title}</h2>
|
||||||
<p className="text-sm text-[#86868B] dark:text-[#A1A1A6] leading-relaxed mb-6 line-clamp-3">{heroArticle.excerpt}</p>
|
<p className="text-sm text-[#86868B] dark:text-[#A1A1A6] leading-relaxed mb-6 line-clamp-3">{heroArticle.excerpt}</p>
|
||||||
@@ -96,16 +112,18 @@ export default async function NewsHub({ params }: { params: Promise<{ locale: st
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
</article>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* GRID COLUMNAS */}
|
{/* GRID COLUMNAS */}
|
||||||
{gridArticles.length > 0 && (
|
{gridArticles.length > 0 && (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6 mt-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6 mt-4">
|
||||||
{gridArticles.map((article) => (
|
{gridArticles.map((article) => (
|
||||||
<Link key={article.id} href={`/${locale}/news/${article.slug}`} className="group flex flex-col bg-white/60 dark:bg-[#1D1D1F]/40 backdrop-blur-md border border-black/5 dark:border-white/10 rounded-3xl overflow-hidden shadow-sm hover:shadow-md transition-all duration-500 hover:-translate-y-1">
|
<article key={article.id} className="flex flex-col">
|
||||||
|
<Link href={`/${locale}/news/${article.slug}`} className="group flex flex-col flex-1 bg-white/60 dark:bg-[#1D1D1F]/40 backdrop-blur-md border border-black/5 dark:border-white/10 rounded-3xl overflow-hidden shadow-sm hover:shadow-md transition-all duration-500 hover:-translate-y-1">
|
||||||
<div className="w-full h-40 relative overflow-hidden bg-[#1D1D1F]">
|
<div className="w-full h-40 relative overflow-hidden bg-[#1D1D1F]">
|
||||||
{article.coverImage ? (
|
{article.coverImage ? (
|
||||||
<Image src={`/news/${article.slug}/${article.coverImage}`} alt={article.title} fill className="object-cover transition-transform duration-700 group-hover:scale-105" />
|
<Image src={`/news/${article.slug}/${article.coverImage}`} alt={article.title} fill sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" className="object-cover transition-transform duration-700 group-hover:scale-105" />
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-[#0066CC]/10 to-[#0A0A0C]" />
|
<div className="absolute inset-0 bg-gradient-to-br from-[#0066CC]/10 to-[#0A0A0C]" />
|
||||||
)}
|
)}
|
||||||
@@ -113,7 +131,7 @@ export default async function NewsHub({ params }: { params: Promise<{ locale: st
|
|||||||
<div className="p-5 flex flex-col flex-1">
|
<div className="p-5 flex flex-col flex-1">
|
||||||
<div className="flex justify-between items-center mb-3">
|
<div className="flex justify-between items-center mb-3">
|
||||||
<span className="text-[8px] font-bold uppercase tracking-widest text-[#0066CC] dark:text-[#4DA6FF]">{article.category}</span>
|
<span className="text-[8px] font-bold uppercase tracking-widest text-[#0066CC] dark:text-[#4DA6FF]">{article.category}</span>
|
||||||
<span className="text-[9px] text-[#86868B]">{new Date(article.publishedAt).toLocaleDateString(locale === 'en' ? 'en-US' : locale, { month: 'short', day: 'numeric' })}</span>
|
<time dateTime={new Date(article.publishedAt).toISOString()} className="text-[9px] text-[#86868B]">{new Date(article.publishedAt).toLocaleDateString(locale === 'en' ? 'en-US' : locale, { month: 'short', day: 'numeric' })}</time>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-base font-medium text-[#1D1D1F] dark:text-white mb-2 leading-snug group-hover:text-[#0066CC] dark:group-hover:text-[#4DA6FF] transition-colors line-clamp-2">{article.title}</h3>
|
<h3 className="text-base font-medium text-[#1D1D1F] dark:text-white mb-2 leading-snug group-hover:text-[#0066CC] dark:group-hover:text-[#4DA6FF] transition-colors line-clamp-2">{article.title}</h3>
|
||||||
<p className="text-xs text-[#86868B] dark:text-[#A1A1A6] line-clamp-2 mb-4">{article.excerpt}</p>
|
<p className="text-xs text-[#86868B] dark:text-[#A1A1A6] line-clamp-2 mb-4">{article.excerpt}</p>
|
||||||
@@ -122,6 +140,7 @@ export default async function NewsHub({ params }: { params: Promise<{ locale: st
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export default async function Home({ params }: { params: Promise<{ locale: strin
|
|||||||
shortDescription: true, heroDescription: true,
|
shortDescription: true, heroDescription: true,
|
||||||
dashboardMetricsJson: true, isActive: true, translationsJson: true
|
dashboardMetricsJson: true, isActive: true, translationsJson: true
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: "asc" }
|
orderBy: [{ order: "asc" }, { createdAt: "asc" }]
|
||||||
});
|
});
|
||||||
dbApps = rawApps.map((app: any) => getLocalizedData(app as any, locale));
|
dbApps = rawApps.map((app: any) => getLocalizedData(app as any, locale));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export default function AuthModal({ session }: { session: any }) {
|
|||||||
setError(res.error);
|
setError(res.error);
|
||||||
} else {
|
} else {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
|
// NavBar listens to this event to refresh its session badge without polling.
|
||||||
|
if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent("flux:session-changed"));
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -87,6 +89,7 @@ export default function AuthModal({ session }: { session: any }) {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await logoutClient();
|
await logoutClient();
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
|
if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent("flux:session-changed"));
|
||||||
router.refresh();
|
router.refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { setRequestLocale } from "next-intl/server";
|
||||||
|
import { buildPageMetadata } from "@/lib/seo";
|
||||||
|
import Breadcrumbs from "@/components/seo/Breadcrumbs";
|
||||||
|
|
||||||
|
// Static legal page. Revalidate rarely.
|
||||||
|
export const revalidate = 86400;
|
||||||
|
|
||||||
|
const LAST_UPDATED = "June 2026";
|
||||||
|
const COMPANY = "FLUX Srl";
|
||||||
|
const ADDRESS = "Romano d'Ezzelino, Vicenza, Italy";
|
||||||
|
const CONTACT_EMAIL = "privacy@rf-flux.com"; // TODO: confirm with FLUX legal
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { locale } = await params;
|
||||||
|
return buildPageMetadata({
|
||||||
|
locale,
|
||||||
|
pathWithoutLocale: "privacy",
|
||||||
|
title: "Privacy & Cookie Policy | FLUX",
|
||||||
|
description:
|
||||||
|
"How FLUX Srl collects, uses and protects personal data on rf-flux.com, in compliance with the EU GDPR.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PrivacyPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
|
||||||
|
const crumbs = [
|
||||||
|
{ name: "Home", url: `/${locale}` },
|
||||||
|
{ name: "Privacy & Cookie Policy", url: `/${locale}/privacy` },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="relative w-full min-h-screen bg-[#F5F5F7] dark:bg-[#050505]">
|
||||||
|
<div className="max-w-3xl mx-auto px-6 pt-28 md:pt-36 pb-24">
|
||||||
|
<Breadcrumbs items={crumbs} />
|
||||||
|
|
||||||
|
<header className="mt-6 mb-10">
|
||||||
|
<h1 className="text-3xl md:text-5xl font-light text-[#1D1D1F] dark:text-white tracking-tight">
|
||||||
|
Privacy & Cookie <span className="font-medium">Policy</span>
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 text-sm text-[#86868B] dark:text-[#A1A1A6]">Last updated: {LAST_UPDATED}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Template disclaimer — remove once reviewed by legal counsel */}
|
||||||
|
<div className="mb-10 rounded-2xl border border-amber-300/50 dark:border-amber-400/30 bg-amber-50 dark:bg-amber-400/10 p-4 text-sm text-amber-900 dark:text-amber-200">
|
||||||
|
<strong>Template notice:</strong> this is a standard GDPR-compliant
|
||||||
|
template provided as a starting point. Please have it reviewed and
|
||||||
|
adapted by your legal counsel before relying on it, and confirm the
|
||||||
|
contact details below.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-8 text-[#1D1D1F] dark:text-[#F5F5F7]">
|
||||||
|
<Section title="1. Who we are">
|
||||||
|
<P>
|
||||||
|
{COMPANY} (“we”, “us”, “our”)
|
||||||
|
is the data controller responsible for your personal data
|
||||||
|
collected through this website, {SITE}. Our registered office is
|
||||||
|
in {ADDRESS}.
|
||||||
|
</P>
|
||||||
|
<P>
|
||||||
|
For any privacy-related request you can contact us at{" "}
|
||||||
|
<a href={`mailto:${CONTACT_EMAIL}`} className="text-[#0066CC] dark:text-[#00F0FF] underline underline-offset-2">
|
||||||
|
{CONTACT_EMAIL}
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</P>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="2. What data we collect">
|
||||||
|
<P>We collect personal data only when you actively provide it, or through privacy-respecting analytics:</P>
|
||||||
|
<ul className="list-disc pl-5 space-y-1.5 text-[#3A3A3C]">
|
||||||
|
<li>
|
||||||
|
<strong>Contact & consultation requests:</strong> name, company,
|
||||||
|
email, phone (optional) and any message you send through our
|
||||||
|
forms or the FLUX AI assistant.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>AI assistant conversations:</strong> the messages you
|
||||||
|
exchange with the on-site assistant, used to answer your
|
||||||
|
questions and improve the service. Your IP address is stored
|
||||||
|
only in pseudonymised (hashed) form.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Analytics:</strong> aggregated, anonymised usage data
|
||||||
|
via Google Analytics 4 — but only after you accept analytics
|
||||||
|
cookies (see section 4).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Technical logs:</strong> standard server logs (IP,
|
||||||
|
browser, timestamps) kept for security and troubleshooting.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="3. How and why we use it">
|
||||||
|
<P>We process your data on the following legal bases (GDPR Art. 6):</P>
|
||||||
|
<ul className="list-disc pl-5 space-y-1.5 text-[#3A3A3C]">
|
||||||
|
<li><strong>Consent</strong> — analytics cookies; you can withdraw it at any time.</li>
|
||||||
|
<li><strong>Pre-contractual / legitimate interest</strong> — responding to your consultation and quote requests.</li>
|
||||||
|
<li><strong>Legitimate interest</strong> — keeping the site secure and improving our products and content.</li>
|
||||||
|
</ul>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="4. Cookies & consent">
|
||||||
|
<P>
|
||||||
|
We use a strictly necessary set of cookies to run the site and,
|
||||||
|
optionally, analytics cookies. When you first visit, a banner lets
|
||||||
|
you accept or decline analytics. We use Google Consent Mode v2:
|
||||||
|
until you accept, no analytics cookies are set and no personal
|
||||||
|
data is sent to Google. You can change your choice at any time by
|
||||||
|
clearing the site cookies in your browser.
|
||||||
|
</P>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="5. Who we share data with">
|
||||||
|
<P>We never sell your data. We share it only with trusted processors strictly to operate the site:</P>
|
||||||
|
<ul className="list-disc pl-5 space-y-1.5 text-[#3A3A3C]">
|
||||||
|
<li><strong>Google (Analytics)</strong> — anonymised usage statistics, only with your consent.</li>
|
||||||
|
<li><strong>Email / hosting providers</strong> — to deliver your requests to our team and host the site.</li>
|
||||||
|
</ul>
|
||||||
|
<P>
|
||||||
|
Some providers may process data outside the EU/EEA; where that
|
||||||
|
happens, transfers are covered by appropriate safeguards such as
|
||||||
|
the EU Standard Contractual Clauses.
|
||||||
|
</P>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="6. How long we keep it">
|
||||||
|
<P>
|
||||||
|
We keep consultation and contact data for as long as needed to
|
||||||
|
handle your request and to comply with legal obligations, then
|
||||||
|
delete or anonymise it. Analytics data is retained according to
|
||||||
|
Google Analytics’ configured retention period.
|
||||||
|
</P>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="7. Your rights">
|
||||||
|
<P>Under the GDPR you have the right to:</P>
|
||||||
|
<ul className="list-disc pl-5 space-y-1.5 text-[#3A3A3C]">
|
||||||
|
<li>access, rectify or erase your personal data;</li>
|
||||||
|
<li>restrict or object to processing;</li>
|
||||||
|
<li>data portability;</li>
|
||||||
|
<li>withdraw consent at any time;</li>
|
||||||
|
<li>lodge a complaint with your data protection authority (in Italy, the Garante per la protezione dei dati personali).</li>
|
||||||
|
</ul>
|
||||||
|
<P>
|
||||||
|
To exercise any of these rights, contact us at{" "}
|
||||||
|
<a href={`mailto:${CONTACT_EMAIL}`} className="text-[#0066CC] dark:text-[#00F0FF] underline underline-offset-2">
|
||||||
|
{CONTACT_EMAIL}
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</P>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="8. Data security">
|
||||||
|
<P>
|
||||||
|
We apply appropriate technical and organisational measures
|
||||||
|
(encryption in transit, access controls, pseudonymisation) to
|
||||||
|
protect your data against unauthorised access, loss or misuse.
|
||||||
|
</P>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="9. Changes to this policy">
|
||||||
|
<P>
|
||||||
|
We may update this policy from time to time. The “last
|
||||||
|
updated” date at the top reflects the latest revision.
|
||||||
|
</P>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SITE = "rf-flux.com";
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg md:text-xl font-semibold text-[#1D1D1F] dark:text-white mb-3">{title}</h2>
|
||||||
|
<div className="space-y-3 text-[15px] leading-relaxed">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function P({ children }: { children: React.ReactNode }) {
|
||||||
|
return <p className="text-[#3A3A3C] dark:text-[#A1A1A6]">{children}</p>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Linkedin, Mail, Globe, Twitter, User } from "lucide-react";
|
||||||
|
import { trackEvent } from "@/lib/analytics/gtag";
|
||||||
|
|
||||||
|
export interface TeamCard {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
bio: string | null;
|
||||||
|
photoUrl: string | null;
|
||||||
|
email: string | null;
|
||||||
|
linkedinUrl: string | null;
|
||||||
|
xUrl: string | null;
|
||||||
|
websiteUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TeamGrid({ members }: { members: TeamCard[] }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
|
||||||
|
{members.map((m, i) => (
|
||||||
|
<motion.article
|
||||||
|
key={m.id}
|
||||||
|
initial={{ opacity: 0, y: 24 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-60px" }}
|
||||||
|
transition={{ duration: 0.5, delay: Math.min(i * 0.06, 0.4), ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="group relative flex flex-col rounded-3xl bg-white dark:bg-[#111] border border-black/[0.06] dark:border-white/10 shadow-[0_2px_20px_rgba(0,0,0,0.04)] hover:shadow-[0_12px_40px_rgba(0,0,0,0.10)] dark:shadow-[0_2px_20px_rgba(0,0,0,0.4)] transition-all duration-500 overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Portrait */}
|
||||||
|
<div className="relative aspect-[4/5] w-full overflow-hidden bg-gradient-to-br from-[#EEF2F5] to-[#E3E9ED] dark:from-[#1A1A1C] dark:to-[#0E0E10]">
|
||||||
|
{m.photoUrl ? (
|
||||||
|
<Image
|
||||||
|
src={m.photoUrl}
|
||||||
|
alt={m.name}
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||||
|
className="object-cover transition-transform duration-700 group-hover:scale-[1.03]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-[#B0B8BF]">
|
||||||
|
<User size={64} strokeWidth={1} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Subtle gradient for text legibility if needed later */}
|
||||||
|
<div className="absolute inset-x-0 bottom-0 h-1/3 bg-gradient-to-t from-black/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex flex-col flex-1 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-[#1D1D1F] dark:text-white tracking-tight">{m.name}</h3>
|
||||||
|
<p className="text-[#0066CC] dark:text-[#00F0FF] text-xs font-medium uppercase tracking-[0.12em] mt-1">
|
||||||
|
{m.role}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{m.bio && (
|
||||||
|
<p className="mt-4 text-sm leading-relaxed text-[#6E6E73] dark:text-[#A1A1A6] line-clamp-5">{m.bio}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Social links — only the ones that exist */}
|
||||||
|
<div className="mt-auto pt-5 flex items-center gap-2">
|
||||||
|
{m.linkedinUrl && (
|
||||||
|
<SocialLink href={m.linkedinUrl} label={`${m.name} on LinkedIn`} name={m.name} network="linkedin">
|
||||||
|
<Linkedin size={16} />
|
||||||
|
</SocialLink>
|
||||||
|
)}
|
||||||
|
{m.xUrl && (
|
||||||
|
<SocialLink href={m.xUrl} label={`${m.name} on X`} name={m.name} network="x">
|
||||||
|
<Twitter size={16} />
|
||||||
|
</SocialLink>
|
||||||
|
)}
|
||||||
|
{m.websiteUrl && (
|
||||||
|
<SocialLink href={m.websiteUrl} label={`${m.name} website`} name={m.name} network="web">
|
||||||
|
<Globe size={16} />
|
||||||
|
</SocialLink>
|
||||||
|
)}
|
||||||
|
{m.email && (
|
||||||
|
<SocialLink href={`mailto:${m.email}`} label={`Email ${m.name}`} name={m.name} network="email" external={false}>
|
||||||
|
<Mail size={16} />
|
||||||
|
</SocialLink>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SocialLink({
|
||||||
|
href, label, children, name, network, external = true,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
name: string;
|
||||||
|
network: string;
|
||||||
|
external?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
{...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||||
|
onClick={() => trackEvent({ name: "contact_cta_clicked", params: { location: `team:${network}` } })}
|
||||||
|
className="inline-flex items-center justify-center w-9 h-9 rounded-full border border-black/[0.08] dark:border-white/15 text-[#6E6E73] dark:text-[#A1A1A6] hover:text-white hover:bg-[#1D1D1F] hover:border-[#1D1D1F] dark:hover:bg-[#00F0FF] dark:hover:text-black dark:hover:border-[#00F0FF] transition-colors"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getLocalizedData } from "@/lib/i18nHelper";
|
||||||
|
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||||
|
import { buildPageMetadata, baseUrl } from "@/lib/seo";
|
||||||
|
import JsonLd from "@/components/seo/JsonLd";
|
||||||
|
import Breadcrumbs from "@/components/seo/Breadcrumbs";
|
||||||
|
import BreathingField from "@/components/visuals/BreathingField";
|
||||||
|
import TeamGrid, { type TeamCard } from "./TeamGrid";
|
||||||
|
|
||||||
|
// ISR: revalidate every 60s, like the other public pages.
|
||||||
|
export const revalidate = 60;
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { locale } = await params;
|
||||||
|
const t = await getTranslations({ locale, namespace: "TeamPage" });
|
||||||
|
return buildPageMetadata({
|
||||||
|
locale,
|
||||||
|
pathWithoutLocale: "team",
|
||||||
|
title: `${t("eyebrow")} | FLUX`,
|
||||||
|
description: t("description"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function TeamPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
const t = await getTranslations({ locale, namespace: "TeamPage" });
|
||||||
|
|
||||||
|
let members: TeamCard[] = [];
|
||||||
|
try {
|
||||||
|
const rows = await prisma.teamMember.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: [{ order: "asc" }, { createdAt: "asc" }],
|
||||||
|
});
|
||||||
|
members = rows.map((row) => {
|
||||||
|
const localized = getLocalizedData(row, locale);
|
||||||
|
return {
|
||||||
|
id: localized.id,
|
||||||
|
name: localized.name,
|
||||||
|
role: localized.role,
|
||||||
|
bio: localized.bio,
|
||||||
|
photoUrl: localized.photoUrl,
|
||||||
|
email: localized.email,
|
||||||
|
linkedinUrl: localized.linkedinUrl,
|
||||||
|
xUrl: localized.xUrl,
|
||||||
|
websiteUrl: localized.websiteUrl,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[team] DB error fetching members:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON-LD: a Person entity per member, plus a breadcrumb trail.
|
||||||
|
const orgUrl = baseUrl();
|
||||||
|
const personSchemas = members.map((m) => ({
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Person",
|
||||||
|
name: m.name,
|
||||||
|
jobTitle: m.role,
|
||||||
|
worksFor: { "@type": "Organization", name: "FLUX Srl", url: orgUrl },
|
||||||
|
...(m.photoUrl ? { image: `${orgUrl}${m.photoUrl}` } : {}),
|
||||||
|
...(m.linkedinUrl ? { sameAs: [m.linkedinUrl] } : {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const crumbs = [
|
||||||
|
{ name: "Home", url: `/${locale}` },
|
||||||
|
{ name: t("eyebrow"), url: `/${locale}/team` },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{personSchemas.length > 0 && <JsonLd data={personSchemas} />}
|
||||||
|
|
||||||
|
<main className="relative w-full min-h-screen bg-[#F5F5F7] dark:bg-[#050505] overflow-hidden">
|
||||||
|
{/* Ambient visual, consistent with the News / Heritage hubs */}
|
||||||
|
<div className="absolute inset-0 opacity-60 pointer-events-none">
|
||||||
|
<BreathingField />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 max-w-7xl mx-auto px-6 pt-28 md:pt-36 pb-24">
|
||||||
|
<Breadcrumbs items={crumbs} />
|
||||||
|
|
||||||
|
<header className="max-w-3xl mt-6 mb-16 md:mb-24">
|
||||||
|
<p className="text-[#0066CC] dark:text-[#00F0FF] text-xs md:text-sm font-semibold uppercase tracking-[0.2em] mb-4">
|
||||||
|
{t("eyebrow")}
|
||||||
|
</p>
|
||||||
|
<h1 className="text-4xl md:text-6xl font-light text-[#1D1D1F] dark:text-white tracking-tight leading-[1.05]">
|
||||||
|
{t("title1")}{" "}
|
||||||
|
<span className="font-medium">{t("title2")}</span>
|
||||||
|
</h1>
|
||||||
|
<p className="mt-6 text-base md:text-lg text-[#6E6E73] dark:text-[#A1A1A6] leading-relaxed max-w-2xl">
|
||||||
|
{t("description")}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{members.length === 0 ? (
|
||||||
|
<div className="text-center py-24 text-[#86868B] dark:text-[#A1A1A6]">
|
||||||
|
<p>{t("empty")}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TeamGrid members={members} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,13 @@ import bcrypt from "bcryptjs";
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { SignJWT, jwtVerify } from "jose";
|
import { SignJWT, jwtVerify } from "jose";
|
||||||
|
|
||||||
const getSecretKey = () => new TextEncoder().encode(process.env.SESSION_SECRET || "flux-super-secret-key-2026");
|
const getSecretKey = () => {
|
||||||
|
const s = process.env.SESSION_SECRET;
|
||||||
|
if (!s || s.length < 32) {
|
||||||
|
throw new Error("SESSION_SECRET environment variable is required (min 32 chars).");
|
||||||
|
}
|
||||||
|
return new TextEncoder().encode(s);
|
||||||
|
};
|
||||||
|
|
||||||
export async function registerClientRequest(formData: FormData) {
|
export async function registerClientRequest(formData: FormData) {
|
||||||
const fullName = formData.get("fullName") as string;
|
const fullName = formData.get("fullName") as string;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { sendEmail } from "@/lib/mailer";
|
import { sendEmail } from "@/lib/mailer";
|
||||||
|
import { escapeHtml, escapeAttr, safeMailto } from "@/lib/escapeHtml";
|
||||||
|
|
||||||
// ── Helper: Generate sequential ticket ID ──
|
// ── Helper: Generate sequential ticket ID ──
|
||||||
async function generateTicketId(type: string): Promise<string> {
|
async function generateTicketId(type: string): Promise<string> {
|
||||||
@@ -91,7 +92,10 @@ export async function submitOperationsSignal(payload: {
|
|||||||
replyTo: payload.clientEmail,
|
replyTo: payload.clientEmail,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track email delivery in DB
|
// Track email delivery — best-effort. The signal (lead) is already saved,
|
||||||
|
// so a telemetry-update hiccup must NOT fail the request and make the
|
||||||
|
// client retry into a duplicate.
|
||||||
|
try {
|
||||||
await prisma.operationsSignal.update({
|
await prisma.operationsSignal.update({
|
||||||
where: { id: signal.id },
|
where: { id: signal.id },
|
||||||
data: {
|
data: {
|
||||||
@@ -100,8 +104,11 @@ export async function submitOperationsSignal(payload: {
|
|||||||
emailError: emailResult.error,
|
emailError: emailResult.error,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
} catch (trackErr) {
|
||||||
|
console.warn("[operations] email tracking update failed (lead already saved):", trackErr);
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true, ticketId, emailSent: emailResult.success };
|
return { success: true, ticketId, emailSent: emailResult.success, emailError: emailResult.error };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error submitting signal:", error);
|
console.error("Error submitting signal:", error);
|
||||||
return { error: "Failed to submit request. Please try again." };
|
return { error: "Failed to submit request. Please try again." };
|
||||||
@@ -119,16 +126,20 @@ function generateRichEmailHtml(payload: any, ticketId: string, aiAnalysis: strin
|
|||||||
const cartRows = cartItems.map((item: any) => `
|
const cartRows = cartItems.map((item: any) => `
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 16px 12px; border-bottom: 1px solid #E5E5EA;">
|
<td style="padding: 16px 12px; border-bottom: 1px solid #E5E5EA;">
|
||||||
<strong style="color: #1D1D1F; font-size: 14px;">${item.title}</strong><br/>
|
<strong style="color: #1D1D1F; font-size: 14px;">${escapeHtml(item.title)}</strong><br/>
|
||||||
<span style="color: #86868B; font-size: 11px; font-family: monospace;">SKU: ${item.sku}</span>
|
<span style="color: #86868B; font-size: 11px; font-family: monospace;">SKU: ${escapeHtml(item.sku)}</span>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 16px 12px; border-bottom: 1px solid #E5E5EA; text-align: center; font-family: monospace; font-weight: 600; color: #1D1D1F;">${item.quantity}</td>
|
<td style="padding: 16px 12px; border-bottom: 1px solid #E5E5EA; text-align: center; font-family: monospace; font-weight: 600; color: #1D1D1F;">${escapeHtml(item.quantity)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
||||||
const fileLinks = files.map((fileUrl: string) => {
|
const fileLinks = files
|
||||||
|
// Only accept internal paths (start with a single "/"); ignore anything
|
||||||
|
// that could point off-site or break out of the href attribute.
|
||||||
|
.filter((fileUrl: string) => typeof fileUrl === 'string' && /^\/[^/]/.test(fileUrl))
|
||||||
|
.map((fileUrl: string) => {
|
||||||
const isVideo = fileUrl.endsWith('.mp4') || fileUrl.endsWith('.mov');
|
const isVideo = fileUrl.endsWith('.mp4') || fileUrl.endsWith('.mov');
|
||||||
return `<a href="${appUrl}${fileUrl}" style="display: inline-block; padding: 10px 16px; background: #0066CC; color: white; text-decoration: none; border-radius: 8px; margin: 4px 8px 4px 0; font-size: 13px; font-weight: 600; text-align: center;">View ${isVideo ? 'Video' : 'Image'}</a>`;
|
return `<a href="${escapeAttr(appUrl + fileUrl)}" style="display: inline-block; padding: 10px 16px; background: #0066CC; color: white; text-decoration: none; border-radius: 8px; margin: 4px 8px 4px 0; font-size: 13px; font-weight: 600; text-align: center;">View ${isVideo ? 'Video' : 'Image'}</a>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -138,21 +149,21 @@ function generateRichEmailHtml(payload: any, ticketId: string, aiAnalysis: strin
|
|||||||
<div style="padding: 40px 32px 32px 32px; text-align: center; border-bottom: 1px solid #E5E5EA;">
|
<div style="padding: 40px 32px 32px 32px; text-align: center; border-bottom: 1px solid #E5E5EA;">
|
||||||
<img src="${appUrl}/logoEmail.png" alt="FLUX SRL" style="height: 50px; width: auto; object-fit: contain; margin-bottom: 24px;" />
|
<img src="${appUrl}/logoEmail.png" alt="FLUX SRL" style="height: 50px; width: auto; object-fit: contain; margin-bottom: 24px;" />
|
||||||
<p style="color: #0066CC; font-size: 11px; letter-spacing: 2px; text-transform: uppercase; margin: 0; font-weight: 700;">Operations Command</p>
|
<p style="color: #0066CC; font-size: 11px; letter-spacing: 2px; text-transform: uppercase; margin: 0; font-weight: 700;">Operations Command</p>
|
||||||
<h1 style="margin: 12px 0 16px 0; font-size: 26px; font-weight: 300; color: #1D1D1F; letter-spacing: -0.5px;">Incoming ${payload.type} Signal</h1>
|
<h1 style="margin: 12px 0 16px 0; font-size: 26px; font-weight: 300; color: #1D1D1F; letter-spacing: -0.5px;">Incoming ${escapeHtml(payload.type)} Signal</h1>
|
||||||
<span style="display: inline-block; padding: 6px 16px; background-color: #F5F5F7; border-radius: 20px; font-family: monospace; color: #1D1D1F; font-size: 13px; font-weight: 600; border: 1px solid #E5E5EA;">Ticket: ${ticketId}</span>
|
<span style="display: inline-block; padding: 6px 16px; background-color: #F5F5F7; border-radius: 20px; font-family: monospace; color: #1D1D1F; font-size: 13px; font-weight: 600; border: 1px solid #E5E5EA;">Ticket: ${escapeHtml(ticketId)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="padding: 32px; color: #1D1D1F;">
|
<div style="padding: 32px; color: #1D1D1F;">
|
||||||
<div style="background-color: #F5F5F7; padding: 24px; border-radius: 12px; margin-bottom: 32px; border: 1px solid #E5E5EA;">
|
<div style="background-color: #F5F5F7; padding: 24px; border-radius: 12px; margin-bottom: 32px; border: 1px solid #E5E5EA;">
|
||||||
<p style="margin: 0 0 8px 0; font-size: 15px;"><strong>${payload.clientName}</strong> · ${payload.clientCompany}</p>
|
<p style="margin: 0 0 8px 0; font-size: 15px;"><strong>${escapeHtml(payload.clientName)}</strong> · ${escapeHtml(payload.clientCompany)}</p>
|
||||||
<p style="margin: 0 0 8px 0; font-size: 14px; color: #86868B;">Email: <a href="mailto:${payload.clientEmail}" style="color: #0066CC; text-decoration: none;">${payload.clientEmail}</a></p>
|
<p style="margin: 0 0 8px 0; font-size: 14px; color: #86868B;">Email: <a href="${escapeAttr(safeMailto(payload.clientEmail))}" style="color: #0066CC; text-decoration: none;">${escapeHtml(payload.clientEmail)}</a></p>
|
||||||
<p style="margin: 0; font-size: 14px; color: #86868B;">Phone: ${payload.clientPhone || 'N/A'}</p>
|
<p style="margin: 0; font-size: 14px; color: #86868B;">Phone: ${payload.clientPhone ? escapeHtml(payload.clientPhone) : 'N/A'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${payload.message ? `
|
${payload.message ? `
|
||||||
<div style="margin-bottom: 32px;">
|
<div style="margin-bottom: 32px;">
|
||||||
<h3 style="font-size: 12px; text-transform: uppercase; color: #86868B; letter-spacing: 1px; margin-bottom: 12px;">Client Notes</h3>
|
<h3 style="font-size: 12px; text-transform: uppercase; color: #86868B; letter-spacing: 1px; margin-bottom: 12px;">Client Notes</h3>
|
||||||
<div style="padding: 20px; border-left: 4px solid #1D1D1F; background: #FAFAFA; border-radius: 0 8px 8px 0; font-size: 14px; line-height: 1.6;">${payload.message}</div>
|
<div style="padding: 20px; border-left: 4px solid #1D1D1F; background: #FAFAFA; border-radius: 0 8px 8px 0; font-size: 14px; line-height: 1.6;">${escapeHtml(payload.message)}</div>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,15 @@ import fs from "fs";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { revalidateContent, type RevalidateScope } from "@/lib/revalidate";
|
import { revalidateContent, type RevalidateScope } from "@/lib/revalidate";
|
||||||
import { optimizeImage, isOptimizable } from "@/lib/imageOptimizer";
|
import { optimizeImage, isOptimizable } from "@/lib/imageOptimizer";
|
||||||
|
import { getAdminSession } from "@/lib/session";
|
||||||
|
|
||||||
|
// All asset operations are admin-only. The middleware (src/proxy.ts) does NOT
|
||||||
|
// cover /api, so each handler must verify the admin session itself.
|
||||||
|
async function requireAdmin(): Promise<NextResponse | null> {
|
||||||
|
const session = await getAdminSession();
|
||||||
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const SCOPE_ROOTS: Record<string, string> = {
|
const SCOPE_ROOTS: Record<string, string> = {
|
||||||
applications: path.join(process.cwd(), "public", "applications"),
|
applications: path.join(process.cwd(), "public", "applications"),
|
||||||
@@ -43,10 +52,12 @@ const SCOPE_ROOTS: Record<string, string> = {
|
|||||||
footage: path.join(process.cwd(), "public", "footage", "main"),
|
footage: path.join(process.cwd(), "public", "footage", "main"),
|
||||||
// 🔥 NUEVO: Site-wide brand assets (favicon, logo, OG image)
|
// 🔥 NUEVO: Site-wide brand assets (favicon, logo, OG image)
|
||||||
branding: path.join(process.cwd(), "public", "branding"),
|
branding: path.join(process.cwd(), "public", "branding"),
|
||||||
|
// 🔥 NUEVO: Team member portraits (flat folder, slug ignored)
|
||||||
|
team: path.join(process.cwd(), "public", "team"),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Scopes that ignore the `slug` parameter and write directly under their root.
|
// Scopes that ignore the `slug` parameter and write directly under their root.
|
||||||
const FLAT_SCOPES = new Set(["footage", "branding"]);
|
const FLAT_SCOPES = new Set(["footage", "branding", "team"]);
|
||||||
|
|
||||||
const MEDIA_TYPES: Record<string, string[]> = {
|
const MEDIA_TYPES: Record<string, string[]> = {
|
||||||
image: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg", ".avif"],
|
image: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg", ".avif"],
|
||||||
@@ -104,6 +115,7 @@ function buildSafePath(scope: string, slug: string, subPath?: string): string |
|
|||||||
function buildPublicUrl(scope: string, slug: string, rel: string): string {
|
function buildPublicUrl(scope: string, slug: string, rel: string): string {
|
||||||
if (scope === "footage") return `/footage/main/${rel}`;
|
if (scope === "footage") return `/footage/main/${rel}`;
|
||||||
if (scope === "branding") return `/branding/${rel}`;
|
if (scope === "branding") return `/branding/${rel}`;
|
||||||
|
if (scope === "team") return `/team/${rel}`;
|
||||||
return `/${scope}/${slug}/${rel}`;
|
return `/${scope}/${slug}/${rel}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +132,7 @@ function buildBreadcrumbs(subPath: string) {
|
|||||||
|
|
||||||
// GET — List files and folders
|
// GET — List files and folders
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
|
const unauth = await requireAdmin(); if (unauth) return unauth;
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const scope = searchParams.get("scope") || "applications";
|
const scope = searchParams.get("scope") || "applications";
|
||||||
@@ -195,6 +208,7 @@ export async function GET(request: NextRequest) {
|
|||||||
// produces a different hash, so the browser cache invalidates instantly
|
// produces a different hash, so the browser cache invalidates instantly
|
||||||
// without any header trickery.
|
// without any header trickery.
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
const unauth = await requireAdmin(); if (unauth) return unauth;
|
||||||
try {
|
try {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const scope = (formData.get("scope") as string) || "applications";
|
const scope = (formData.get("scope") as string) || "applications";
|
||||||
@@ -277,6 +291,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// PUT — Create a new folder
|
// PUT — Create a new folder
|
||||||
export async function PUT(request: NextRequest) {
|
export async function PUT(request: NextRequest) {
|
||||||
|
const unauth = await requireAdmin(); if (unauth) return unauth;
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { scope = "applications", slug = "", folderName, parentPath = "" } = body;
|
const { scope = "applications", slug = "", folderName, parentPath = "" } = body;
|
||||||
@@ -312,6 +327,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
// { scope, slug, filePath: "..." } single delete
|
// { scope, slug, filePath: "..." } single delete
|
||||||
// { scope, slug, filePaths: ["a", "b", ...] } bulk delete
|
// { scope, slug, filePaths: ["a", "b", ...] } bulk delete
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
|
const unauth = await requireAdmin(); if (unauth) return unauth;
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { scope = "applications", slug = "", filePath, filePaths } = body;
|
const { scope = "applications", slug = "", filePath, filePaths } = body;
|
||||||
@@ -368,6 +384,7 @@ export async function DELETE(request: NextRequest) {
|
|||||||
// Cannot overwrite an existing file (returns 409). Sanitises target name
|
// Cannot overwrite an existing file (returns 409). Sanitises target name
|
||||||
// the same way upload does, and creates intermediate folders if needed.
|
// the same way upload does, and creates intermediate folders if needed.
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
|
const unauth = await requireAdmin(); if (unauth) return unauth;
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { scope = "applications", slug = "", fromPath, toPath } = body;
|
const { scope = "applications", slug = "", fromPath, toPath } = body;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import sharp from "sharp";
|
|||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { revalidateContent } from "@/lib/revalidate";
|
import { revalidateContent } from "@/lib/revalidate";
|
||||||
|
import { getAdminSession } from "@/lib/session";
|
||||||
|
|
||||||
interface VariantSpec {
|
interface VariantSpec {
|
||||||
size: number;
|
size: number;
|
||||||
@@ -48,6 +49,9 @@ const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB cap
|
|||||||
const ALLOWED_EXT = new Set([".png", ".jpg", ".jpeg", ".webp"]);
|
const ALLOWED_EXT = new Set([".png", ".jpg", ".jpeg", ".webp"]);
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
// Admin-only: the middleware does not cover /api, so guard here.
|
||||||
|
const session = await getAdminSession();
|
||||||
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
try {
|
try {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const file = formData.get("file") as File | null;
|
const file = formData.get("file") as File | null;
|
||||||
|
|||||||
+399
-40
@@ -1,8 +1,10 @@
|
|||||||
import { openai } from '@ai-sdk/openai';
|
import { openai } from '@ai-sdk/openai';
|
||||||
import { streamText, stepCountIs, UIMessage, convertToModelMessages, tool } from 'ai';
|
import { streamText, stepCountIs, UIMessage, convertToModelMessages, tool } from 'ai';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { checkChatRateLimit } from '@/lib/rateLimit';
|
import { checkChatRateLimit, getClientIp } from '@/lib/rateLimit';
|
||||||
|
import { log } from '@/lib/logger';
|
||||||
|
|
||||||
export const maxDuration = 60;
|
export const maxDuration = 60;
|
||||||
|
|
||||||
@@ -37,9 +39,24 @@ const COMPARISON_DATA: Record<string, { rf: number; traditional: number; unit: s
|
|||||||
// ─── DYNAMIC SYSTEM PROMPT BUILDER ──────────────────────────────
|
// ─── DYNAMIC SYSTEM PROMPT BUILDER ──────────────────────────────
|
||||||
// Injects real-time database context so the AI knows what exists
|
// Injects real-time database context so the AI knows what exists
|
||||||
|
|
||||||
|
// Cache the built prompt briefly so we don't run 4 DB queries on every single
|
||||||
|
// chat message. CMS changes appear within the TTL. Only healthy builds are
|
||||||
|
// cached, so a transient DB outage retries on the next message.
|
||||||
|
let _promptCache: { value: string; at: number } | null = null;
|
||||||
|
const SYSTEM_PROMPT_TTL_MS = 60_000;
|
||||||
|
|
||||||
async function buildSystemPrompt(): Promise<string> {
|
async function buildSystemPrompt(): Promise<string> {
|
||||||
// Query real data from Prisma
|
if (_promptCache && Date.now() - _promptCache.at < SYSTEM_PROMPT_TTL_MS) {
|
||||||
const [activeApps, installationCount, eventCount, partsCount] = await Promise.all([
|
return _promptCache.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live DB context. If Postgres is unreachable, fall back to safe defaults so
|
||||||
|
// the assistant still answers (degraded) instead of 500-ing the whole chat.
|
||||||
|
let activeApps: Array<{ slug: string; title: string; shortDescription: string; category: string }> = [];
|
||||||
|
let installationCount = 0, eventCount = 0, partsCount = 0;
|
||||||
|
let dbOk = true;
|
||||||
|
try {
|
||||||
|
[activeApps, installationCount, eventCount, partsCount] = await Promise.all([
|
||||||
prisma.application.findMany({
|
prisma.application.findMany({
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
select: { slug: true, title: true, shortDescription: true, category: true },
|
select: { slug: true, title: true, shortDescription: true, category: true },
|
||||||
@@ -49,10 +66,16 @@ async function buildSystemPrompt(): Promise<string> {
|
|||||||
prisma.globalNode.count({ where: { nodeType: 'event', isActive: true } }),
|
prisma.globalNode.count({ where: { nodeType: 'event', isActive: true } }),
|
||||||
prisma.sparePart.count({ where: { isActive: true } }),
|
prisma.sparePart.count({ where: { isActive: true } }),
|
||||||
]);
|
]);
|
||||||
|
} catch (e) {
|
||||||
|
dbOk = false;
|
||||||
|
log.warn('chat.system_prompt_db_unavailable', { err: String(e) });
|
||||||
|
}
|
||||||
|
|
||||||
const appList = activeApps.map((a: any) => ` - ${a.title} (slug: "${a.slug}", category: ${a.category})`).join('\n');
|
const appList = activeApps.length
|
||||||
|
? activeApps.map((a) => ` - ${a.title} (slug: "${a.slug}", category: ${a.category})`).join('\n')
|
||||||
|
: ' (live catalog temporarily unavailable — describe FLUX applications from general RF knowledge)';
|
||||||
|
|
||||||
return `You are "FluxAI", the intelligent engineering advisor and sales specialist for FLUX Srl — a world leader in solid-state Radio Frequency (RF), Microwave, and Infrared industrial equipment. Founded by Patrizio Grando with 40+ years of legacy. Headquarters: Romano d'Ezzelino, Vicenza, Italy.
|
const prompt = `You are "FluxAI", the intelligent engineering advisor and sales specialist for FLUX Srl — a world leader in solid-state Radio Frequency (RF), Microwave, and Infrared industrial equipment. Founded by Patrizio Grando with 40+ years of legacy. Headquarters: Romano d'Ezzelino, Vicenza, Italy.
|
||||||
|
|
||||||
PERSONALITY:
|
PERSONALITY:
|
||||||
- Senior RF engineer who also understands business ROI.
|
- Senior RF engineer who also understands business ROI.
|
||||||
@@ -89,45 +112,62 @@ Example of a perfect autonomous flow:
|
|||||||
6. You output your final text referencing real data, the case study card, and gently offer a consultation.
|
6. You output your final text referencing real data, the case study card, and gently offer a consultation.
|
||||||
|
|
||||||
═══════════════════════════════════════════
|
═══════════════════════════════════════════
|
||||||
SALES METHODOLOGY — SPIN FRAMEWORK:
|
SALES METHODOLOGY — FUNNEL-AWARE SPIN:
|
||||||
═══════════════════════════════════════════
|
═══════════════════════════════════════════
|
||||||
Before deploying tools, qualify the prospect through natural conversation:
|
|
||||||
|
|
||||||
S (Situación): What's their current process? What method? What volume?
|
STAGE 1 — QUALIFY (S+P from SPIN):
|
||||||
P (Problema): What's not working? Energy costs? Quality issues? Speed?
|
Trigger: User mentions an industry or problem WITHOUT specifics.
|
||||||
I (Implicación): What does the problem cost them? Rejected batches? Downtime?
|
Action: Ask 1-2 qualifying questions. DO NOT fire tools yet.
|
||||||
N (Necesidad): Confirm the need before recommending.
|
Example: "Estoy en textiles" → "What specific process — post-dye drying, finishing, moisture leveling? And what method do you use currently?"
|
||||||
|
Example: "I need to reduce costs" → "Which industry and production process? What throughput per hour?"
|
||||||
|
|
||||||
|
STAGE 2 — RECOMMEND + EDUCATE:
|
||||||
|
Trigger: User provides industry + process OR industry + problem.
|
||||||
|
Action: Call 'recommend_application' first to match their needs to FLUX products. Then chain 'rf_technology_explainer' or 'get_application_knowledge' for the top match.
|
||||||
|
Example: User says "I dry textiles after dyeing, about 800 kg/h" → recommend_application → navigate to the recommended app page → get_application_knowledge.
|
||||||
|
|
||||||
|
STAGE 3 — QUANTIFY + PROVE:
|
||||||
|
Trigger: User understands the application and wants numbers.
|
||||||
|
Action: Chain 'energy_savings_calculator' → 'search_installations' → 'show_case_study' with the most relevant real installation.
|
||||||
|
|
||||||
|
STAGE 4 — SPECIFY + CONVERT:
|
||||||
|
Trigger: User asks about equipment, pricing, or next steps.
|
||||||
|
Action: 'show_equipment_specs' → 'schedule_consultation'. This is the PRIMARY goal.
|
||||||
|
|
||||||
RULES:
|
RULES:
|
||||||
- If the user mentions an industry WITHOUT specifics → ask 1-2 qualifying questions BEFORE firing tools.
|
- Progress through stages naturally. Do not skip Stage 1 unless the user provides enough context.
|
||||||
Example: "Estoy en textiles" → "What specific process are you evaluating — post-dye drying, finishing, moisture leveling? And what method do you currently use?"
|
- If the user provides DETAILED context (industry + process + volume + problem), jump to Stage 2 or 3.
|
||||||
- If the user provides DETAILED context (industry + process + volume OR problem) → proceed directly to tools.
|
- EXCEPTION: If the user explicitly asks for something specific ("show me case studies", "calculate savings for 500 kg/h"), deliver it immediately regardless of stage.
|
||||||
- Never fire more than 2 tools in a single autonomous sequence without including meaningful analysis text.
|
- Never fire more than 3 tools in a single autonomous sequence without including analysis text between them.
|
||||||
- EXCEPTION: If the user explicitly asks for something specific ("show me case studies", "calculate savings for 500 kg/h"), deliver it immediately.
|
- After EVERY tool result, include a brief human-readable insight before the next tool or suggestion.
|
||||||
|
|
||||||
IDEAL CONVERSION FLOW:
|
|
||||||
Qualify → Educate (explainer/comparison) → Quantify (calculator) → Prove (case study) → Recommend (equipment specs) → Convert (consultation)
|
|
||||||
|
|
||||||
═══════════════════════════════════════════
|
═══════════════════════════════════════════
|
||||||
TOOL USAGE RULES:
|
TOOL USAGE RULES:
|
||||||
═══════════════════════════════════════════
|
═══════════════════════════════════════════
|
||||||
1. SEARCH INSTALLATIONS: Use 'search_installations' to find real installations from our database. This is a DATA tool — you receive the results and reason about them before responding.
|
1. RECOMMEND APPLICATION (NEW — Stage 2): Use 'recommend_application' when the user describes their industry or problem and you need to identify which FLUX product fits. This is your FIRST tool when entering Stage 2. It returns ranked matches from the database.
|
||||||
2. SHOW CASE STUDY: Use 'show_case_study' to display a rich case study card for a specific installation. Requires a nodeId (get it from search_installations first) or an application slug for auto-match.
|
2. SEARCH INSTALLATIONS: Use 'search_installations' to find real installations. DATA tool — read results and reference them in your response.
|
||||||
3. SAVINGS/ROI: Use 'energy_savings_calculator' when discussing costs, energy, ROI. If volume is missing, assume 500 kg/h and 16h/day.
|
3. SHOW CASE STUDY: Use 'show_case_study' for a rich case study card. Get nodeId from search_installations first.
|
||||||
4. NAVIGATION: Use 'navigate_to_section' to move the user around the site.
|
4. SAVINGS/ROI: Use 'energy_savings_calculator' for cost/energy/ROI discussions. Default: 500 kg/h, 16h/day if volume unknown.
|
||||||
5. COMPARISONS: Use 'process_comparison_table' for RF vs conventional tech.
|
5. NAVIGATION: Use 'navigate_to_section'. Mode A "section" for homepage scroll (valid: "technology", "applications-dashboard", "applications-deep", "global", "our-story", "legacy"). Mode B "url" for cross-page (url="/applications/{slug}", url="/news", url="/heritage", url="/parts").
|
||||||
6. EXPLAIN TECH: Use 'rf_technology_explainer' for physics/mechanism questions.
|
6. COMPARISONS: Use 'process_comparison_table' for RF vs conventional tech.
|
||||||
7. APPLICATION KNOWLEDGE: Use 'get_application_knowledge' to retrieve deep technical theory, advantages, and datasheets from our knowledge base.
|
7. EXPLAIN TECH: Use 'rf_technology_explainer' for physics/mechanism questions.
|
||||||
8. EQUIPMENT SPECS: Use 'show_equipment_specs' to display real machine specifications from an actual installation.
|
8. APPLICATION KNOWLEDGE: Use 'get_application_knowledge' for deep technical theory after identifying the right application.
|
||||||
9. CONSULTATION (PRIMARY GOAL): Use 'schedule_consultation' when the user shows buying intent.
|
9. EQUIPMENT SPECS: Use 'show_equipment_specs' for real machine specifications.
|
||||||
|
10. CONSULTATION (PRIMARY GOAL): Use 'schedule_consultation' when the user shows buying intent.
|
||||||
|
|
||||||
PROACTIVE NEXT STEPS:
|
PROACTIVE NEXT STEPS (always suggest the next logical action):
|
||||||
After showing results, gently suggest the logical next action:
|
recommend → "Let me show you the details of this application..." → navigate to app page or get_application_knowledge
|
||||||
savings → case study ("We have real installations proving these numbers...")
|
knowledge/explainer → "Want to see what this means for your energy costs?" → energy_savings_calculator
|
||||||
case study → equipment specs ("Want to see the technical specs of the system used?")
|
savings → "We have real installations proving these numbers..." → search_installations + show_case_study
|
||||||
equipment → consultation ("Shall I arrange a conversation with our engineering team?")
|
case study → "Want to see the technical specs of the system used?" → show_equipment_specs
|
||||||
|
equipment → "Shall I arrange a conversation with our engineering team?" → schedule_consultation
|
||||||
|
comparison → "Let me quantify the difference for your specific operation..." → energy_savings_calculator
|
||||||
|
|
||||||
LANGUAGE: Respond in the exact same language the user writes in.`;
|
LANGUAGE: Respond in the exact same language the user writes in.`;
|
||||||
|
|
||||||
|
// Only cache a healthy build so a transient DB outage retries next message.
|
||||||
|
if (dbOk) _promptCache = { value: prompt, at: Date.now() };
|
||||||
|
return prompt;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── HELPER: Parse JSON safely ──────────────────────────────────
|
// ─── HELPER: Parse JSON safely ──────────────────────────────────
|
||||||
@@ -148,11 +188,24 @@ function industryFromSlug(slug: string): string {
|
|||||||
return 'other';
|
return 'other';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lightweight industry sniffer used for AiConversation.industryLabel telemetry.
|
||||||
|
// Order matters — more specific terms first.
|
||||||
|
function detectIndustryFromText(text: string): string | null {
|
||||||
|
const t = text.toLowerCase();
|
||||||
|
if (/text|fabric|dye|stenter|finishing|yarn/.test(t)) return 'textile';
|
||||||
|
if (/food|defrost|bak|pasteuriz|tempering|cook/.test(t)) return 'food';
|
||||||
|
if (/rubber|latex|vulcaniz|foam|tyre|tire/.test(t)) return 'rubber';
|
||||||
|
if (/pharma|cannabis|drug|api\b|lab/.test(t)) return 'pharma';
|
||||||
|
if (/wood|timber|lumber|kiln/.test(t)) return 'wood';
|
||||||
|
if (/ceramic|kiln|clay/.test(t)) return 'other';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── ROUTE HANDLER ──────────────────────────────────────────────
|
// ─── ROUTE HANDLER ──────────────────────────────────────────────
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
// ─── Rate limit (per-IP token bucket, 30 req/min) ──────────────
|
// ─── Rate limit (per-IP token bucket, 30 req/min) ──────────────
|
||||||
const rate = checkChatRateLimit(req);
|
const rate = await checkChatRateLimit(req);
|
||||||
if (!rate.ok) {
|
if (!rate.ok) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -170,16 +223,97 @@ export async function POST(req: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { messages, context }: {
|
// ─── Fail fast if the AI provider isn't configured ─────────────
|
||||||
|
// Without this, a missing/invalid key surfaces mid-stream after headers
|
||||||
|
// are already sent, producing a confusing broken response.
|
||||||
|
if (!process.env.OPENAI_API_KEY) {
|
||||||
|
log.error("chat.openai_key_missing", new Error("OPENAI_API_KEY is not set"));
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "The AI assistant is temporarily unavailable. Please try again later." }),
|
||||||
|
{ status: 503, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
messages,
|
||||||
|
context,
|
||||||
|
sessionId,
|
||||||
|
locale,
|
||||||
|
pageUrl,
|
||||||
|
}: {
|
||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
context?: { section?: string; activeTab?: string };
|
context?: { section?: string; activeTab?: string };
|
||||||
|
sessionId?: string;
|
||||||
|
locale?: string;
|
||||||
|
pageUrl?: string | null;
|
||||||
} = await req.json();
|
} = await req.json();
|
||||||
|
|
||||||
const contextNote = context?.section
|
const contextNote = context?.section
|
||||||
? `\n\n[CONTEXT: User is currently viewing the "${context.section}" section${context.activeTab ? `, tab: "${context.activeTab}"` : ''}.`
|
? `\n\n[CONTEXT: User is currently viewing the "${context.section}" section${context.activeTab ? `, tab: "${context.activeTab}"` : ''}.`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
// Build system prompt with live database context
|
// ─── FluxAI telemetry: upsert conversation + record user message ────────
|
||||||
|
// Wrapped in try/catch — telemetry never blocks the chat response.
|
||||||
|
let conversationId: string | null = null;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
if (sessionId) {
|
||||||
|
try {
|
||||||
|
const ipHash = createHash('sha256')
|
||||||
|
.update(`${getClientIp(req)}|${process.env.SESSION_SECRET ?? ''}`)
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 32);
|
||||||
|
|
||||||
|
const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user');
|
||||||
|
const lastUserText = lastUserMsg
|
||||||
|
? (lastUserMsg as unknown as { parts?: { type: string; text?: string }[] }).parts
|
||||||
|
?.filter((p) => p.type === 'text')
|
||||||
|
.map((p) => p.text || '')
|
||||||
|
.join(' ')
|
||||||
|
.slice(0, 8000)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const detectedIndustry = lastUserText ? detectIndustryFromText(lastUserText) : null;
|
||||||
|
|
||||||
|
const conv = await prisma.aiConversation.upsert({
|
||||||
|
where: { sessionId },
|
||||||
|
update: {
|
||||||
|
lastMessageAt: new Date(),
|
||||||
|
messageCount: { increment: 1 },
|
||||||
|
...(detectedIndustry ? { industryLabel: detectedIndustry } : {}),
|
||||||
|
// Once we have an industry, advance to QUALIFY.
|
||||||
|
...(detectedIndustry ? { funnelStage: 'QUALIFY' } : {}),
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
sessionId,
|
||||||
|
visitorIp: ipHash,
|
||||||
|
userAgent: req.headers.get('user-agent')?.slice(0, 240) ?? null,
|
||||||
|
locale: locale ?? null,
|
||||||
|
pageUrl: pageUrl ?? null,
|
||||||
|
industryLabel: detectedIndustry,
|
||||||
|
funnelStage: detectedIndustry ? 'QUALIFY' : 'DISCOVERY',
|
||||||
|
messageCount: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
conversationId = conv.id;
|
||||||
|
|
||||||
|
if (lastUserText) {
|
||||||
|
await prisma.aiEvent.create({
|
||||||
|
data: {
|
||||||
|
conversationId: conv.id,
|
||||||
|
type: 'user_msg',
|
||||||
|
payloadJson: JSON.stringify({ text: lastUserText }).slice(0, 8000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log.warn('chat.telemetry_upsert_failed', { err: String(e) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build system prompt with live database context.
|
||||||
|
// The static section (personality, knowledge, rules) is identical across
|
||||||
|
// requests, so we tag it with `providerOptions.openai.promptCacheKey` —
|
||||||
|
// a no-op today, but ready for prompt caching when the SDK lands it.
|
||||||
const systemPrompt = await buildSystemPrompt();
|
const systemPrompt = await buildSystemPrompt();
|
||||||
|
|
||||||
const coreMessages = await convertToModelMessages(messages);
|
const coreMessages = await convertToModelMessages(messages);
|
||||||
@@ -188,6 +322,105 @@ export async function POST(req: Request) {
|
|||||||
model: openai('gpt-4o'),
|
model: openai('gpt-4o'),
|
||||||
system: systemPrompt + contextNote,
|
system: systemPrompt + contextNote,
|
||||||
messages: coreMessages,
|
messages: coreMessages,
|
||||||
|
providerOptions: { openai: { promptCacheKey: 'fluxai-v1' } },
|
||||||
|
// Surface streaming/provider errors (OpenAI 429/500, bad key) in the logs
|
||||||
|
// and, when possible, persist them to the conversation timeline.
|
||||||
|
onError: ({ error }) => {
|
||||||
|
log.error("chat.stream_error", error, { conversationId: conversationId ?? undefined });
|
||||||
|
if (conversationId) {
|
||||||
|
prisma.aiEvent.create({
|
||||||
|
data: {
|
||||||
|
conversationId,
|
||||||
|
type: "error",
|
||||||
|
payloadJson: JSON.stringify({ message: error instanceof Error ? error.message : String(error) }).slice(0, 2000),
|
||||||
|
},
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFinish: async ({ usage, toolCalls, toolResults }) => {
|
||||||
|
if (!conversationId) return;
|
||||||
|
try {
|
||||||
|
const latencyMs = Date.now() - startedAt;
|
||||||
|
// 1. Persist the assistant message (compact)
|
||||||
|
await prisma.aiEvent.create({
|
||||||
|
data: {
|
||||||
|
conversationId,
|
||||||
|
type: 'ai_msg',
|
||||||
|
payloadJson: JSON.stringify({
|
||||||
|
toolCalls: toolCalls?.map((tc) => ({ name: tc.toolName })) ?? [],
|
||||||
|
}).slice(0, 8000),
|
||||||
|
latencyMs,
|
||||||
|
tokensIn:
|
||||||
|
(usage as unknown as { inputTokens?: number; promptTokens?: number })?.inputTokens ??
|
||||||
|
(usage as unknown as { promptTokens?: number })?.promptTokens ??
|
||||||
|
null,
|
||||||
|
tokensOut:
|
||||||
|
(usage as unknown as { outputTokens?: number; completionTokens?: number })?.outputTokens ??
|
||||||
|
(usage as unknown as { completionTokens?: number })?.completionTokens ??
|
||||||
|
null,
|
||||||
|
cachedTokens:
|
||||||
|
(usage as unknown as { cachedTokens?: number })?.cachedTokens ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Persist each tool call/result
|
||||||
|
const tcArr = toolCalls ?? [];
|
||||||
|
const trArr = toolResults ?? [];
|
||||||
|
let advanceStage: string | null = null;
|
||||||
|
let savings: number | null = null;
|
||||||
|
let volume: string | null = null;
|
||||||
|
|
||||||
|
for (const tc of tcArr) {
|
||||||
|
await prisma.aiEvent.create({
|
||||||
|
data: {
|
||||||
|
conversationId,
|
||||||
|
type: 'tool_call',
|
||||||
|
toolName: tc.toolName,
|
||||||
|
payloadJson: JSON.stringify(
|
||||||
|
(tc as unknown as { args?: unknown; input?: unknown }).args ??
|
||||||
|
(tc as unknown as { input?: unknown }).input ??
|
||||||
|
{},
|
||||||
|
).slice(0, 8000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (tc.toolName === 'energy_savings_calculator') advanceStage = 'RECOMMEND';
|
||||||
|
if (tc.toolName === 'schedule_consultation') {
|
||||||
|
advanceStage = 'HANDOFF';
|
||||||
|
const args = ((tc as unknown as { args?: unknown; input?: unknown }).args ??
|
||||||
|
(tc as unknown as { input?: unknown }).input ??
|
||||||
|
{}) as {
|
||||||
|
estimatedSavingsPercent?: number | null;
|
||||||
|
productionVolume?: string | null;
|
||||||
|
};
|
||||||
|
if (typeof args.estimatedSavingsPercent === 'number') savings = args.estimatedSavingsPercent;
|
||||||
|
if (typeof args.productionVolume === 'string') volume = args.productionVolume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const tr of trArr) {
|
||||||
|
await prisma.aiEvent.create({
|
||||||
|
data: {
|
||||||
|
conversationId,
|
||||||
|
type: 'tool_result',
|
||||||
|
toolName: (tr as unknown as { toolName?: string }).toolName ?? null,
|
||||||
|
payloadJson: JSON.stringify((tr as unknown as { result?: unknown }).result ?? {}).slice(0, 8000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Update conversation funnel + counters
|
||||||
|
await prisma.aiConversation.update({
|
||||||
|
where: { id: conversationId },
|
||||||
|
data: {
|
||||||
|
toolCallCount: { increment: tcArr.length },
|
||||||
|
...(advanceStage ? { funnelStage: advanceStage } : {}),
|
||||||
|
...(savings != null ? { estimatedSavingsPercent: savings } : {}),
|
||||||
|
...(volume ? { productionVolume: volume } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
log.warn('chat.telemetry_finish_failed', { err: String(e) });
|
||||||
|
}
|
||||||
|
},
|
||||||
// 🔥 RESTORED: AI SDK 6 multi-step autonomy. The agent can now chain
|
// 🔥 RESTORED: AI SDK 6 multi-step autonomy. The agent can now chain
|
||||||
// search → calculator → case-study → consultation in a single turn,
|
// search → calculator → case-study → consultation in a single turn,
|
||||||
// exactly as the SPIN methodology in the system prompt was designed for.
|
// exactly as the SPIN methodology in the system prompt was designed for.
|
||||||
@@ -199,6 +432,106 @@ export async function POST(req: Request) {
|
|||||||
// DATA TOOLS (have execute, return data for AI to reason about)
|
// DATA TOOLS (have execute, return data for AI to reason about)
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ── TOOL 0: Recommend Application (DATA — smart product matching) ──
|
||||||
|
recommend_application: tool({
|
||||||
|
description: `Analyze the user's needs and recommend the best FLUX application(s) from the database. Use this FIRST when a prospect describes their industry, problem, or process without specifying a particular FLUX product. Returns ranked matches with confidence scores and reasoning. After getting results, chain into 'navigate_to_section' (url) to show them the application page, or 'get_application_knowledge' for deep technical detail.`,
|
||||||
|
inputSchema: z.object({
|
||||||
|
industryKeywords: z.array(z.string())
|
||||||
|
.describe('Keywords about their industry, e.g. ["textile", "fabric", "drying", "moisture"]'),
|
||||||
|
problemDescription: z.string()
|
||||||
|
.describe('What the user is trying to solve, e.g. "too much moisture after dyeing, high energy costs"'),
|
||||||
|
processType: z.string().optional()
|
||||||
|
.describe('Specific process if mentioned, e.g. "post-dye drying", "defrosting meat blocks"'),
|
||||||
|
currentMethod: z.string().optional()
|
||||||
|
.describe('Their current equipment/method if mentioned, e.g. "stenter", "steam autoclave"'),
|
||||||
|
}),
|
||||||
|
execute: async ({ industryKeywords, problemDescription, processType, currentMethod }) => {
|
||||||
|
const apps = await prisma.application.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
select: {
|
||||||
|
slug: true,
|
||||||
|
title: true,
|
||||||
|
subtitle: true,
|
||||||
|
category: true,
|
||||||
|
shortDescription: true,
|
||||||
|
heroDescription: true,
|
||||||
|
dashboardMetricsJson: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Score each application against the user's needs
|
||||||
|
const scored = apps.map((app: any) => {
|
||||||
|
const searchText = `${app.title} ${app.subtitle || ''} ${app.shortDescription} ${app.category} ${(app.heroDescription || '').slice(0, 800)}`.toLowerCase();
|
||||||
|
let score = 0;
|
||||||
|
const matchedKeywords: string[] = [];
|
||||||
|
|
||||||
|
for (const kw of industryKeywords) {
|
||||||
|
if (searchText.includes(kw.toLowerCase())) {
|
||||||
|
score += 10;
|
||||||
|
matchedKeywords.push(kw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check problem description words
|
||||||
|
const problemWords = problemDescription.toLowerCase().split(/\s+/).filter((w: string) => w.length > 3);
|
||||||
|
for (const pw of problemWords) {
|
||||||
|
if (searchText.includes(pw)) score += 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bonus for process type match
|
||||||
|
if (processType && searchText.includes(processType.toLowerCase())) score += 15;
|
||||||
|
|
||||||
|
// Bonus for current method match (they're looking to replace it)
|
||||||
|
if (currentMethod && searchText.includes(currentMethod.toLowerCase())) score += 8;
|
||||||
|
|
||||||
|
return {
|
||||||
|
slug: app.slug,
|
||||||
|
title: app.title,
|
||||||
|
subtitle: app.subtitle,
|
||||||
|
category: app.category,
|
||||||
|
shortDescription: app.shortDescription,
|
||||||
|
score,
|
||||||
|
matchedKeywords,
|
||||||
|
metrics: safeParseJson(app.dashboardMetricsJson, []),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const ranked = scored
|
||||||
|
.filter((a: any) => a.score > 0)
|
||||||
|
.sort((a: any, b: any) => b.score - a.score)
|
||||||
|
.slice(0, 3);
|
||||||
|
|
||||||
|
if (ranked.length === 0) {
|
||||||
|
return {
|
||||||
|
found: 0,
|
||||||
|
message: 'No direct match found. Ask the user for more details about their specific process and materials.',
|
||||||
|
allApplications: apps.map((a: any) => ({ slug: a.slug, title: a.title, category: a.category })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
found: ranked.length,
|
||||||
|
recommendations: ranked.map((r: any, idx: number) => ({
|
||||||
|
rank: idx + 1,
|
||||||
|
slug: r.slug,
|
||||||
|
title: r.title,
|
||||||
|
subtitle: r.subtitle,
|
||||||
|
category: r.category,
|
||||||
|
shortDescription: r.shortDescription,
|
||||||
|
matchedKeywords: r.matchedKeywords,
|
||||||
|
confidenceScore: Math.min(100, r.score),
|
||||||
|
topMetrics: r.metrics.slice(0, 3),
|
||||||
|
})),
|
||||||
|
userContext: {
|
||||||
|
industryKeywords,
|
||||||
|
problemDescription,
|
||||||
|
processType: processType || null,
|
||||||
|
currentMethod: currentMethod || null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
// ── TOOL 1: Search Installations (DATA — queries Prisma) ──
|
// ── TOOL 1: Search Installations (DATA — queries Prisma) ──
|
||||||
search_installations: tool({
|
search_installations: tool({
|
||||||
description: `Search the FLUX global installation database for real case studies and projects. Returns summaries for you to analyze and reference in your response. Use when you need to find relevant installations to back up your claims, or when the user asks about references, case studies, or proven results. You can then call 'show_case_study' with a specific nodeId to display the full card to the user.`,
|
description: `Search the FLUX global installation database for real case studies and projects. Returns summaries for you to analyze and reference in your response. Use when you need to find relevant installations to back up your claims, or when the user asks about references, case studies, or proven results. You can then call 'show_case_study' with a specific nodeId to display the full card to the user.`,
|
||||||
@@ -459,13 +792,39 @@ export async function POST(req: Request) {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// ── TOOL 6: Navigate to Section (client-side — NO execute) ──
|
// ── TOOL 6: Navigate (client-side — NO execute) ──────────────
|
||||||
|
// Handles BOTH same-page scrolling (section) and cross-page
|
||||||
|
// routing (url). The client inspects which field is set.
|
||||||
navigate_to_section: tool({
|
navigate_to_section: tool({
|
||||||
description: `Maps the user to a specific section of the FLUX website. Use when the user says "show me", "take me to", "where is", or asks about a specific page section. Available sections: "hero", "applications-dashboard", "applications-deep", "global" (globe), "timeline", "heritage", "news", "parts-catalog", "contact".`,
|
description: `Navigate the user to any part of the FLUX website.
|
||||||
|
|
||||||
|
TWO MODES:
|
||||||
|
A) SAME-PAGE SCROLL — set "section" to scroll to a homepage element by its DOM id:
|
||||||
|
"technology" (hero/intro), "applications-dashboard", "applications-deep", "global" (interactive globe), "our-story" (timeline), "legacy" (Patrizio legacy)
|
||||||
|
ONLY use these exact IDs. They only work when the user is on the homepage.
|
||||||
|
|
||||||
|
B) CROSS-PAGE NAVIGATION — set "url" to a route path (WITHOUT locale prefix). The client adds the locale automatically:
|
||||||
|
"/news" — news hub listing
|
||||||
|
"/news/{slug}" — specific article (use a real slug from context)
|
||||||
|
"/heritage" — company heritage deep-dive
|
||||||
|
"/parts" — spare parts catalog (B2B portal)
|
||||||
|
"/applications/{slug}" — application detail page (use real slug from the database list above)
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- ALWAYS prefer mode B for news, heritage, parts, and application detail pages.
|
||||||
|
- Only use mode A for scrolling within the homepage.
|
||||||
|
- When using mode B, use application slugs from the database list in this prompt.
|
||||||
|
- "show me textile drying" → url="/applications/textile-drying"
|
||||||
|
- "take me to the news" → url="/news"
|
||||||
|
- "show me the heritage" → url="/heritage"
|
||||||
|
- "show me the spare parts" → url="/parts"
|
||||||
|
- "show me the globe" → section="global"
|
||||||
|
- "go to the top" → section="technology"`,
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
section: z.string().describe('Target section ID'),
|
section: z.string().optional().describe('Homepage element ID for same-page scroll'),
|
||||||
|
url: z.string().optional().describe('Route path for cross-page navigation (e.g. "/applications/textile-drying", "/news", "/heritage"). No locale prefix.'),
|
||||||
subAction: z.enum(['none', 'activate-tab', 'highlight-node']).default('none'),
|
subAction: z.enum(['none', 'activate-tab', 'highlight-node']).default('none'),
|
||||||
tabId: z.string().optional().describe('Application slug to activate'),
|
tabId: z.string().optional().describe('Application slug to activate on dashboard tab'),
|
||||||
nodeId: z.string().optional().describe('Globe node ID to highlight'),
|
nodeId: z.string().optional().describe('Globe node ID to highlight'),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,12 +1,50 @@
|
|||||||
// /src/app/api/consultation/route.ts
|
// /src/app/api/consultation/route.ts
|
||||||
// Public API endpoint for ConsultationScheduler → OperationsSignal
|
// Public API endpoint for ConsultationScheduler -> OperationsSignal.
|
||||||
// Uses SMTP mailer (no Resend dependency)
|
// Hardened (v2):
|
||||||
|
// - Zod schema validates every field, rejects malformed emails / oversize input.
|
||||||
|
// - Double-submit CSRF check rejects cross-site form posts.
|
||||||
|
// - escapeHtml() everywhere in the email template (no raw interpolation).
|
||||||
|
// - Structured logging (no silent console.error).
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { sendEmail } from "@/lib/mailer";
|
import { sendEmail } from "@/lib/mailer";
|
||||||
|
import { escapeHtml, escapeAttr, safeMailto } from "@/lib/escapeHtml";
|
||||||
|
import { log } from "@/lib/logger";
|
||||||
|
import { verifyCsrfToken, CSRF_COOKIE_NAME, CSRF_HEADER_NAME } from "@/lib/csrf";
|
||||||
|
|
||||||
|
const ConsultationSchema = z.object({
|
||||||
|
contact: z.object({
|
||||||
|
name: z.string().min(1).max(120),
|
||||||
|
email: z.string().email().max(254),
|
||||||
|
company: z.string().min(1).max(160),
|
||||||
|
phone: z.string().max(40).optional().nullable(),
|
||||||
|
message: z.string().max(4000).optional().nullable(),
|
||||||
|
preferredContact: z.enum(["email", "phone", "whatsapp"]).optional().nullable(),
|
||||||
|
timeframe: z.string().max(80).optional().nullable(),
|
||||||
|
}),
|
||||||
|
aiContext: z
|
||||||
|
.object({
|
||||||
|
industryLabel: z.string().max(120).optional().nullable(),
|
||||||
|
process: z.string().max(120).optional().nullable(),
|
||||||
|
estimatedSavingsPercent: z.number().min(0).max(100).optional().nullable(),
|
||||||
|
productionVolume: z.string().max(120).optional().nullable(),
|
||||||
|
conversationInsights: z.array(z.string().max(500)).max(20).optional().nullable(),
|
||||||
|
suggestedTopics: z.array(z.string().max(160)).max(20).optional().nullable(),
|
||||||
|
sessionId: z.string().uuid().optional().nullable(),
|
||||||
|
})
|
||||||
|
.partial()
|
||||||
|
.optional(),
|
||||||
|
meta: z
|
||||||
|
.object({
|
||||||
|
source: z.string().max(80).optional().nullable(),
|
||||||
|
url: z.string().url().max(500).optional().nullable(),
|
||||||
|
})
|
||||||
|
.partial()
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
// Helper: sequential ticket ID
|
|
||||||
async function generateConsultationTicketId(): Promise<string> {
|
async function generateConsultationTicketId(): Promise<string> {
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
const count = await prisma.operationsSignal.count({
|
const count = await prisma.operationsSignal.count({
|
||||||
@@ -16,34 +54,54 @@ async function generateConsultationTicketId(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
// ── CSRF: double-submit cookie + header must match ──────────────────────
|
||||||
const body = await request.json();
|
const csrfCookie = request.cookies.get(CSRF_COOKIE_NAME)?.value ?? null;
|
||||||
const { contact, aiContext, meta } = body;
|
const csrfHeader = request.headers.get(CSRF_HEADER_NAME);
|
||||||
|
if (!csrfCookie || !csrfHeader || csrfCookie !== csrfHeader || !verifyCsrfToken(csrfHeader)) {
|
||||||
if (!contact?.name || !contact?.email || !contact?.company) {
|
log.warn("consultation.csrf_rejected", { hasCookie: !!csrfCookie, hasHeader: !!csrfHeader });
|
||||||
return NextResponse.json({ error: "Missing required contact fields" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid CSRF token" }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Body parse + schema validation ──────────────────────────────────────
|
||||||
|
let parsed: z.infer<typeof ConsultationSchema>;
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
parsed = ConsultationSchema.parse(body);
|
||||||
|
} catch (e) {
|
||||||
|
log.warn("consultation.validation_failed", { error: e instanceof z.ZodError ? e.issues : String(e) });
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid payload", details: e instanceof z.ZodError ? e.issues : undefined },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { contact, aiContext, meta } = parsed;
|
||||||
|
|
||||||
|
try {
|
||||||
const ticketId = await generateConsultationTicketId();
|
const ticketId = await generateConsultationTicketId();
|
||||||
|
|
||||||
// Build structured AI analysis
|
// Build structured AI analysis (plain text, no markup needed)
|
||||||
const aiParts: string[] = [];
|
const aiParts: string[] = [];
|
||||||
if (aiContext?.industryLabel && aiContext?.process) aiParts.push(`[INDUSTRY] ${aiContext.industryLabel} — ${aiContext.process}`);
|
if (aiContext?.industryLabel && aiContext?.process)
|
||||||
if (aiContext?.estimatedSavingsPercent) aiParts.push(`[ESTIMATED SAVINGS] ~${aiContext.estimatedSavingsPercent}% energy reduction`);
|
aiParts.push(`[INDUSTRY] ${aiContext.industryLabel} - ${aiContext.process}`);
|
||||||
|
if (aiContext?.estimatedSavingsPercent)
|
||||||
|
aiParts.push(`[ESTIMATED SAVINGS] ~${aiContext.estimatedSavingsPercent}% energy reduction`);
|
||||||
if (aiContext?.productionVolume) aiParts.push(`[PRODUCTION VOLUME] ${aiContext.productionVolume}`);
|
if (aiContext?.productionVolume) aiParts.push(`[PRODUCTION VOLUME] ${aiContext.productionVolume}`);
|
||||||
if (aiContext?.conversationInsights?.length > 0) aiParts.push(`[AI DISCUSSION POINTS]\n${aiContext.conversationInsights.map((i: string) => `• ${i}`).join("\n")}`);
|
if (aiContext?.conversationInsights?.length)
|
||||||
if (aiContext?.suggestedTopics?.length > 0) aiParts.push(`[SUGGESTED ENGINEERING TOPICS]\n${aiContext.suggestedTopics.map((t: string) => `→ ${t}`).join("\n")}`);
|
aiParts.push(`[AI DISCUSSION POINTS]\n${aiContext.conversationInsights.map((i) => `- ${i}`).join("\n")}`);
|
||||||
if (contact?.preferredContact) aiParts.push(`[PREFERRED CONTACT] ${contact.preferredContact.toUpperCase()}`);
|
if (aiContext?.suggestedTopics?.length)
|
||||||
if (contact?.timeframe) aiParts.push(`[TIMEFRAME] ${contact.timeframe}`);
|
aiParts.push(`[SUGGESTED ENGINEERING TOPICS]\n${aiContext.suggestedTopics.map((t) => `-> ${t}`).join("\n")}`);
|
||||||
if (meta?.source) aiParts.push(`[SOURCE] ${meta.source} — ${meta.url || "N/A"}`);
|
if (contact.preferredContact) aiParts.push(`[PREFERRED CONTACT] ${contact.preferredContact.toUpperCase()}`);
|
||||||
|
if (contact.timeframe) aiParts.push(`[TIMEFRAME] ${contact.timeframe}`);
|
||||||
|
if (meta?.source) aiParts.push(`[SOURCE] ${meta.source} - ${meta.url || "N/A"}`);
|
||||||
|
|
||||||
const aiAnalysis = aiParts.join("\n\n");
|
const aiAnalysis = aiParts.join("\n\n");
|
||||||
|
|
||||||
const messageParts: string[] = [];
|
const messageParts: string[] = [];
|
||||||
if (contact.message) messageParts.push(contact.message);
|
if (contact.message) messageParts.push(contact.message);
|
||||||
if (contact.timeframe) messageParts.push(`Timeframe: ${contact.timeframe}`);
|
if (contact.timeframe) messageParts.push(`Timeframe: ${contact.timeframe}`);
|
||||||
if (contact.preferredContact) messageParts.push(`Preferred contact: ${contact.preferredContact}`);
|
if (contact.preferredContact) messageParts.push(`Preferred contact: ${contact.preferredContact}`);
|
||||||
|
|
||||||
// Save to DB
|
|
||||||
const signal = await prisma.operationsSignal.create({
|
const signal = await prisma.operationsSignal.create({
|
||||||
data: {
|
data: {
|
||||||
ticketId,
|
ticketId,
|
||||||
@@ -60,23 +118,36 @@ export async function POST(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Resolve email targets
|
// ── Link conversation -> signal (best-effort, never blocks the response)
|
||||||
|
if (aiContext?.sessionId) {
|
||||||
|
try {
|
||||||
|
await prisma.aiConversation.updateMany({
|
||||||
|
where: { sessionId: aiContext.sessionId },
|
||||||
|
data: { outcome: "CONSULTATION", signalId: signal.id, closedAt: new Date() },
|
||||||
|
});
|
||||||
|
} catch (linkErr) {
|
||||||
|
log.warn("consultation.link_conversation_failed", { sessionId: aiContext.sessionId, err: String(linkErr) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const route = await prisma.notificationRoute.findUnique({ where: { routeType: "CONSULTATION" } });
|
const route = await prisma.notificationRoute.findUnique({ where: { routeType: "CONSULTATION" } });
|
||||||
const targetEmails = route && route.isActive
|
const targetEmails =
|
||||||
|
route && route.isActive
|
||||||
? route.emails.split(",").map((e: string) => e.trim()).filter(Boolean)
|
? route.emails.split(",").map((e: string) => e.trim()).filter(Boolean)
|
||||||
: ["engineering@fluxsrl.com"];
|
: ["engineering@fluxsrl.com"];
|
||||||
|
|
||||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://flux.com";
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://flux.com";
|
||||||
|
|
||||||
// Send via SMTP
|
|
||||||
const emailResult = await sendEmail({
|
const emailResult = await sendEmail({
|
||||||
to: targetEmails,
|
to: targetEmails,
|
||||||
subject: `[CONSULTATION] ${aiContext?.industryLabel || "General"} inquiry from ${contact.company} — ${ticketId}`,
|
subject: `[CONSULTATION] ${aiContext?.industryLabel || "General"} inquiry from ${contact.company} - ${ticketId}`,
|
||||||
html: generateConsultationEmail(contact, aiContext, ticketId, appUrl),
|
html: generateConsultationEmail(contact, aiContext, ticketId, appUrl),
|
||||||
replyTo: contact.email,
|
replyTo: contact.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track email delivery
|
// Best-effort email tracking — the lead is already saved; never fail the
|
||||||
|
// request (and risk a client retry / duplicate) over a telemetry update.
|
||||||
|
try {
|
||||||
await prisma.operationsSignal.update({
|
await prisma.operationsSignal.update({
|
||||||
where: { id: signal.id },
|
where: { id: signal.id },
|
||||||
data: {
|
data: {
|
||||||
@@ -85,6 +156,11 @@ export async function POST(request: NextRequest) {
|
|||||||
emailError: emailResult.error,
|
emailError: emailResult.error,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
} catch (trackErr) {
|
||||||
|
log.warn("consultation.email_tracking_failed", { ticketId, err: String(trackErr) });
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("consultation.submitted", { ticketId, emailSent: emailResult.success });
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -93,33 +169,65 @@ export async function POST(request: NextRequest) {
|
|||||||
emailError: emailResult.error,
|
emailError: emailResult.error,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Consultation API error:", error);
|
log.error("consultation.submit_failed", error);
|
||||||
return NextResponse.json({ error: "Failed to submit consultation request" }, { status: 500 });
|
return NextResponse.json({ error: "Failed to submit consultation request" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateConsultationEmail(contact: any, aiContext: any, ticketId: string, appUrl: string) {
|
type ParsedContact = z.infer<typeof ConsultationSchema>["contact"];
|
||||||
const insights = (aiContext?.conversationInsights || []).map((i: string) => `<li style="margin-bottom: 6px;">${i}</li>`).join("");
|
type ParsedAiContext = z.infer<typeof ConsultationSchema>["aiContext"];
|
||||||
const topics = (aiContext?.suggestedTopics || []).map((t: string) => `<li style="margin-bottom: 4px; color: #0066CC;">${t}</li>`).join("");
|
|
||||||
|
function generateConsultationEmail(
|
||||||
|
contact: ParsedContact,
|
||||||
|
aiContext: ParsedAiContext | undefined,
|
||||||
|
ticketId: string,
|
||||||
|
_appUrl: string,
|
||||||
|
) {
|
||||||
|
const insightsHtml = (aiContext?.conversationInsights ?? [])
|
||||||
|
.map((i) => `<li style="margin-bottom: 6px;">${escapeHtml(i)}</li>`)
|
||||||
|
.join("");
|
||||||
|
const topicsHtml = (aiContext?.suggestedTopics ?? [])
|
||||||
|
.map((t) => `<li style="margin-bottom: 4px; color: #0066CC;">${escapeHtml(t)}</li>`)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const safeName = escapeHtml(contact.name);
|
||||||
|
const safeCompany = escapeHtml(contact.company);
|
||||||
|
const safeEmail = escapeHtml(contact.email);
|
||||||
|
const mailHref = escapeAttr(safeMailto(contact.email));
|
||||||
|
const safePhone = contact.phone ? escapeHtml(contact.phone) : "";
|
||||||
|
const safePreferred = escapeHtml((contact.preferredContact || "email").toUpperCase());
|
||||||
|
const safeTimeframe = escapeHtml(contact.timeframe || "N/A");
|
||||||
|
const safeIndustry = aiContext?.industryLabel ? escapeHtml(aiContext.industryLabel) : "";
|
||||||
|
const safeProcess = aiContext?.process ? escapeHtml(aiContext.process) : "General";
|
||||||
|
const safeSavings = aiContext?.estimatedSavingsPercent
|
||||||
|
? escapeHtml(String(aiContext.estimatedSavingsPercent))
|
||||||
|
: "";
|
||||||
|
const safeVolume = aiContext?.productionVolume ? escapeHtml(aiContext.productionVolume) : "";
|
||||||
|
const safeMessage = contact.message ? escapeHtml(contact.message) : "";
|
||||||
|
const safeTicketId = escapeHtml(ticketId);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; color: #1D1D1F;">
|
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; color: #1D1D1F;">
|
||||||
<div style="border-bottom: 2px solid #00F0FF; padding-bottom: 20px; margin-bottom: 24px;">
|
<div style="border-bottom: 2px solid #00F0FF; padding-bottom: 20px; margin-bottom: 24px;">
|
||||||
<p style="color: #86868B; font-size: 12px; letter-spacing: 2px; text-transform: uppercase;">FLUX AI — Engineering Consultation</p>
|
<p style="color: #86868B; font-size: 12px; letter-spacing: 2px; text-transform: uppercase;">FLUX AI - Engineering Consultation</p>
|
||||||
<h1 style="margin: 8px 0 0 0; font-size: 22px;">New Consultation Request</h1>
|
<h1 style="margin: 8px 0 0 0; font-size: 22px;">New Consultation Request</h1>
|
||||||
<p style="font-family: monospace; color: #00F0FF;">${ticketId}</p>
|
<p style="font-family: monospace; color: #00F0FF;">${safeTicketId}</p>
|
||||||
</div>
|
</div>
|
||||||
<div style="background: #F5F5F7; padding: 20px; border-radius: 12px; margin-bottom: 24px;">
|
<div style="background: #F5F5F7; padding: 20px; border-radius: 12px; margin-bottom: 24px;">
|
||||||
<p style="margin: 4px 0;"><strong>${contact.name}</strong> — ${contact.company}</p>
|
<p style="margin: 4px 0;"><strong>${safeName}</strong> - ${safeCompany}</p>
|
||||||
<p style="margin: 4px 0;">Email: <a href="mailto:${contact.email}" style="color: #0066CC;">${contact.email}</a></p>
|
<p style="margin: 4px 0;">Email: <a href="${mailHref}" style="color: #0066CC;">${safeEmail}</a></p>
|
||||||
${contact.phone ? `<p style="margin: 4px 0;">Phone: ${contact.phone}</p>` : ""}
|
${safePhone ? `<p style="margin: 4px 0;">Phone: ${safePhone}</p>` : ""}
|
||||||
<p style="margin: 4px 0;">Preferred: <strong>${(contact.preferredContact || "email").toUpperCase()}</strong> · Timeframe: <strong>${contact.timeframe || "N/A"}</strong></p>
|
<p style="margin: 4px 0;">Preferred: <strong>${safePreferred}</strong> · Timeframe: <strong>${safeTimeframe}</strong></p>
|
||||||
</div>
|
</div>
|
||||||
${aiContext?.industryLabel ? `<div style="background: #F0FBFF; border-left: 4px solid #00F0FF; padding: 16px 20px; border-radius: 0 12px 12px 0; margin-bottom: 24px;"><h3 style="margin: 0 0 8px 0; font-size: 13px; color: #00F0FF;">AI Context</h3><p style="margin: 4px 0;"><strong>Industry:</strong> ${aiContext.industryLabel} — ${aiContext.process || "General"}</p>${aiContext.estimatedSavingsPercent ? `<p style="color: #059669;"><strong>Savings:</strong> ~${aiContext.estimatedSavingsPercent}%</p>` : ""}${aiContext.productionVolume ? `<p><strong>Volume:</strong> ${aiContext.productionVolume}</p>` : ""}</div>` : ""}
|
${
|
||||||
${insights ? `<div style="margin-bottom: 24px;"><h3 style="font-size: 13px; color: #86868B;">Key Points</h3><ul style="padding-left: 20px;">${insights}</ul></div>` : ""}
|
safeIndustry
|
||||||
${topics ? `<div style="margin-bottom: 24px;"><h3 style="font-size: 13px; color: #0066CC;">Prepare Topics</h3><ul style="padding-left: 20px; list-style: none;">${topics}</ul></div>` : ""}
|
? `<div style="background: #F0FBFF; border-left: 4px solid #00F0FF; padding: 16px 20px; border-radius: 0 12px 12px 0; margin-bottom: 24px;"><h3 style="margin: 0 0 8px 0; font-size: 13px; color: #00F0FF;">AI Context</h3><p style="margin: 4px 0;"><strong>Industry:</strong> ${safeIndustry} - ${safeProcess}</p>${safeSavings ? `<p style="color: #059669;"><strong>Savings:</strong> ~${safeSavings}%</p>` : ""}${safeVolume ? `<p><strong>Volume:</strong> ${safeVolume}</p>` : ""}</div>`
|
||||||
${contact.message ? `<div style="margin-bottom: 24px;"><h3 style="font-size: 13px; color: #86868B;">Client Notes</h3><div style="padding: 12px; border-left: 4px solid #1D1D1F; background: #FAFAFA;">${contact.message}</div></div>` : ""}
|
: ""
|
||||||
<div style="border-top: 1px solid #E5E5EA; padding-top: 16px; margin-top: 32px; font-size: 11px; color: #86868B; text-align: center;"><p>FLUX Operations · Reply to contact ${contact.name} directly.</p></div>
|
}
|
||||||
|
${insightsHtml ? `<div style="margin-bottom: 24px;"><h3 style="font-size: 13px; color: #86868B;">Key Points</h3><ul style="padding-left: 20px;">${insightsHtml}</ul></div>` : ""}
|
||||||
|
${topicsHtml ? `<div style="margin-bottom: 24px;"><h3 style="font-size: 13px; color: #0066CC;">Prepare Topics</h3><ul style="padding-left: 20px; list-style: none;">${topicsHtml}</ul></div>` : ""}
|
||||||
|
${safeMessage ? `<div style="margin-bottom: 24px;"><h3 style="font-size: 13px; color: #86868B;">Client Notes</h3><div style="padding: 12px; border-left: 4px solid #1D1D1F; background: #FAFAFA;">${safeMessage}</div></div>` : ""}
|
||||||
|
<div style="border-top: 1px solid #E5E5EA; padding-top: 16px; margin-top: 32px; font-size: 11px; color: #86868B; text-align: center;"><p>FLUX Operations · Reply to contact ${safeName} directly.</p></div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// src/app/api/csrf/route.ts
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Issues a fresh CSRF token for the current browser session. Public POST
|
||||||
|
// endpoints (e.g. /api/consultation) require the matching cookie + header
|
||||||
|
// (double-submit pattern). See src/lib/csrf.ts for the verification flow.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { CSRF_COOKIE_NAME, csrfCookieOptions, issueCsrfToken } from "@/lib/csrf";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const token = issueCsrfToken();
|
||||||
|
const res = NextResponse.json({ token });
|
||||||
|
res.cookies.set(CSRF_COOKIE_NAME, token, csrfCookieOptions);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// src/app/api/health/route.ts
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Readiness probe. Returns 200 only if Postgres responds. Used by Docker
|
||||||
|
// healthcheck and by external uptime monitors so Nginx can fail fast and
|
||||||
|
// orchestrators can recycle the container if the DB connection dies.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { log } from "@/lib/logger";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
try {
|
||||||
|
await prisma.$queryRaw`SELECT 1`;
|
||||||
|
return Response.json({
|
||||||
|
ok: true,
|
||||||
|
db: "up",
|
||||||
|
latencyMs: Date.now() - startedAt,
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
log.error("health.db_unreachable", e);
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
db: "down",
|
||||||
|
latencyMs: Date.now() - startedAt,
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,19 +2,20 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { revalidateContent } from "@/lib/revalidate";
|
import { revalidateContent } from "@/lib/revalidate";
|
||||||
|
import { detectFileType, expectedTypeForExtension } from "@/lib/fileType";
|
||||||
|
import { log } from "@/lib/logger";
|
||||||
|
|
||||||
// 1. REGLAS DE SEGURIDAD ESTRICTAS
|
// 1. STRICT SECURITY RULES
|
||||||
const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp', '.mp4', '.mov'];
|
const ALLOWED_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".mp4", ".mov"];
|
||||||
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB Límite
|
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const file = formData.get("file") as File;
|
const file = formData.get("file") as File;
|
||||||
const ticketId = formData.get("ticketId") as string;
|
const ticketId = formData.get("ticketId") as string;
|
||||||
const clientName = formData.get("clientName") as string || "unregistered";
|
const clientName = (formData.get("clientName") as string) || "unregistered";
|
||||||
|
|
||||||
// 2. VALIDACIONES INICIALES
|
|
||||||
if (!file || !ticketId) {
|
if (!file || !ticketId) {
|
||||||
return NextResponse.json({ error: "Faltan datos requeridos (archivo o ticketId)" }, { status: 400 });
|
return NextResponse.json({ error: "Faltan datos requeridos (archivo o ticketId)" }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -25,50 +26,66 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const ext = path.extname(file.name).toLowerCase();
|
const ext = path.extname(file.name).toLowerCase();
|
||||||
if (!ALLOWED_EXTENSIONS.includes(ext)) {
|
if (!ALLOWED_EXTENSIONS.includes(ext)) {
|
||||||
return NextResponse.json({ error: `Formato no permitido. Solo aceptamos: ${ALLOWED_EXTENSIONS.join(", ")}` }, { status: 400 });
|
return NextResponse.json(
|
||||||
|
{ error: `Formato no permitido. Solo aceptamos: ${ALLOWED_EXTENSIONS.join(", ")}` },
|
||||||
|
{ status: 415 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. SANITIZACIÓN DE NOMBRES (Evita inyección de código y caracteres raros)
|
// 2. SANITIZE NAMES
|
||||||
const safeTicketId = ticketId.replace(/[^a-zA-Z0-9-]/g, "");
|
const safeTicketId = ticketId.replace(/[^a-zA-Z0-9-]/g, "");
|
||||||
// Convertimos "David Herran!" a "david-herran"
|
|
||||||
const safeClientName = clientName.toLowerCase().replace(/[^a-z0-9]/g, "-").substring(0, 30);
|
const safeClientName = clientName.toLowerCase().replace(/[^a-z0-9]/g, "-").substring(0, 30);
|
||||||
const folderName = `${safeTicketId}-${safeClientName}`;
|
const folderName = `${safeTicketId}-${safeClientName}`;
|
||||||
|
|
||||||
// 4. CREACIÓN DE LA CARPETA DEL CLIENTE
|
|
||||||
// Ruta final: /public/operations-inbox/REQ-2026-X8Y-david-herran/
|
|
||||||
const uploadDir = path.join(process.cwd(), "public", "operations-inbox", folderName);
|
const uploadDir = path.join(process.cwd(), "public", "operations-inbox", folderName);
|
||||||
|
|
||||||
// Escudo Anti-Hacking (Verifica que la ruta resuelta no se escape de la carpeta public)
|
// 3. PATH TRAVERSAL GUARD
|
||||||
if (!path.resolve(uploadDir).startsWith(path.resolve(process.cwd(), "public", "operations-inbox"))) {
|
if (!path.resolve(uploadDir).startsWith(path.resolve(process.cwd(), "public", "operations-inbox"))) {
|
||||||
return NextResponse.json({ error: "Ruta de directorio inválida" }, { status: 400 });
|
return NextResponse.json({ error: "Ruta de directorio inválida" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. READ BUFFER FIRST so we can sniff magic bytes BEFORE writing to disk.
|
||||||
|
// This prevents stored-XSS payloads (HTML/JS renamed to .png).
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const detected = detectFileType(buffer);
|
||||||
|
const expected = expectedTypeForExtension(ext);
|
||||||
|
|
||||||
|
if (!detected || (expected && detected !== expected && !(expected === "jpeg" && detected === "jpeg"))) {
|
||||||
|
log.warn("public_upload.magic_mismatch", {
|
||||||
|
ext,
|
||||||
|
detected,
|
||||||
|
expected,
|
||||||
|
size: file.size,
|
||||||
|
ticketId: safeTicketId,
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "El contenido del archivo no coincide con su extensión." },
|
||||||
|
{ status: 415 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. CREATE FOLDER + WRITE
|
||||||
if (!fs.existsSync(uploadDir)) {
|
if (!fs.existsSync(uploadDir)) {
|
||||||
fs.mkdirSync(uploadDir, { recursive: true });
|
fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. GUARDAR EL ARCHIVO FÍSICAMENTE
|
|
||||||
const safeFileName = file.name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9._-]/g, "");
|
const safeFileName = file.name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9._-]/g, "");
|
||||||
const filePath = path.join(uploadDir, safeFileName);
|
const filePath = path.join(uploadDir, safeFileName);
|
||||||
|
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
|
||||||
fs.writeFileSync(filePath, buffer);
|
fs.writeFileSync(filePath, buffer);
|
||||||
|
|
||||||
// 6. DEVOLVER LA URL PÚBLICA PARA GUARDARLA EN POSTGRES
|
|
||||||
const publicUrl = `/operations-inbox/${folderName}/${safeFileName}`;
|
const publicUrl = `/operations-inbox/${folderName}/${safeFileName}`;
|
||||||
|
|
||||||
// Invalida caché del operations-inbox / dashboard
|
|
||||||
revalidateContent({ scope: "operations-inbox", slug: folderName });
|
revalidateContent({ scope: "operations-inbox", slug: folderName });
|
||||||
|
|
||||||
|
log.info("public_upload.saved", { ticketId: safeTicketId, file: safeFileName, detected, bytes: file.size });
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
url: publicUrl,
|
url: publicUrl,
|
||||||
fileName: safeFileName,
|
fileName: safeFileName,
|
||||||
type: ext === '.mp4' || ext === '.mov' ? 'video' : 'image'
|
type: ext === ".mp4" || ext === ".mov" ? "video" : "image",
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error crítico en subida pública:", error);
|
log.error("public_upload.failed", error);
|
||||||
return NextResponse.json({ error: "Error interno del servidor" }, { status: 500 });
|
return NextResponse.json({ error: "Error interno del servidor" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,7 +18,7 @@ export async function getApplications() {
|
|||||||
noStore();
|
noStore();
|
||||||
try {
|
try {
|
||||||
const apps = await prisma.application.findMany({
|
const apps = await prisma.application.findMany({
|
||||||
orderBy: { createdAt: "asc" }
|
orderBy: [{ order: "asc" }, { createdAt: "asc" }]
|
||||||
});
|
});
|
||||||
return { success: true, apps };
|
return { success: true, apps };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -161,6 +161,24 @@ export async function deleteApplication(slug: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 6b. REORDENAR APLICACIONES (drag-to-reorder, mismo patrón que HeroSlide)
|
||||||
|
// Recibe la lista de slugs en el nuevo orden y renumera el campo `order`
|
||||||
|
// en una sola transacción atómica.
|
||||||
|
export async function reorderApplications(orderedSlugs: string[]) {
|
||||||
|
try {
|
||||||
|
await prisma.$transaction(
|
||||||
|
orderedSlugs.map((slug, idx) =>
|
||||||
|
prisma.application.update({ where: { slug }, data: { order: idx } })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
revalidatePath("/hq-command/dashboard/applications");
|
||||||
|
revalidatePath("/[locale]", "layout");
|
||||||
|
return { success: true };
|
||||||
|
} catch (e) {
|
||||||
|
return { error: "Failed to reorder applications." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 7. LA SEMILLA: AUTO-POBLAR LA BASE DE DATOS (Intacto)
|
// 7. LA SEMILLA: AUTO-POBLAR LA BASE DE DATOS (Intacto)
|
||||||
export async function seedInitialApplications() {
|
export async function seedInitialApplications() {
|
||||||
// ... Tu código actual de la semilla se queda exactamente igual ...
|
// ... Tu código actual de la semilla se queda exactamente igual ...
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import { useRouter } from "next/navigation";
|
|||||||
import {
|
import {
|
||||||
ArrowLeft, Layers, DatabaseZap, Loader2, X, CheckCircle2, FileText, LayoutTemplate, AlignLeft, Plus, Trash2, AlertCircle, Eye, EyeOff, Sparkles,
|
ArrowLeft, Layers, DatabaseZap, Loader2, X, CheckCircle2, FileText, LayoutTemplate, AlignLeft, Plus, Trash2, AlertCircle, Eye, EyeOff, Sparkles,
|
||||||
Bold, Italic, Heading1, Heading2, Heading3, List, ListOrdered, Quote, Table, Minus, Image as ImageIcon, Video, Box, Type, Code, RotateCcw, RotateCw,
|
Bold, Italic, Heading1, Heading2, Heading3, List, ListOrdered, Quote, Table, Minus, Image as ImageIcon, Video, Box, Type, Code, RotateCcw, RotateCw,
|
||||||
Maximize2, Minimize2, ChevronDown, FolderOpen, Upload, FolderPlus, ChevronRight, File, ArrowUpFromLine, Search, Grid3X3, LayoutList, Copy, Check
|
Maximize2, Minimize2, ChevronDown, FolderOpen, Upload, FolderPlus, ChevronRight, File, ArrowUpFromLine, Search, Grid3X3, LayoutList, Copy, Check, GripVertical
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getApplications, createApplication, updateApplicationData, toggleApplication, deleteApplication } from "./actions";
|
import { getApplications, createApplication, updateApplicationData, toggleApplication, deleteApplication, reorderApplications } from "./actions";
|
||||||
import { useHqUi } from "@/components/hq/Toast";
|
import { useHqUi } from "@/components/hq/Toast";
|
||||||
|
|
||||||
|
|
||||||
@@ -258,9 +258,29 @@ export default function ApplicationsManager() {
|
|||||||
const [sections, setSections] = useState<any[]>([]);
|
const [sections, setSections] = useState<any[]>([]);
|
||||||
const [dashboardMetrics, setDashboardMetrics] = useState<any[]>([]);
|
const [dashboardMetrics, setDashboardMetrics] = useState<any[]>([]);
|
||||||
|
|
||||||
|
const [draggedSlug, setDraggedSlug] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetchApps = async () => { setIsLoading(true); const res = await getApplications(); if (res.success && res.apps) setApps(res.apps); setIsLoading(false); };
|
const fetchApps = async () => { setIsLoading(true); const res = await getApplications(); if (res.success && res.apps) setApps(res.apps); setIsLoading(false); };
|
||||||
useEffect(() => { fetchApps(); }, []);
|
useEffect(() => { fetchApps(); }, []);
|
||||||
|
|
||||||
|
// Drag-to-reorder — same pattern as the Hero panel. Optimistic local
|
||||||
|
// reorder, then persist the new order to the DB.
|
||||||
|
const onDropApp = async (targetSlug: string) => {
|
||||||
|
if (!draggedSlug || draggedSlug === targetSlug) return;
|
||||||
|
const slugs = apps.map((a) => a.slug);
|
||||||
|
const from = slugs.indexOf(draggedSlug);
|
||||||
|
const to = slugs.indexOf(targetSlug);
|
||||||
|
if (from < 0 || to < 0) return;
|
||||||
|
const reordered = [...slugs];
|
||||||
|
reordered.splice(from, 1);
|
||||||
|
reordered.splice(to, 0, draggedSlug);
|
||||||
|
setApps((prev) => reordered.map((s) => prev.find((a) => a.slug === s)!));
|
||||||
|
setDraggedSlug(null);
|
||||||
|
const res = await reorderApplications(reordered);
|
||||||
|
if (res?.error) { ui.toast(res.error, "error"); fetchApps(); }
|
||||||
|
else ui.toast("Order updated.", "success");
|
||||||
|
};
|
||||||
|
|
||||||
const openEditModal = (app: any) => {
|
const openEditModal = (app: any) => {
|
||||||
setEditingApp(app); setActiveTab("basic");
|
setEditingApp(app); setActiveTab("basic");
|
||||||
try { setAdvantages(JSON.parse(app.advantagesJson || "[]")); } catch { setAdvantages([]); }
|
try { setAdvantages(JSON.parse(app.advantagesJson || "[]")); } catch { setAdvantages([]); }
|
||||||
@@ -299,7 +319,7 @@ export default function ApplicationsManager() {
|
|||||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-light text-white flex items-center gap-3"><Layers className="text-purple-400" /> Knowledge Base</h1>
|
<h1 className="text-3xl font-light text-white flex items-center gap-3"><Layers className="text-purple-400" /> Knowledge Base</h1>
|
||||||
<p className="text-[#86868B] mt-2">Manage the technical literature and general specifications of your application categories.</p>
|
<p className="text-[#86868B] mt-2">Manage the technical literature and specifications. Drag rows by the handle to reorder how applications appear on the site.</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setIsCreateModalOpen(true)} className="flex items-center gap-2 bg-purple-500/10 text-purple-400 border border-purple-500/20 px-5 py-2.5 rounded-xl font-medium hover:bg-purple-500 hover:text-white transition-all"><Plus size={18} /> New Application</button>
|
<button onClick={() => setIsCreateModalOpen(true)} className="flex items-center gap-2 bg-purple-500/10 text-purple-400 border border-purple-500/20 px-5 py-2.5 rounded-xl font-medium hover:bg-purple-500 hover:text-white transition-all"><Plus size={18} /> New Application</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -308,14 +328,24 @@ export default function ApplicationsManager() {
|
|||||||
<div className="bg-[#111] border border-white/5 rounded-3xl overflow-hidden shadow-2xl">
|
<div className="bg-[#111] border border-white/5 rounded-3xl overflow-hidden shadow-2xl">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left border-collapse">
|
<table className="w-full text-left border-collapse">
|
||||||
<thead><tr className="border-b border-white/5 text-[10px] uppercase tracking-widest text-[#86868B] bg-black/40"><th className="p-6 font-semibold">Application Sector</th><th className="p-6 font-semibold">Status</th><th className="p-6 font-semibold">Visibility</th><th className="p-6 font-semibold text-right">Actions</th></tr></thead>
|
<thead><tr className="border-b border-white/5 text-[10px] uppercase tracking-widest text-[#86868B] bg-black/40"><th className="p-6 font-semibold w-10"></th><th className="p-6 font-semibold">Application Sector</th><th className="p-6 font-semibold">Status</th><th className="p-6 font-semibold">Visibility</th><th className="p-6 font-semibold text-right">Actions</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<tr><td colSpan={4} className="p-8 text-center text-[#86868B]"><Loader2 className="animate-spin mx-auto mb-2" size={24} /> Loading database...</td></tr>
|
<tr><td colSpan={5} className="p-8 text-center text-[#86868B]"><Loader2 className="animate-spin mx-auto mb-2" size={24} /> Loading database...</td></tr>
|
||||||
|
) : apps.length === 0 ? (
|
||||||
|
<tr><td colSpan={5} className="p-8 text-center text-[#86868B]">No applications yet.</td></tr>
|
||||||
) : apps.map((app) => {
|
) : apps.map((app) => {
|
||||||
const isPopulated = app.heroDescription && app.heroDescription.length > 10;
|
const isPopulated = app.heroDescription && app.heroDescription.length > 10;
|
||||||
return (
|
return (
|
||||||
<tr key={app.slug} className={`border-b border-white/5 transition-colors group ${app.isActive ? 'hover:bg-white/[0.02]' : 'opacity-50'}`}>
|
<tr
|
||||||
|
key={app.slug}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => setDraggedSlug(app.slug)}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={() => onDropApp(app.slug)}
|
||||||
|
className={`border-b border-white/5 transition-colors group ${draggedSlug === app.slug ? 'opacity-40' : ''} ${app.isActive ? 'hover:bg-white/[0.02]' : 'opacity-50'}`}
|
||||||
|
>
|
||||||
|
<td className="p-6"><span className="cursor-grab text-[#86868B] hover:text-white inline-flex" title="Drag to reorder"><GripVertical size={16} /></span></td>
|
||||||
<td className="p-6"><p className="font-medium text-white">{app.title}</p><p className="text-xs text-[#86868B] font-mono mt-1">/{app.slug}</p></td>
|
<td className="p-6"><p className="font-medium text-white">{app.title}</p><p className="text-xs text-[#86868B] font-mono mt-1">/{app.slug}</p></td>
|
||||||
<td className="p-6">{isPopulated ? <span className="bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-3 py-1 rounded-full text-[10px] uppercase tracking-wider flex items-center gap-1.5 w-fit font-semibold"><CheckCircle2 size={12} /> Populated</span> : <span className="bg-white/5 text-[#86868B] border border-white/10 px-3 py-1 rounded-full text-[10px] uppercase tracking-wider flex items-center gap-1.5 w-fit"><AlertCircle size={12} /> Pending Setup</span>}</td>
|
<td className="p-6">{isPopulated ? <span className="bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-3 py-1 rounded-full text-[10px] uppercase tracking-wider flex items-center gap-1.5 w-fit font-semibold"><CheckCircle2 size={12} /> Populated</span> : <span className="bg-white/5 text-[#86868B] border border-white/10 px-3 py-1 rounded-full text-[10px] uppercase tracking-wider flex items-center gap-1.5 w-fit"><AlertCircle size={12} /> Pending Setup</span>}</td>
|
||||||
<td className="p-6"><button onClick={() => {toggleApplication(app.slug, app.isActive); fetchApps();}} className={`text-xs font-medium px-3 py-1 rounded-full flex items-center gap-1.5 transition-colors ${app.isActive ? 'bg-purple-500/10 text-purple-400' : 'bg-red-500/10 text-red-400'}`}>{app.isActive ? <><Eye size={12} /> Visible</> : <><EyeOff size={12} /> Hidden</>}</button></td>
|
<td className="p-6"><button onClick={() => {toggleApplication(app.slug, app.isActive); fetchApps();}} className={`text-xs font-medium px-3 py-1 rounded-full flex items-center gap-1.5 transition-colors ${app.isActive ? 'bg-purple-500/10 text-purple-400' : 'bg-red-500/10 text-red-400'}`}>{app.isActive ? <><Eye size={12} /> Visible</> : <><EyeOff size={12} /> Hidden</>}</button></td>
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// src/app/hq-command/dashboard/conversations/[id]/page.tsx
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// FluxAI conversation detail — full event timeline, tool calls expanded,
|
||||||
|
// link to the OperationsSignal when the chat converted.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { ArrowLeft, Bot, User, Wrench, AlertTriangle, MessageSquare } from "lucide-react";
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ConversationDetailPage({ params }: PageProps) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const conversation = await prisma.aiConversation.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
events: { orderBy: { createdAt: "asc" } },
|
||||||
|
signal: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!conversation) notFound();
|
||||||
|
|
||||||
|
const durationMs =
|
||||||
|
(conversation.closedAt ?? conversation.lastMessageAt).getTime() -
|
||||||
|
conversation.startedAt.getTime();
|
||||||
|
const durationLabel =
|
||||||
|
durationMs < 60000 ? `${Math.round(durationMs / 1000)}s` : `${Math.round(durationMs / 60000)} min`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen px-6 md:px-12 py-10 max-w-5xl mx-auto">
|
||||||
|
<Link
|
||||||
|
href="/hq-command/dashboard/conversations"
|
||||||
|
className="inline-flex items-center gap-2 text-xs text-white/50 hover:text-white mb-6"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={14} /> Back to conversations
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<header className="mb-8">
|
||||||
|
<h1 className="text-2xl font-light tracking-tight">Conversation</h1>
|
||||||
|
<p className="text-xs font-mono text-white/40 mt-1">{conversation.sessionId}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-8">
|
||||||
|
<Meta label="Started" value={conversation.startedAt.toISOString().slice(0, 16).replace("T", " ")} />
|
||||||
|
<Meta label="Duration" value={durationLabel} />
|
||||||
|
<Meta label="Industry" value={conversation.industryLabel ?? "—"} />
|
||||||
|
<Meta label="Locale" value={conversation.locale ?? "—"} />
|
||||||
|
<Meta label="Stage" value={conversation.funnelStage} />
|
||||||
|
<Meta label="Outcome" value={conversation.outcome} />
|
||||||
|
<Meta label="Messages" value={String(conversation.messageCount)} />
|
||||||
|
<Meta label="Tool calls" value={String(conversation.toolCallCount)} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{conversation.signal ? (
|
||||||
|
<div className="mb-8 rounded-2xl border border-emerald-500/30 bg-emerald-500/[0.04] p-5">
|
||||||
|
<div className="text-xs uppercase tracking-widest text-emerald-300">
|
||||||
|
Converted to consultation
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm">
|
||||||
|
<span className="font-mono text-emerald-300">{conversation.signal.ticketId}</span>
|
||||||
|
{" · "}
|
||||||
|
<span className="text-white/70">{conversation.signal.clientName}</span>
|
||||||
|
{" · "}
|
||||||
|
<span className="text-white/50">{conversation.signal.clientCompany}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{conversation.pageUrl ? (
|
||||||
|
<p className="mb-6 text-xs text-white/40">
|
||||||
|
Entry page: <span className="text-white/60">{conversation.pageUrl}</span>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<h2 className="text-xs uppercase tracking-widest text-white/40 mb-3">Event timeline</h2>
|
||||||
|
<ol className="space-y-2">
|
||||||
|
{conversation.events.map((ev) => (
|
||||||
|
<li
|
||||||
|
key={ev.id}
|
||||||
|
className="rounded-xl border border-white/10 bg-white/[0.02] px-4 py-3 text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<EventIcon type={ev.type} />
|
||||||
|
<span className="text-xs uppercase tracking-widest text-white/40">
|
||||||
|
{ev.type}
|
||||||
|
{ev.toolName ? ` · ${ev.toolName}` : ""}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto text-[10px] text-white/30">
|
||||||
|
{ev.createdAt.toISOString().slice(11, 19)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<pre className="mt-2 whitespace-pre-wrap text-xs text-white/70 break-words font-mono">
|
||||||
|
{truncate(ev.payloadJson, 1200)}
|
||||||
|
</pre>
|
||||||
|
{ev.tokensIn || ev.tokensOut || ev.latencyMs ? (
|
||||||
|
<div className="mt-2 flex gap-4 text-[10px] text-white/40">
|
||||||
|
{ev.latencyMs ? <span>{ev.latencyMs} ms</span> : null}
|
||||||
|
{ev.tokensIn ? <span>in: {ev.tokensIn}</span> : null}
|
||||||
|
{ev.tokensOut ? <span>out: {ev.tokensOut}</span> : null}
|
||||||
|
{ev.cachedTokens ? <span>cached: {ev.cachedTokens}</span> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(s: string, n: number) {
|
||||||
|
if (s.length <= n) return s;
|
||||||
|
return s.slice(0, n) + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
function Meta({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-white/5 bg-white/[0.02] px-4 py-3">
|
||||||
|
<div className="text-[10px] uppercase tracking-widest text-white/30">{label}</div>
|
||||||
|
<div className="text-sm mt-1">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventIcon({ type }: { type: string }) {
|
||||||
|
if (type === "user_msg") return <User size={14} className="text-[#00F0FF]" />;
|
||||||
|
if (type === "ai_msg") return <Bot size={14} className="text-purple-300" />;
|
||||||
|
if (type === "tool_call") return <Wrench size={14} className="text-amber-300" />;
|
||||||
|
if (type === "tool_result") return <Wrench size={14} className="text-emerald-300" />;
|
||||||
|
if (type === "error") return <AlertTriangle size={14} className="text-red-400" />;
|
||||||
|
return <MessageSquare size={14} className="text-white/40" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
// src/app/hq-command/dashboard/conversations/page.tsx
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// FluxAI Conversations — analytics MVP for the HQ Command Center.
|
||||||
|
// Surfaces what visitors are actually asking, which industries dominate,
|
||||||
|
// and how many chats convert into consultation tickets.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
MessageSquare,
|
||||||
|
Target,
|
||||||
|
Activity,
|
||||||
|
TrendingUp,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
type StageRow = { funnelStage: string; _count: { _all: number } };
|
||||||
|
type IndustryRow = { industryLabel: string | null; _count: { _all: number } };
|
||||||
|
type OutcomeRow = { outcome: string; _count: { _all: number } };
|
||||||
|
|
||||||
|
export default async function ConversationsDashboardPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ stage?: string; outcome?: string; industry?: string }>;
|
||||||
|
}) {
|
||||||
|
const { stage, outcome, industry } = (await searchParams) ?? {};
|
||||||
|
|
||||||
|
// ── KPI: counts + breakdowns ─────────────────────────────────────────────
|
||||||
|
const [
|
||||||
|
total,
|
||||||
|
stageBreakdown,
|
||||||
|
industryBreakdown,
|
||||||
|
outcomeBreakdown,
|
||||||
|
averages,
|
||||||
|
] = await Promise.all([
|
||||||
|
prisma.aiConversation.count(),
|
||||||
|
prisma.aiConversation.groupBy({
|
||||||
|
by: ["funnelStage"],
|
||||||
|
_count: { _all: true },
|
||||||
|
}) as unknown as Promise<StageRow[]>,
|
||||||
|
prisma.aiConversation.groupBy({
|
||||||
|
by: ["industryLabel"],
|
||||||
|
_count: { _all: true },
|
||||||
|
orderBy: { _count: { industryLabel: "desc" } },
|
||||||
|
take: 5,
|
||||||
|
}) as unknown as Promise<IndustryRow[]>,
|
||||||
|
prisma.aiConversation.groupBy({
|
||||||
|
by: ["outcome"],
|
||||||
|
_count: { _all: true },
|
||||||
|
}) as unknown as Promise<OutcomeRow[]>,
|
||||||
|
prisma.aiConversation.aggregate({
|
||||||
|
_avg: { messageCount: true, toolCallCount: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const consultationCount =
|
||||||
|
outcomeBreakdown.find((r) => r.outcome === "CONSULTATION")?._count._all ?? 0;
|
||||||
|
const conversionRate = total > 0 ? Math.round((consultationCount / total) * 10000) / 100 : 0;
|
||||||
|
|
||||||
|
// ── Recent conversations list ────────────────────────────────────────────
|
||||||
|
const conversations = await prisma.aiConversation.findMany({
|
||||||
|
where: {
|
||||||
|
...(stage ? { funnelStage: stage } : {}),
|
||||||
|
...(outcome ? { outcome } : {}),
|
||||||
|
...(industry ? { industryLabel: industry } : {}),
|
||||||
|
},
|
||||||
|
orderBy: { startedAt: "desc" },
|
||||||
|
take: 50,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
sessionId: true,
|
||||||
|
startedAt: true,
|
||||||
|
lastMessageAt: true,
|
||||||
|
industryLabel: true,
|
||||||
|
funnelStage: true,
|
||||||
|
outcome: true,
|
||||||
|
messageCount: true,
|
||||||
|
toolCallCount: true,
|
||||||
|
estimatedSavingsPercent: true,
|
||||||
|
pageUrl: true,
|
||||||
|
locale: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen px-6 md:px-12 py-10 max-w-7xl mx-auto">
|
||||||
|
<Link
|
||||||
|
href="/hq-command/dashboard"
|
||||||
|
className="inline-flex items-center gap-2 text-xs text-white/50 hover:text-white mb-6"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={14} /> Back to Command Center
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<header className="mb-10">
|
||||||
|
<h1 className="text-3xl font-light tracking-tight">FluxAI Conversations</h1>
|
||||||
|
<p className="text-sm text-white/50 mt-1">
|
||||||
|
Funnel analytics for every chat with the on-site engineering assistant.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ── KPI tiles ───────────────────────────────────────────────────── */}
|
||||||
|
<section className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-10">
|
||||||
|
<Kpi
|
||||||
|
icon={<MessageSquare size={16} />}
|
||||||
|
label="Total conversations"
|
||||||
|
value={String(total)}
|
||||||
|
accent="text-[#00F0FF]"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
icon={<Target size={16} />}
|
||||||
|
label="Conversion rate"
|
||||||
|
value={`${conversionRate}%`}
|
||||||
|
sub={`${consultationCount} → consultation`}
|
||||||
|
accent="text-emerald-400"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
icon={<Activity size={16} />}
|
||||||
|
label="Avg messages / chat"
|
||||||
|
value={(averages._avg.messageCount ?? 0).toFixed(1)}
|
||||||
|
accent="text-purple-400"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
icon={<TrendingUp size={16} />}
|
||||||
|
label="Avg tool calls"
|
||||||
|
value={(averages._avg.toolCallCount ?? 0).toFixed(1)}
|
||||||
|
accent="text-amber-400"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Funnel + Industries ─────────────────────────────────────────── */}
|
||||||
|
<section className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-10">
|
||||||
|
<Panel title="Funnel stages">
|
||||||
|
{stageBreakdown.length === 0 ? (
|
||||||
|
<Empty label="No conversations yet." />
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{(["DISCOVERY", "QUALIFY", "RECOMMEND", "HANDOFF"] as const).map((s) => {
|
||||||
|
const row = stageBreakdown.find((r) => r.funnelStage === s);
|
||||||
|
const count = row?._count._all ?? 0;
|
||||||
|
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<li key={s} className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-white/70">{s}</span>
|
||||||
|
<span className="text-white/40">{count} · {pct}%</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel title="Top industries">
|
||||||
|
{industryBreakdown.length === 0 ? (
|
||||||
|
<Empty label="No industry signals captured yet." />
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{industryBreakdown.map((row, i) => (
|
||||||
|
<li key={i} className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-white/70">
|
||||||
|
{row.industryLabel ?? "Unknown"}
|
||||||
|
</span>
|
||||||
|
<span className="text-white/40">{row._count._all}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Recent conversations table ──────────────────────────────────── */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-sm uppercase tracking-widest text-white/40 mb-3">
|
||||||
|
Last 50 conversations
|
||||||
|
</h2>
|
||||||
|
<div className="overflow-x-auto rounded-2xl border border-white/10">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-white/[0.02] text-xs uppercase tracking-widest text-white/40">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left">Started</th>
|
||||||
|
<th className="px-4 py-3 text-left">Industry</th>
|
||||||
|
<th className="px-4 py-3 text-left">Stage</th>
|
||||||
|
<th className="px-4 py-3 text-left">Outcome</th>
|
||||||
|
<th className="px-4 py-3 text-right">Msgs</th>
|
||||||
|
<th className="px-4 py-3 text-right">Tools</th>
|
||||||
|
<th className="px-4 py-3 text-left">Locale</th>
|
||||||
|
<th className="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{conversations.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-4 py-8 text-center text-white/40">
|
||||||
|
No conversations match these filters yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
conversations.map((c) => (
|
||||||
|
<tr key={c.id} className="border-t border-white/5 hover:bg-white/[0.02]">
|
||||||
|
<td className="px-4 py-3 text-white/60 whitespace-nowrap">
|
||||||
|
{c.startedAt.toISOString().slice(0, 16).replace("T", " ")}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">{c.industryLabel ?? "—"}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<StagePill stage={c.funnelStage} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<OutcomePill outcome={c.outcome} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-white/60">{c.messageCount}</td>
|
||||||
|
<td className="px-4 py-3 text-right text-white/60">{c.toolCallCount}</td>
|
||||||
|
<td className="px-4 py-3 text-white/60">{c.locale ?? "—"}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<Link
|
||||||
|
href={`/hq-command/dashboard/conversations/${c.id}`}
|
||||||
|
className="text-[#00F0FF] hover:underline text-xs"
|
||||||
|
>
|
||||||
|
Open →
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Kpi({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
sub,
|
||||||
|
accent,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
sub?: string;
|
||||||
|
accent?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/[0.02] p-5">
|
||||||
|
<div className={`flex items-center gap-2 text-xs uppercase tracking-widest ${accent ?? "text-white/40"}`}>
|
||||||
|
{icon}
|
||||||
|
<span>{label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-2xl font-light tracking-tight">{value}</div>
|
||||||
|
{sub ? <div className="text-xs text-white/40 mt-1">{sub}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/[0.02] p-6">
|
||||||
|
<h3 className="text-xs uppercase tracking-widest text-white/40 mb-4">{title}</h3>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Empty({ label }: { label: string }) {
|
||||||
|
return <p className="text-sm text-white/40 italic">{label}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StagePill({ stage }: { stage: string }) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
DISCOVERY: "bg-white/10 text-white/70",
|
||||||
|
QUALIFY: "bg-purple-500/15 text-purple-300",
|
||||||
|
RECOMMEND: "bg-amber-500/15 text-amber-300",
|
||||||
|
HANDOFF: "bg-emerald-500/15 text-emerald-300",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={`px-2 py-1 rounded-full text-[10px] uppercase tracking-widest ${map[stage] ?? "bg-white/10 text-white/70"}`}>
|
||||||
|
{stage}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OutcomePill({ outcome }: { outcome: string }) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
OPEN: "bg-white/10 text-white/60",
|
||||||
|
CONSULTATION: "bg-emerald-500/15 text-emerald-300",
|
||||||
|
ABANDONED: "bg-red-500/10 text-red-300/70",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={`px-2 py-1 rounded-full text-[10px] uppercase tracking-widest ${map[outcome] ?? "bg-white/10 text-white/60"}`}>
|
||||||
|
{outcome}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -133,6 +133,15 @@ export default async function DashboardPage() {
|
|||||||
bg: "bg-white/10",
|
bg: "bg-white/10",
|
||||||
border: "hover:border-white/50"
|
border: "hover:border-white/50"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "The Team",
|
||||||
|
description: "Add team members with photo, bio and social links. Drag to reorder.",
|
||||||
|
icon: Users,
|
||||||
|
href: "/hq-command/dashboard/team",
|
||||||
|
color: "text-sky-400",
|
||||||
|
bg: "bg-sky-500/10",
|
||||||
|
border: "hover:border-sky-500/50"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Component Matrix",
|
title: "Component Matrix",
|
||||||
description: "Manage the spare parts catalog, pricing, and SKUs.",
|
description: "Manage the spare parts catalog, pricing, and SKUs.",
|
||||||
@@ -177,6 +186,15 @@ export default async function DashboardPage() {
|
|||||||
color: "text-fuchsia-400",
|
color: "text-fuchsia-400",
|
||||||
bg: "bg-fuchsia-500/10",
|
bg: "bg-fuchsia-500/10",
|
||||||
border: "hover:border-fuchsia-500/50"
|
border: "hover:border-fuchsia-500/50"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "FluxAI Conversations",
|
||||||
|
description: "Funnel analytics, top industries, and full transcripts of every chat with FluxAI.",
|
||||||
|
icon: Sparkles,
|
||||||
|
href: "/hq-command/dashboard/conversations",
|
||||||
|
color: "text-[#00F0FF]",
|
||||||
|
bg: "bg-[#00F0FF]/10",
|
||||||
|
border: "hover:border-[#00F0FF]/50"
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { translateContentForCMS } from "@/lib/aiTranslator";
|
||||||
|
import { log } from "@/lib/logger";
|
||||||
|
|
||||||
|
export interface TeamMemberInput {
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
bio?: string | null;
|
||||||
|
photoUrl?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
linkedinUrl?: string | null;
|
||||||
|
xUrl?: string | null;
|
||||||
|
websiteUrl?: string | null;
|
||||||
|
autoTranslate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function revalidateTeam() {
|
||||||
|
revalidatePath("/team");
|
||||||
|
revalidatePath("/[locale]/team", "layout");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTeamMembers() {
|
||||||
|
try {
|
||||||
|
const members = await prisma.teamMember.findMany({
|
||||||
|
orderBy: [{ order: "asc" }, { createdAt: "asc" }],
|
||||||
|
});
|
||||||
|
return { success: true, members };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
log.error("team.list_failed", error);
|
||||||
|
return { error: error instanceof Error ? error.message : "Failed to load team" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translatable fields only — name is a proper noun and never translated.
|
||||||
|
async function buildTranslations(role: string, bio: string | null | undefined, autoTranslate: boolean) {
|
||||||
|
const englishFields: Record<string, string> = { role };
|
||||||
|
if (bio) englishFields.bio = bio;
|
||||||
|
|
||||||
|
const merged: Record<string, Record<string, string>> = { en: englishFields };
|
||||||
|
|
||||||
|
if (autoTranslate) {
|
||||||
|
const aiResult = await translateContentForCMS(englishFields);
|
||||||
|
if (aiResult) {
|
||||||
|
for (const [locale, fields] of Object.entries(aiResult)) {
|
||||||
|
merged[locale] = { ...merged[locale], ...(fields as Record<string, string>) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return JSON.stringify(merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTeamMember(input: TeamMemberInput) {
|
||||||
|
try {
|
||||||
|
const last = await prisma.teamMember.findFirst({
|
||||||
|
orderBy: { order: "desc" },
|
||||||
|
select: { order: true },
|
||||||
|
});
|
||||||
|
const nextOrder = last ? last.order + 1 : 0;
|
||||||
|
|
||||||
|
const translationsJson = await buildTranslations(input.role, input.bio, !!input.autoTranslate);
|
||||||
|
|
||||||
|
const member = await prisma.teamMember.create({
|
||||||
|
data: {
|
||||||
|
name: input.name,
|
||||||
|
role: input.role,
|
||||||
|
bio: input.bio || null,
|
||||||
|
photoUrl: input.photoUrl || null,
|
||||||
|
email: input.email || null,
|
||||||
|
linkedinUrl: input.linkedinUrl || null,
|
||||||
|
xUrl: input.xUrl || null,
|
||||||
|
websiteUrl: input.websiteUrl || null,
|
||||||
|
order: nextOrder,
|
||||||
|
translationsJson,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidateTeam();
|
||||||
|
return { success: true, member };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
log.error("team.create_failed", error);
|
||||||
|
return { error: error instanceof Error ? error.message : "Failed to create member" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTeamMember(id: string, input: Partial<TeamMemberInput> & { isActive?: boolean }) {
|
||||||
|
try {
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
if (input.name !== undefined) data.name = input.name;
|
||||||
|
if (input.role !== undefined) data.role = input.role;
|
||||||
|
if (input.bio !== undefined) data.bio = input.bio || null;
|
||||||
|
if (input.photoUrl !== undefined) data.photoUrl = input.photoUrl || null;
|
||||||
|
if (input.email !== undefined) data.email = input.email || null;
|
||||||
|
if (input.linkedinUrl !== undefined) data.linkedinUrl = input.linkedinUrl || null;
|
||||||
|
if (input.xUrl !== undefined) data.xUrl = input.xUrl || null;
|
||||||
|
if (input.websiteUrl !== undefined) data.websiteUrl = input.websiteUrl || null;
|
||||||
|
if (input.isActive !== undefined) data.isActive = input.isActive;
|
||||||
|
|
||||||
|
// Rebuild translations when role or bio changed (or a translate was requested).
|
||||||
|
if (input.role !== undefined || input.bio !== undefined || input.autoTranslate) {
|
||||||
|
const existing = await prisma.teamMember.findUnique({ where: { id } });
|
||||||
|
const role = input.role ?? existing?.role ?? "";
|
||||||
|
const bio = input.bio ?? existing?.bio ?? null;
|
||||||
|
data.translationsJson = await buildTranslations(role, bio, !!input.autoTranslate);
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await prisma.teamMember.update({ where: { id }, data });
|
||||||
|
revalidateTeam();
|
||||||
|
return { success: true, member };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
log.error("team.update_failed", error, { id });
|
||||||
|
return { error: error instanceof Error ? error.message : "Failed to update member" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTeamMember(id: string) {
|
||||||
|
try {
|
||||||
|
await prisma.teamMember.delete({ where: { id } });
|
||||||
|
revalidateTeam();
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
log.error("team.delete_failed", error, { id });
|
||||||
|
return { error: error instanceof Error ? error.message : "Failed to delete member" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reorderTeamMembers(orderedIds: string[]) {
|
||||||
|
try {
|
||||||
|
await prisma.$transaction(
|
||||||
|
orderedIds.map((id, idx) =>
|
||||||
|
prisma.teamMember.update({ where: { id }, data: { order: idx } }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
revalidateTeam();
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
log.error("team.reorder_failed", error);
|
||||||
|
return { error: error instanceof Error ? error.message : "Failed to reorder" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
ArrowLeft, Users, Plus, Trash2, Loader2, GripVertical, Eye, EyeOff,
|
||||||
|
Sparkles, Upload, Check, Linkedin, Mail, Globe, Twitter, ChevronDown,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
getTeamMembers, createTeamMember, updateTeamMember, deleteTeamMember,
|
||||||
|
reorderTeamMembers,
|
||||||
|
} from "./actions";
|
||||||
|
import { useHqUi } from "@/components/hq/Toast";
|
||||||
|
|
||||||
|
interface MemberRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
bio: string | null;
|
||||||
|
photoUrl: string | null;
|
||||||
|
email: string | null;
|
||||||
|
linkedinUrl: string | null;
|
||||||
|
xUrl: string | null;
|
||||||
|
websiteUrl: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
order: number;
|
||||||
|
translationsJson: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TeamDashboard() {
|
||||||
|
const ui = useHqUi();
|
||||||
|
const [members, setMembers] = useState<MemberRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
const [savingId, setSavingId] = useState<string | null>(null);
|
||||||
|
const [savedFlash, setSavedFlash] = useState<string | null>(null);
|
||||||
|
const [draggedId, setDraggedId] = useState<string | null>(null);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await getTeamMembers();
|
||||||
|
if (res.success && res.members) setMembers(res.members as MemberRow[]);
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const flashSaved = (id: string) => {
|
||||||
|
setSavedFlash(id);
|
||||||
|
setTimeout(() => setSavedFlash(null), 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
setCreating(true);
|
||||||
|
const res = await createTeamMember({ name: "New member", role: "Role / Title" });
|
||||||
|
setCreating(false);
|
||||||
|
if (res.success && res.member) {
|
||||||
|
await load();
|
||||||
|
setExpandedId(res.member.id);
|
||||||
|
} else {
|
||||||
|
ui.toast(res.error || "Could not create member", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inline patch with optimistic update + auto-save (name/role/isActive).
|
||||||
|
const patch = async (id: string, p: Partial<MemberRow>) => {
|
||||||
|
setMembers((prev) => prev.map((m) => (m.id === id ? { ...m, ...p } : m)));
|
||||||
|
setSavingId(id);
|
||||||
|
const res = await updateTeamMember(id, p as never);
|
||||||
|
setSavingId(null);
|
||||||
|
if (res.success) flashSaved(id);
|
||||||
|
else ui.toast(res.error || "Save failed", "error");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string, name: string) => {
|
||||||
|
const ok = await ui.confirm({
|
||||||
|
title: "Remove team member",
|
||||||
|
message: `Remove ${name} from the public team page. This cannot be undone.`,
|
||||||
|
confirmLabel: "Remove",
|
||||||
|
destructive: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
await deleteTeamMember(id);
|
||||||
|
ui.toast("Member removed.", "success");
|
||||||
|
await load();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Drag reorder — same pattern as the Hero panel.
|
||||||
|
const onDrop = async (targetId: string) => {
|
||||||
|
if (!draggedId || draggedId === targetId) return;
|
||||||
|
const ids = members.map((m) => m.id);
|
||||||
|
const from = ids.indexOf(draggedId);
|
||||||
|
const to = ids.indexOf(targetId);
|
||||||
|
if (from < 0 || to < 0) return;
|
||||||
|
const reordered = [...ids];
|
||||||
|
reordered.splice(from, 1);
|
||||||
|
reordered.splice(to, 0, draggedId);
|
||||||
|
setMembers((prev) => reordered.map((id, i) => ({ ...prev.find((m) => m.id === id)!, order: i })));
|
||||||
|
setDraggedId(null);
|
||||||
|
await reorderTeamMembers(reordered);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen p-6 md:p-12 max-w-5xl mx-auto">
|
||||||
|
<Link
|
||||||
|
href="/hq-command/dashboard"
|
||||||
|
className="inline-flex items-center gap-2 text-sm text-[#86868B] hover:text-white mb-6 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={14} /> Back to Dashboard
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4 mb-8">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 text-[#00F0FF] mb-2">
|
||||||
|
<Users size={16} />
|
||||||
|
<span className="text-[10px] uppercase tracking-widest font-bold">The Team</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl md:text-4xl font-light text-white">
|
||||||
|
Team <span className="font-medium">Members.</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-[#86868B] mt-2 text-sm">
|
||||||
|
Drag to reorder. Click a card to edit photo, bio and social links. Name & role auto-save.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={creating}
|
||||||
|
className="inline-flex items-center gap-2 bg-[#00F0FF] text-black px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-[#00F0FF]/80 transition-colors disabled:opacity-50 shrink-0"
|
||||||
|
>
|
||||||
|
{creating ? <Loader2 size={14} className="animate-spin" /> : <Plus size={16} />}
|
||||||
|
Add member
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-20 text-[#86868B]">
|
||||||
|
<Loader2 className="animate-spin mr-2" size={16} /> Loading team…
|
||||||
|
</div>
|
||||||
|
) : members.length === 0 ? (
|
||||||
|
<div className="text-center py-20 text-[#86868B] bg-white/[0.02] rounded-3xl border border-white/5">
|
||||||
|
<Users size={32} className="mx-auto mb-3 opacity-40" />
|
||||||
|
<p>No team members yet.</p>
|
||||||
|
<p className="text-xs mt-1">Click “Add member” to build the public team page.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{members.map((m) => {
|
||||||
|
const isExpanded = expandedId === m.id;
|
||||||
|
const isSaving = savingId === m.id;
|
||||||
|
const justSaved = savedFlash === m.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => setDraggedId(m.id)}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={() => onDrop(m.id)}
|
||||||
|
className={`bg-[#111] border rounded-2xl overflow-hidden transition-all ${
|
||||||
|
draggedId === m.id ? "opacity-50" : ""
|
||||||
|
} ${m.isActive ? "border-white/10" : "border-white/5 opacity-60"}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 p-3">
|
||||||
|
<button className="cursor-grab text-[#86868B] hover:text-white p-1" title="Drag to reorder">
|
||||||
|
<GripVertical size={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="relative w-14 h-14 rounded-full overflow-hidden bg-black/40 flex-shrink-0 border border-white/10">
|
||||||
|
{m.photoUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={m.photoUrl} alt={m.name} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-[#86868B]">
|
||||||
|
<Users size={18} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<input
|
||||||
|
value={m.name}
|
||||||
|
onChange={(e) => setMembers((prev) => prev.map((x) => (x.id === m.id ? { ...x, name: e.target.value } : x)))}
|
||||||
|
onBlur={(e) => patch(m.id, { name: e.target.value })}
|
||||||
|
placeholder="Full name"
|
||||||
|
className="w-full bg-transparent border-0 outline-none text-white text-sm font-medium placeholder:text-[#86868B] focus:bg-white/[0.04] rounded px-2 py-1 -mx-2"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={m.role}
|
||||||
|
onChange={(e) => setMembers((prev) => prev.map((x) => (x.id === m.id ? { ...x, role: e.target.value } : x)))}
|
||||||
|
onBlur={(e) => patch(m.id, { role: e.target.value })}
|
||||||
|
placeholder="Role / title"
|
||||||
|
className="w-full bg-transparent border-0 outline-none text-[#00F0FF] text-xs placeholder:text-[#86868B] focus:bg-white/[0.04] rounded px-2 py-0.5 -mx-2 mt-0.5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 text-xs">
|
||||||
|
{isSaving && <Loader2 size={12} className="animate-spin text-[#00F0FF]" />}
|
||||||
|
{justSaved && <span className="text-emerald-400 flex items-center gap-1"><Check size={12} /> Saved</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => patch(m.id, { isActive: !m.isActive })}
|
||||||
|
className={`p-2 rounded-lg transition-colors ${m.isActive ? "text-emerald-400 hover:bg-emerald-500/10" : "text-[#86868B] hover:bg-white/5"}`}
|
||||||
|
title={m.isActive ? "Hide from team page" : "Show on team page"}
|
||||||
|
>
|
||||||
|
{m.isActive ? <Eye size={16} /> : <EyeOff size={16} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedId(isExpanded ? null : m.id)}
|
||||||
|
className="p-2 rounded-lg text-[#86868B] hover:bg-white/5 hover:text-white"
|
||||||
|
title="Edit details"
|
||||||
|
>
|
||||||
|
<ChevronDown size={16} className={`transition-transform ${isExpanded ? "rotate-180 text-[#00F0FF]" : ""}`} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(m.id, m.name)}
|
||||||
|
className="p-2 rounded-lg text-rose-400 hover:bg-rose-500/10"
|
||||||
|
title="Remove member"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<MemberEditor
|
||||||
|
member={m}
|
||||||
|
onSaved={async () => { await load(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Expanded editor: photo upload + bio + social links + AI translate ───────
|
||||||
|
function MemberEditor({ member, onSaved }: { member: MemberRow; onSaved: () => Promise<void> }) {
|
||||||
|
const ui = useHqUi();
|
||||||
|
const [photoUrl, setPhotoUrl] = useState(member.photoUrl || "");
|
||||||
|
const [bio, setBio] = useState(member.bio || "");
|
||||||
|
const [email, setEmail] = useState(member.email || "");
|
||||||
|
const [linkedinUrl, setLinkedinUrl] = useState(member.linkedinUrl || "");
|
||||||
|
const [xUrl, setXUrl] = useState(member.xUrl || "");
|
||||||
|
const [websiteUrl, setWebsiteUrl] = useState(member.websiteUrl || "");
|
||||||
|
const [autoTranslate, setAutoTranslate] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const uploadPhoto = async (file: File) => {
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("scope", "team");
|
||||||
|
fd.append("optimize", "1");
|
||||||
|
fd.append("file", file);
|
||||||
|
const res = await fetch("/api/assets", { method: "POST", body: fd });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) setPhotoUrl(data.file.publicUrl);
|
||||||
|
else ui.toast(data.error || "Upload failed", "error");
|
||||||
|
} catch (err: unknown) {
|
||||||
|
ui.toast(err instanceof Error ? err.message : "Upload failed", "error");
|
||||||
|
}
|
||||||
|
setUploading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
const res = await updateTeamMember(member.id, {
|
||||||
|
bio, photoUrl, email, linkedinUrl, xUrl, websiteUrl, autoTranslate,
|
||||||
|
});
|
||||||
|
setSaving(false);
|
||||||
|
if (res.success) { ui.toast("Saved.", "success"); await onSaved(); }
|
||||||
|
else ui.toast(res.error || "Save failed", "error");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t border-white/5 bg-white/[0.02] p-5 space-y-4">
|
||||||
|
{/* Photo */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative w-20 h-20 rounded-full overflow-hidden bg-black/40 border border-white/10 flex-shrink-0">
|
||||||
|
{photoUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={photoUrl} alt="" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-[#86868B]"><Users size={20} /></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) uploadPhoto(f); e.target.value = ""; }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
className="inline-flex items-center gap-2 bg-white/5 hover:bg-white/10 text-white text-xs px-3 py-2 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{uploading ? <Loader2 size={12} className="animate-spin" /> : <Upload size={12} />}
|
||||||
|
{photoUrl ? "Replace photo" : "Upload photo"}
|
||||||
|
</button>
|
||||||
|
<p className="text-[10px] text-[#86868B] mt-1.5">Square portrait recommended, min 400×400.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bio */}
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase tracking-widest text-[#86868B] font-bold">Bio (English master)</label>
|
||||||
|
<textarea
|
||||||
|
value={bio}
|
||||||
|
onChange={(e) => setBio(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
placeholder="Short biography. Markdown supported."
|
||||||
|
className="mt-1.5 w-full bg-black/40 border border-white/10 text-white text-sm rounded-lg px-3 py-2 outline-none focus:border-[#00F0FF]/40 resize-y"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Social links */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<SocialInput icon={<Mail size={13} />} value={email} onChange={setEmail} placeholder="email@fluxsrl.com" />
|
||||||
|
<SocialInput icon={<Linkedin size={13} />} value={linkedinUrl} onChange={setLinkedinUrl} placeholder="https://linkedin.com/in/…" />
|
||||||
|
<SocialInput icon={<Twitter size={13} />} value={xUrl} onChange={setXUrl} placeholder="https://x.com/…" />
|
||||||
|
<SocialInput icon={<Globe size={13} />} value={websiteUrl} onChange={setWebsiteUrl} placeholder="https://…" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-xs text-[#86868B] cursor-pointer">
|
||||||
|
<input type="checkbox" checked={autoTranslate} onChange={(e) => setAutoTranslate(e.target.checked)} className="accent-[#00F0FF]" />
|
||||||
|
<Sparkles size={12} className="text-[#00F0FF]" /> Auto-translate role & bio to IT, VEC, ES, DE with AI
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={save}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 bg-[#00F0FF] text-black text-sm font-medium rounded-lg hover:bg-[#00F0FF]/80 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
|
||||||
|
Save details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SocialInput({ icon, value, onChange, placeholder }: {
|
||||||
|
icon: React.ReactNode; value: string; onChange: (v: string) => void; placeholder: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 bg-black/40 border border-white/10 rounded-lg px-3 py-2 focus-within:border-[#00F0FF]/40">
|
||||||
|
<span className="text-[#86868B]">{icon}</span>
|
||||||
|
<input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="flex-1 bg-transparent border-0 outline-none text-white text-xs placeholder:text-[#86868B]/60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
{ path: "", priority: 1.0, changeFrequency: "weekly" as const },
|
{ path: "", priority: 1.0, changeFrequency: "weekly" as const },
|
||||||
{ path: "/news", priority: 0.7, changeFrequency: "daily" as const },
|
{ path: "/news", priority: 0.7, changeFrequency: "daily" as const },
|
||||||
{ path: "/heritage", priority: 0.6, changeFrequency: "monthly" as const },
|
{ path: "/heritage", priority: 0.6, changeFrequency: "monthly" as const },
|
||||||
|
{ path: "/team", priority: 0.6, changeFrequency: "monthly" as const },
|
||||||
|
{ path: "/privacy", priority: 0.3, changeFrequency: "yearly" as const },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const locale of LOCALES) {
|
for (const locale of LOCALES) {
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ export default function CaseStudyViewer({ data }: { data: CaseStudyData }) {
|
|||||||
|
|
||||||
if (!data.found) return null;
|
if (!data.found) return null;
|
||||||
|
|
||||||
|
// Defensive: ensure datasheet/gallery/videos are always arrays
|
||||||
|
const datasheet = Array.isArray(data.datasheet) ? data.datasheet : [];
|
||||||
|
const gallery = Array.isArray(data.gallery) ? data.gallery : [];
|
||||||
|
const videos = Array.isArray(data.videos) ? data.videos : [];
|
||||||
|
|
||||||
const accent = ACCENTS[data.industry] || ACCENTS.textile;
|
const accent = ACCENTS[data.industry] || ACCENTS.textile;
|
||||||
const nodeSlug = nodeToSlug(data.title);
|
const nodeSlug = nodeToSlug(data.title);
|
||||||
const coverSrc = data.mediaFileName ? `/cases/${nodeSlug}/${data.mediaFileName}` : null;
|
const coverSrc = data.mediaFileName ? `/cases/${nodeSlug}/${data.mediaFileName}` : null;
|
||||||
@@ -99,14 +104,14 @@ export default function CaseStudyViewer({ data }: { data: CaseStudyData }) {
|
|||||||
|
|
||||||
{/* Media indicators */}
|
{/* Media indicators */}
|
||||||
<div className="absolute bottom-3 right-3 flex items-center gap-1.5 z-10">
|
<div className="absolute bottom-3 right-3 flex items-center gap-1.5 z-10">
|
||||||
{data.gallery.length > 0 && (
|
{gallery.length > 0 && (
|
||||||
<div className="bg-black/40 backdrop-blur-md text-white text-[9px] px-2 py-0.5 rounded-full flex items-center gap-1">
|
<div className="bg-black/40 backdrop-blur-md text-white text-[9px] px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||||
<ImageIcon size={9} /> {data.gallery.length}
|
<ImageIcon size={9} /> {gallery.length}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{data.videos.length > 0 && (
|
{videos.length > 0 && (
|
||||||
<div className="bg-black/40 backdrop-blur-md text-white text-[9px] px-2 py-0.5 rounded-full flex items-center gap-1">
|
<div className="bg-black/40 backdrop-blur-md text-white text-[9px] px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||||
<Play size={9} /> {data.videos.length}
|
<Play size={9} /> {videos.length}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -133,8 +138,8 @@ export default function CaseStudyViewer({ data }: { data: CaseStudyData }) {
|
|||||||
)}
|
)}
|
||||||
<Metric icon={Clock} label="Performance" value={data.stats} />
|
<Metric icon={Clock} label="Performance" value={data.stats} />
|
||||||
<Metric icon={MapPin} label="Region" value={data.location.split(",").pop()?.trim() || data.location} />
|
<Metric icon={MapPin} label="Region" value={data.location.split(",").pop()?.trim() || data.location} />
|
||||||
{data.datasheet.length > 0 && (
|
{datasheet.length > 0 && (
|
||||||
<Metric icon={FileText} label="Specs" value={`${data.datasheet.length} parameters`} />
|
<Metric icon={FileText} label="Specs" value={`${datasheet.length} parameters`} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -166,21 +171,21 @@ export default function CaseStudyViewer({ data }: { data: CaseStudyData }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Equipment Datasheet (from specificDatasheetJson) */}
|
{/* Equipment Datasheet (from specificDatasheetJson) */}
|
||||||
{data.datasheet.length > 0 && (
|
{datasheet.length > 0 && (
|
||||||
<div className="bg-white/40 dark:bg-white/[0.03] border border-white/60 dark:border-white/[0.06] rounded-xl p-3 mb-3 transition-colors">
|
<div className="bg-white/40 dark:bg-white/[0.03] border border-white/60 dark:border-white/[0.06] rounded-xl p-3 mb-3 transition-colors">
|
||||||
<span className="text-[9px] text-[#86868B] dark:text-[#A1A1A6] uppercase tracking-wider font-semibold block mb-2">
|
<span className="text-[9px] text-[#86868B] dark:text-[#A1A1A6] uppercase tracking-wider font-semibold block mb-2">
|
||||||
Equipment Specifications
|
Equipment Specifications
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{data.datasheet.slice(0, 6).map((spec, i) => (
|
{datasheet.slice(0, 6).map((spec, i) => (
|
||||||
<div key={i} className="flex items-center justify-between py-1 border-b border-black/[0.03] dark:border-white/[0.04] last:border-0">
|
<div key={i} className="flex items-center justify-between py-1 border-b border-black/[0.03] dark:border-white/[0.04] last:border-0">
|
||||||
<span className="text-[10px] text-[#86868B] dark:text-[#A1A1A6]">{spec.label}</span>
|
<span className="text-[10px] text-[#86868B] dark:text-[#A1A1A6]">{spec.label}</span>
|
||||||
<span className="text-[10px] font-medium text-[#1D1D1F] dark:text-[#F5F5F7]">{spec.value}</span>
|
<span className="text-[10px] font-medium text-[#1D1D1F] dark:text-[#F5F5F7]">{spec.value}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{data.datasheet.length > 6 && (
|
{datasheet.length > 6 && (
|
||||||
<span className="text-[9px] text-[#86868B] dark:text-[#A1A1A6] text-center pt-1">
|
<span className="text-[9px] text-[#86868B] dark:text-[#A1A1A6] text-center pt-1">
|
||||||
+{data.datasheet.length - 6} more specs in full view
|
+{datasheet.length - 6} more specs in full view
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -188,17 +193,17 @@ export default function CaseStudyViewer({ data }: { data: CaseStudyData }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Gallery Preview */}
|
{/* Gallery Preview */}
|
||||||
{data.gallery.length > 0 && (
|
{gallery.length > 0 && (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowGallery(!showGallery)}
|
onClick={() => setShowGallery(!showGallery)}
|
||||||
className="text-[10px] text-[#0066CC] dark:text-[#4DA6FF] font-medium flex items-center gap-1 mb-2"
|
className="text-[10px] text-[#0066CC] dark:text-[#4DA6FF] font-medium flex items-center gap-1 mb-2"
|
||||||
>
|
>
|
||||||
<ImageIcon size={10} /> {showGallery ? 'Hide' : 'Show'} Gallery ({data.gallery.length} images)
|
<ImageIcon size={10} /> {showGallery ? 'Hide' : 'Show'} Gallery ({gallery.length} images)
|
||||||
</button>
|
</button>
|
||||||
{showGallery && (
|
{showGallery && (
|
||||||
<div className="grid grid-cols-3 gap-1.5 rounded-lg overflow-hidden">
|
<div className="grid grid-cols-3 gap-1.5 rounded-lg overflow-hidden">
|
||||||
{data.gallery.slice(0, 6).map((img, i) => (
|
{gallery.slice(0, 6).map((img, i) => (
|
||||||
<div key={i} className="relative aspect-square bg-[#F5F5F7] dark:bg-[#1D1D1F] rounded-md overflow-hidden">
|
<div key={i} className="relative aspect-square bg-[#F5F5F7] dark:bg-[#1D1D1F] rounded-md overflow-hidden">
|
||||||
<Image src={`/cases/${nodeSlug}/${img}`} alt={`Gallery ${i + 1}`} fill className="object-cover" sizes="120px" />
|
<Image src={`/cases/${nodeSlug}/${img}`} alt={`Gallery ${i + 1}`} fill className="object-cover" sizes="120px" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { Calendar, User, Building2, Mail, Phone, MessageSquare, ArrowRight, CheckCircle2, Sparkles, ChevronDown } from "lucide-react";
|
import { Calendar, User, Building2, Mail, Phone, MessageSquare, ArrowRight, CheckCircle2, Sparkles, ChevronDown } from "lucide-react";
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { trackEvent } from "@/lib/analytics/gtag";
|
||||||
|
|
||||||
// ── Data from the AI tool execute ──
|
// ── Data from the AI tool execute ──
|
||||||
interface ConsultationData {
|
interface ConsultationData {
|
||||||
@@ -166,7 +167,7 @@ function SuccessView({ data, ticketId }: { data: ConsultationData; ticketId: str
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[15px] font-medium text-[#1D1D1F] dark:text-[#F5F5F7] mb-1 transition-colors">
|
<p className="text-[15px] font-medium text-[#1D1D1F] dark:text-[#F5F5F7] mb-1 transition-colors">
|
||||||
Consultation Requested
|
Request Sent
|
||||||
</p>
|
</p>
|
||||||
{ticketId && (
|
{ticketId && (
|
||||||
<p className="text-[11px] font-mono text-[#0066CC] dark:text-[#4DA6FF] mb-2">{ticketId}</p>
|
<p className="text-[11px] font-mono text-[#0066CC] dark:text-[#4DA6FF] mb-2">{ticketId}</p>
|
||||||
@@ -187,7 +188,7 @@ function SuccessView({ data, ticketId }: { data: ConsultationData; ticketId: str
|
|||||||
</span>
|
</span>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{[
|
{[
|
||||||
"Engineer reviews your AI-prepared brief",
|
"Our team reviews your AI-prepared brief",
|
||||||
`Custom RF analysis for your ${data.process} process`,
|
`Custom RF analysis for your ${data.process} process`,
|
||||||
"Proposal with ROI projections and timeline",
|
"Proposal with ROI projections and timeline",
|
||||||
].map((step, i) => (
|
].map((step, i) => (
|
||||||
@@ -251,9 +252,18 @@ export default function ConsultationScheduler({ data }: { data: ConsultationData
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// ── Fetch a fresh CSRF token (sets the matching cookie too) ──
|
||||||
|
const csrfRes = await fetch("/api/csrf", { method: "GET", credentials: "same-origin" });
|
||||||
|
const { token: csrfToken } = (await csrfRes.json()) as { token?: string };
|
||||||
|
if (!csrfToken) throw new Error("Could not obtain CSRF token");
|
||||||
|
|
||||||
const res = await fetch("/api/consultation", {
|
const res = await fetch("/api/consultation", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
credentials: "same-origin",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-CSRF-Token": csrfToken,
|
||||||
|
},
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -263,6 +273,12 @@ export default function ConsultationScheduler({ data }: { data: ConsultationData
|
|||||||
setTicketId(result.ticketId);
|
setTicketId(result.ticketId);
|
||||||
setSubmitted(true);
|
setSubmitted(true);
|
||||||
|
|
||||||
|
// GA4 conversion event — the primary funnel goal.
|
||||||
|
trackEvent({
|
||||||
|
name: "ai_consultation_submitted",
|
||||||
|
params: { industry: data.industry, ticketId: result.ticketId },
|
||||||
|
});
|
||||||
|
|
||||||
// Also dispatch the event for any external integrations
|
// Also dispatch the event for any external integrations
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent("flux:consultation-submitted", { detail: { ...payload, ticketId: result.ticketId } })
|
new CustomEvent("flux:consultation-submitted", { detail: { ...payload, ticketId: result.ticketId } })
|
||||||
@@ -299,11 +315,11 @@ export default function ConsultationScheduler({ data }: { data: ConsultationData
|
|||||||
<Calendar size={13} className="text-[#0066CC] dark:text-[#4DA6FF]" />
|
<Calendar size={13} className="text-[#0066CC] dark:text-[#4DA6FF]" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[9px] font-semibold uppercase tracking-widest text-[#0066CC] dark:text-[#4DA6FF]">
|
<span className="text-[9px] font-semibold uppercase tracking-widest text-[#0066CC] dark:text-[#4DA6FF]">
|
||||||
Engineering Consultation
|
Get in Touch
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[12px] text-[#86868B] dark:text-[#A1A1A6] mb-4 leading-relaxed">
|
<p className="text-[12px] text-[#86868B] dark:text-[#A1A1A6] mb-4 leading-relaxed">
|
||||||
Your conversation details are pre-loaded. Just add your contact info.
|
Your conversation details are pre-loaded. Just add your contact info and our team will get back to you.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<InsightsCard data={data} />
|
<InsightsCard data={data} />
|
||||||
@@ -400,7 +416,7 @@ export default function ConsultationScheduler({ data }: { data: ConsultationData
|
|||||||
Sending...
|
Sending...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>Request Consultation <ArrowRight size={14} /></>
|
<>Send Request <ArrowRight size={14} /></>
|
||||||
)}
|
)}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
|
||||||
|
|||||||
@@ -49,9 +49,12 @@ export default function EquipmentConfigurator({ data }: { data: EquipmentData })
|
|||||||
const coverSrc = data.mediaFileName ? `/cases/${nodeSlug}/${data.mediaFileName}` : null;
|
const coverSrc = data.mediaFileName ? `/cases/${nodeSlug}/${data.mediaFileName}` : null;
|
||||||
const appLabel = data.application.replace(/-/g, " ").replace(/\b\w/g, c => c.toUpperCase());
|
const appLabel = data.application.replace(/-/g, " ").replace(/\b\w/g, c => c.toUpperCase());
|
||||||
|
|
||||||
|
// Defensive: ensure datasheet is always an array (DB may store malformed JSON)
|
||||||
|
const datasheet = Array.isArray(data.datasheet) ? data.datasheet : [];
|
||||||
|
|
||||||
// Find key specs for header pills (power, frequency, model — from datasheet)
|
// Find key specs for header pills (power, frequency, model — from datasheet)
|
||||||
const findSpec = (keywords: string[]) => {
|
const findSpec = (keywords: string[]) => {
|
||||||
return data.datasheet.find(s =>
|
return datasheet.find(s =>
|
||||||
keywords.some(kw => s.label.toLowerCase().includes(kw))
|
keywords.some(kw => s.label.toLowerCase().includes(kw))
|
||||||
)?.value;
|
)?.value;
|
||||||
};
|
};
|
||||||
@@ -61,8 +64,8 @@ export default function EquipmentConfigurator({ data }: { data: EquipmentData })
|
|||||||
const modelSpec = findSpec(['model', 'modelo', 'type', 'tipo', 'series']);
|
const modelSpec = findSpec(['model', 'modelo', 'type', 'tipo', 'series']);
|
||||||
|
|
||||||
// Split datasheet into primary (first 4) and extended
|
// Split datasheet into primary (first 4) and extended
|
||||||
const primarySpecs = data.datasheet.slice(0, 4);
|
const primarySpecs = datasheet.slice(0, 4);
|
||||||
const extendedSpecs = data.datasheet.slice(4);
|
const extendedSpecs = datasheet.slice(4);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -175,7 +178,7 @@ export default function EquipmentConfigurator({ data }: { data: EquipmentData })
|
|||||||
>
|
>
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<Settings2 size={12} />
|
<Settings2 size={12} />
|
||||||
All Specifications ({data.datasheet.length})
|
All Specifications ({datasheet.length})
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown size={14} className={`transition-transform duration-300 ${showAllSpecs ? "rotate-180" : ""}`} />
|
<ChevronDown size={14} className={`transition-transform duration-300 ${showAllSpecs ? "rotate-180" : ""}`} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Sparkles, ArrowRight, X, Minus, Database, Maximize2, Minimize2 } from "
|
|||||||
import { useChat } from "@ai-sdk/react";
|
import { useChat } from "@ai-sdk/react";
|
||||||
import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls } from "ai";
|
import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls } from "ai";
|
||||||
import { useUIStore } from "@/lib/store/uiStore";
|
import { useUIStore } from "@/lib/store/uiStore";
|
||||||
|
import { useRouter, usePathname } from "next/navigation";
|
||||||
import { useState, useEffect, useRef, useMemo } from "react";
|
import { useState, useEffect, useRef, useMemo } from "react";
|
||||||
|
|
||||||
// ── Renderers ──
|
// ── Renderers ──
|
||||||
@@ -17,6 +18,9 @@ import CaseStudyViewer from "./CaseStudyViewer";
|
|||||||
import EquipmentConfigurator from "./EquipmentConfigurator";
|
import EquipmentConfigurator from "./EquipmentConfigurator";
|
||||||
import EfficiencyCard from "./EfficiencyCard";
|
import EfficiencyCard from "./EfficiencyCard";
|
||||||
|
|
||||||
|
import { getAiSessionId } from "@/lib/aiSessionId";
|
||||||
|
import { trackEvent } from "@/lib/analytics/gtag";
|
||||||
|
|
||||||
export default function SilentObserver() {
|
export default function SilentObserver() {
|
||||||
const {
|
const {
|
||||||
isAiExpanded, toggleAi, setAiExpanded,
|
isAiExpanded, toggleAi, setAiExpanded,
|
||||||
@@ -24,6 +28,10 @@ export default function SilentObserver() {
|
|||||||
setHighlightedMapNode, setSelectedMarkerId,
|
setHighlightedMapNode, setSelectedMarkerId,
|
||||||
} = useUIStore();
|
} = useUIStore();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const locale = pathname?.split('/')[1] || 'en';
|
||||||
|
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [isDark, setIsDark] = useState(false);
|
const [isDark, setIsDark] = useState(false);
|
||||||
const [isWideMode, setIsWideMode] = useState(false);
|
const [isWideMode, setIsWideMode] = useState(false);
|
||||||
@@ -49,15 +57,20 @@ export default function SilentObserver() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ═══ AI SDK 6: Transport with dynamic body ═══
|
// ═══ AI SDK 6: Transport with dynamic body ═══
|
||||||
|
// sessionId is stable per visitor (localStorage UUID) so the chat route can
|
||||||
|
// stitch all messages into the same AiConversation row for analytics.
|
||||||
const transport = useMemo(() => new DefaultChatTransport({
|
const transport = useMemo(() => new DefaultChatTransport({
|
||||||
api: "/api/chat",
|
api: "/api/chat",
|
||||||
body: () => ({
|
body: () => ({
|
||||||
|
sessionId: getAiSessionId(),
|
||||||
|
locale,
|
||||||
|
pageUrl: typeof window !== "undefined" ? window.location.href : null,
|
||||||
context: {
|
context: {
|
||||||
section: sectionRef.current,
|
section: sectionRef.current,
|
||||||
activeTab: tabRef.current,
|
activeTab: tabRef.current,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}), []);
|
}), [locale]);
|
||||||
|
|
||||||
// ═══ AI SDK 6: useChat ═══
|
// ═══ AI SDK 6: useChat ═══
|
||||||
const { messages, sendMessage, addToolOutput, status } = useChat({
|
const { messages, sendMessage, addToolOutput, status } = useChat({
|
||||||
@@ -68,10 +81,40 @@ export default function SilentObserver() {
|
|||||||
if (toolCall.dynamic) return;
|
if (toolCall.dynamic) return;
|
||||||
|
|
||||||
if (toolCall.toolName === "navigate_to_section") {
|
if (toolCall.toolName === "navigate_to_section") {
|
||||||
const { section, subAction, tabId, nodeId } = toolCall.input as {
|
const { section, url, subAction, tabId, nodeId } = toolCall.input as {
|
||||||
section: string; subAction?: string; tabId?: string; nodeId?: string;
|
section?: string; url?: string; subAction?: string; tabId?: string; nodeId?: string;
|
||||||
};
|
};
|
||||||
handleClose();
|
handleClose();
|
||||||
|
|
||||||
|
// Valid homepage DOM IDs — anything else is a page route
|
||||||
|
const HOMEPAGE_IDS = new Set([
|
||||||
|
"technology", "applications-dashboard", "applications-deep",
|
||||||
|
"global", "our-story", "legacy",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Fallback map: if the AI sends a section name that's actually a page
|
||||||
|
const SECTION_TO_PAGE: Record<string, string> = {
|
||||||
|
news: "/news", heritage: "/heritage", parts: "/parts",
|
||||||
|
"parts-catalog": "/parts", contact: "/parts",
|
||||||
|
"inside-flux": "/news", "spare-parts": "/parts",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolve: explicit url > section-to-page fallback > homepage scroll
|
||||||
|
const resolvedUrl = url
|
||||||
|
|| (section && !HOMEPAGE_IDS.has(section) ? SECTION_TO_PAGE[section] || null : null);
|
||||||
|
|
||||||
|
if (resolvedUrl) {
|
||||||
|
// Cross-page navigation
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push(`/${locale}${resolvedUrl}`);
|
||||||
|
}, 400);
|
||||||
|
addToolOutput({
|
||||||
|
tool: "navigate_to_section" as any,
|
||||||
|
toolCallId: toolCall.toolCallId,
|
||||||
|
output: `Navigated to page "${resolvedUrl}"`,
|
||||||
|
});
|
||||||
|
} else if (section && HOMEPAGE_IDS.has(section)) {
|
||||||
|
// Same-page scroll — only for confirmed homepage DOM IDs
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const el = document.getElementById(section);
|
const el = document.getElementById(section);
|
||||||
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
@@ -87,6 +130,7 @@ export default function SilentObserver() {
|
|||||||
output: `Navigated to "${section}" section${tabId ? `, activated tab "${tabId}"` : ""}${nodeId ? `, highlighted node "${nodeId}"` : ""}`,
|
output: `Navigated to "${section}" section${tabId ? `, activated tab "${tabId}"` : ""}${nodeId ? `, highlighted node "${nodeId}"` : ""}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -168,6 +212,11 @@ export default function SilentObserver() {
|
|||||||
const DataToolWorking = ({ label }: { label: string }) => (<div key={key} className="self-start flex items-center gap-2 py-1"><Database size={10} className="text-[#0066CC]/40 dark:text-[#4DA6FF]/40 animate-pulse" /><span className="text-[10px] text-[#86868B]/60 dark:text-[#A1A1A6]/40 italic">{label}</span></div>);
|
const DataToolWorking = ({ label }: { label: string }) => (<div key={key} className="self-start flex items-center gap-2 py-1"><Database size={10} className="text-[#0066CC]/40 dark:text-[#4DA6FF]/40 animate-pulse" /><span className="text-[10px] text-[#86868B]/60 dark:text-[#A1A1A6]/40 italic">{label}</span></div>);
|
||||||
const ToolError = ({ msg }: { msg?: string }) => (<div key={key} className="text-[11px] text-red-400 dark:text-red-300/80 self-start py-1">Analysis unavailable. {msg}</div>);
|
const ToolError = ({ msg }: { msg?: string }) => (<div key={key} className="text-[11px] text-red-400 dark:text-red-300/80 self-start py-1">Analysis unavailable. {msg}</div>);
|
||||||
|
|
||||||
|
if (part.type === "tool-recommend_application") {
|
||||||
|
if (part.state === "input-streaming" || part.state === "input-available") return <DataToolWorking key={key} label="Analyzing your needs..." />;
|
||||||
|
if (part.state === "output-available") return null;
|
||||||
|
if (part.state === "output-error") return <ToolError key={key} msg={(part as any).errorText} />;
|
||||||
|
}
|
||||||
if (part.type === "tool-search_installations") {
|
if (part.type === "tool-search_installations") {
|
||||||
if (part.state === "input-streaming" || part.state === "input-available") return <DataToolWorking key={key} label="Searching installation database..." />;
|
if (part.state === "input-streaming" || part.state === "input-available") return <DataToolWorking key={key} label="Searching installation database..." />;
|
||||||
if (part.state === "output-available") return null;
|
if (part.state === "output-available") return null;
|
||||||
@@ -218,6 +267,41 @@ export default function SilentObserver() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══ Contextual Quick-Replies based on last assistant message ═══
|
||||||
|
function getContextualSuggestions(): string[] {
|
||||||
|
if (isLoading || messages.length === 0) return [];
|
||||||
|
const lastAssistant = [...messages].reverse().find(m => m.role === "assistant");
|
||||||
|
if (!lastAssistant?.parts) return [];
|
||||||
|
|
||||||
|
const toolTypes = new Set(
|
||||||
|
lastAssistant.parts
|
||||||
|
.filter((p: any) => p.type?.startsWith("tool-") && p.state === "output-available")
|
||||||
|
.map((p: any) => p.type)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Priority order: suggest the next logical funnel step
|
||||||
|
if (toolTypes.has("tool-schedule_consultation")) return []; // End of funnel
|
||||||
|
if (toolTypes.has("tool-show_equipment_specs"))
|
||||||
|
return ["Schedule a consultation", "Compare with traditional methods"];
|
||||||
|
if (toolTypes.has("tool-show_case_study"))
|
||||||
|
return ["Show me equipment specs", "Calculate savings for my operation", "Schedule a consultation"];
|
||||||
|
if (toolTypes.has("tool-energy_savings_calculator"))
|
||||||
|
return ["Show me a real installation", "See equipment specs", "How does RF heating work?"];
|
||||||
|
if (toolTypes.has("tool-process_comparison_table"))
|
||||||
|
return ["Calculate savings for my operation", "Show me proven installations"];
|
||||||
|
if (toolTypes.has("tool-rf_technology_explainer"))
|
||||||
|
return ["What would I save in energy costs?", "Show me real installations"];
|
||||||
|
if (toolTypes.has("tool-recommend_application") || toolTypes.has("tool-get_application_knowledge"))
|
||||||
|
return ["Calculate energy savings", "Show me case studies", "Compare RF vs my current method"];
|
||||||
|
if (toolTypes.has("tool-navigate_to_section"))
|
||||||
|
return ["Tell me more about this", "How much energy can I save?"];
|
||||||
|
|
||||||
|
// Default: if assistant responded with text only (Stage 1 qualification)
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions = getContextualSuggestions();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
@@ -230,7 +314,7 @@ export default function SilentObserver() {
|
|||||||
<div className={`fixed inset-0 z-50 flex pointer-events-none px-3 pb-3 md:p-6 transition-all duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] ${isWideMode ? "items-center justify-center" : "items-end justify-center md:justify-end"}`} style={{ paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))' }}>
|
<div className={`fixed inset-0 z-50 flex pointer-events-none px-3 pb-3 md:p-6 transition-all duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] ${isWideMode ? "items-center justify-center" : "items-end justify-center md:justify-end"}`} style={{ paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))' }}>
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{!isAiExpanded ? (
|
{!isAiExpanded ? (
|
||||||
<motion.button key="pill" layoutId="flux-ai-shell" onClick={toggleAi}
|
<motion.button key="pill" layoutId="flux-ai-shell" onClick={() => { trackEvent({ name: "ai_chat_opened", params: { section: currentSection } }); toggleAi(); }}
|
||||||
initial={{ opacity: 0, scale: 0.8, filter: "blur(10px)" }} animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }} exit={{ opacity: 0, scale: 0.9, filter: "blur(10px)" }}
|
initial={{ opacity: 0, scale: 0.8, filter: "blur(10px)" }} animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }} exit={{ opacity: 0, scale: 0.9, filter: "blur(10px)" }}
|
||||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||||
className="pointer-events-auto flex items-center gap-3 px-5 py-3.5 rounded-full cursor-pointer select-none bg-white/70 dark:bg-[#1D1D1F]/80 backdrop-blur-2xl backdrop-saturate-150 border border-white/60 dark:border-white/10 shadow-[0_8px_32px_rgba(0,0,0,0.08)] dark:shadow-[0_8px_32px_rgba(0,0,0,0.5)] hover:shadow-[0_12px_40px_rgba(0,0,0,0.12)] dark:hover:shadow-[0_12px_40px_rgba(0,0,0,0.6)] hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 group">
|
className="pointer-events-auto flex items-center gap-3 px-5 py-3.5 rounded-full cursor-pointer select-none bg-white/70 dark:bg-[#1D1D1F]/80 backdrop-blur-2xl backdrop-saturate-150 border border-white/60 dark:border-white/10 shadow-[0_8px_32px_rgba(0,0,0,0.08)] dark:shadow-[0_8px_32px_rgba(0,0,0,0.5)] hover:shadow-[0_12px_40px_rgba(0,0,0,0.12)] dark:hover:shadow-[0_12px_40px_rgba(0,0,0,0.6)] hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 group">
|
||||||
@@ -304,6 +388,24 @@ export default function SilentObserver() {
|
|||||||
{[0, 1, 2].map((i) => (<motion.div key={i} className="w-1.5 h-1.5 rounded-full bg-[#0066CC] dark:bg-[#4DA6FF]" animate={{ opacity: [0.2, 1, 0.2], scale: [0.8, 1, 0.8] }} transition={{ duration: 1, repeat: Infinity, delay: i * 0.2 }} />))}
|
{[0, 1, 2].map((i) => (<motion.div key={i} className="w-1.5 h-1.5 rounded-full bg-[#0066CC] dark:bg-[#4DA6FF]" animate={{ opacity: [0.2, 1, 0.2], scale: [0.8, 1, 0.8] }} transition={{ duration: 1, repeat: Infinity, delay: i * 0.2 }} />))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{suggestions.length > 0 && !isLoading && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.2 }}
|
||||||
|
className="flex flex-wrap gap-1.5 pt-1 pb-2"
|
||||||
|
>
|
||||||
|
{suggestions.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => { sendMessage({ text: s }); }}
|
||||||
|
className="px-3 py-1.5 rounded-full text-[11px] font-medium bg-[#0066CC]/[0.06] dark:bg-[#4DA6FF]/[0.08] border border-[#0066CC]/10 dark:border-[#4DA6FF]/10 text-[#0066CC] dark:text-[#4DA6FF] hover:bg-[#0066CC]/10 dark:hover:bg-[#4DA6FF]/15 hover:border-[#0066CC]/20 dark:hover:border-[#4DA6FF]/20 active:scale-95 transition-all duration-200"
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={onSubmit} className="relative z-10 px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] md:pb-3 border-t border-black/[0.04] dark:border-white/[0.06] bg-white/40 dark:bg-white/[0.02] flex items-center gap-2.5 transition-colors duration-300">
|
<form onSubmit={onSubmit} className="relative z-10 px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] md:pb-3 border-t border-black/[0.04] dark:border-white/[0.06] bg-white/40 dark:bg-white/[0.02] flex items-center gap-2.5 transition-colors duration-300">
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// src/components/analytics/ConsentBanner.tsx
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// GDPR / ePrivacy cookie consent banner. On-brand (FLUX cyan), minimal, and
|
||||||
|
// localized through next-intl. Shows only when:
|
||||||
|
// - analytics is configured (NEXT_PUBLIC_GA_ID present), AND
|
||||||
|
// - the visitor has not yet made a choice (no consent cookie).
|
||||||
|
//
|
||||||
|
// Accept -> consent granted, GA starts tracking, first page_view fires.
|
||||||
|
// Decline -> consent denied, GA stays cookieless.
|
||||||
|
// The choice is remembered for one year.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Link } from "@/i18n/routing";
|
||||||
|
import {
|
||||||
|
analyticsEnabled,
|
||||||
|
readStoredConsent,
|
||||||
|
storeConsent,
|
||||||
|
updateConsent,
|
||||||
|
pageview,
|
||||||
|
} from "@/lib/analytics/gtag";
|
||||||
|
|
||||||
|
export default function ConsentBanner() {
|
||||||
|
const t = useTranslations("Consent");
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!analyticsEnabled()) return;
|
||||||
|
if (readStoredConsent() === null) setVisible(true);
|
||||||
|
else if (readStoredConsent() === "granted") {
|
||||||
|
// Returning visitor who already consented — re-grant for this session.
|
||||||
|
updateConsent("granted");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
const choose = (granted: boolean) => {
|
||||||
|
const choice = granted ? "granted" : "denied";
|
||||||
|
storeConsent(choice);
|
||||||
|
updateConsent(choice);
|
||||||
|
if (granted && typeof window !== "undefined") {
|
||||||
|
pageview(window.location.pathname + window.location.search, document.title);
|
||||||
|
}
|
||||||
|
setVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label={t("title")}
|
||||||
|
className="fixed bottom-4 left-4 right-4 z-[300] mx-auto max-w-2xl rounded-2xl border border-black/10 bg-white/95 p-5 shadow-2xl backdrop-blur-xl md:left-6 md:right-auto md:bottom-6"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row md:items-center">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-[#1D1D1F]">{t("title")}</p>
|
||||||
|
<p className="mt-1 text-xs leading-relaxed text-[#6E6E73]">
|
||||||
|
{t("body")}{" "}
|
||||||
|
<Link
|
||||||
|
href="/privacy"
|
||||||
|
className="underline decoration-[#00B8CC] underline-offset-2 hover:text-[#1D1D1F]"
|
||||||
|
>
|
||||||
|
{t("learnMore")}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => choose(false)}
|
||||||
|
className="rounded-full border border-black/15 px-4 py-2 text-xs font-medium text-[#1D1D1F] transition-colors hover:bg-black/5"
|
||||||
|
>
|
||||||
|
{t("decline")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => choose(true)}
|
||||||
|
className="rounded-full bg-[#1D1D1F] px-5 py-2 text-xs font-medium text-white transition-colors hover:bg-[#000]"
|
||||||
|
>
|
||||||
|
{t("accept")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// src/components/analytics/GoogleAnalytics.tsx
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Loads gtag.js with Consent Mode v2. Renders NOTHING and loads NOTHING when
|
||||||
|
// NEXT_PUBLIC_GA_ID is unset, so the site is unaffected until the client
|
||||||
|
// provides their Measurement ID.
|
||||||
|
//
|
||||||
|
// Consent defaults to "denied" for all storage. The ConsentBanner flips
|
||||||
|
// analytics_storage to "granted" once the visitor accepts. This is the
|
||||||
|
// Google-recommended GDPR pattern: the tag loads but stores no cookies and
|
||||||
|
// no personal data until consent is given.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import Script from "next/script";
|
||||||
|
import { GA_MEASUREMENT_ID, analyticsEnabled } from "@/lib/analytics/gtag";
|
||||||
|
|
||||||
|
export default function GoogleAnalytics() {
|
||||||
|
if (!analyticsEnabled()) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 1. Consent Mode v2 defaults + gtag bootstrap. Must run BEFORE the
|
||||||
|
gtag.js library so the default consent state is set first. */}
|
||||||
|
<Script id="ga-consent-default" strategy="afterInteractive">
|
||||||
|
{`
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(){dataLayer.push(arguments);}
|
||||||
|
window.gtag = gtag;
|
||||||
|
gtag('consent', 'default', {
|
||||||
|
'analytics_storage': 'denied',
|
||||||
|
'ad_storage': 'denied',
|
||||||
|
'ad_user_data': 'denied',
|
||||||
|
'ad_personalization': 'denied',
|
||||||
|
'wait_for_update': 500
|
||||||
|
});
|
||||||
|
gtag('js', new Date());
|
||||||
|
gtag('config', '${GA_MEASUREMENT_ID}', {
|
||||||
|
'anonymize_ip': true,
|
||||||
|
'send_page_view': false
|
||||||
|
});
|
||||||
|
`}
|
||||||
|
</Script>
|
||||||
|
|
||||||
|
{/* 2. The actual GA4 library. */}
|
||||||
|
<Script
|
||||||
|
id="ga-lib"
|
||||||
|
strategy="afterInteractive"
|
||||||
|
src={`https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// src/components/analytics/PageViewTracker.tsx
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// GA4's gtag does not auto-track client-side route changes in the Next.js App
|
||||||
|
// Router (we set send_page_view:false in the config). This component fires a
|
||||||
|
// page_view on every pathname/search change so SPA navigation is measured.
|
||||||
|
//
|
||||||
|
// Safe no-op when analytics is disabled. Must live inside a Suspense boundary
|
||||||
|
// because it reads useSearchParams (a requirement under ISR).
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
|
import { pageview, analyticsEnabled } from "@/lib/analytics/gtag";
|
||||||
|
|
||||||
|
export default function PageViewTracker() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!analyticsEnabled()) return;
|
||||||
|
const qs = searchParams?.toString();
|
||||||
|
const url = qs ? `${pathname}?${qs}` : pathname;
|
||||||
|
pageview(url, typeof document !== "undefined" ? document.title : undefined);
|
||||||
|
}, [pathname, searchParams]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ export default async function Footer({ locale }: { locale: string }) {
|
|||||||
try {
|
try {
|
||||||
const rawApps = await prisma.application.findMany({
|
const rawApps = await prisma.application.findMany({
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
orderBy: { createdAt: "asc" },
|
orderBy: [{ order: "asc" }, { createdAt: "asc" }],
|
||||||
take: 4,
|
take: 4,
|
||||||
});
|
});
|
||||||
activeApps = rawApps.map((app: any) => getLocalizedData(app, locale));
|
activeApps = rawApps.map((app: any) => getLocalizedData(app, locale));
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const NAV_KEYS = [
|
|||||||
{ key: "globalMap", href: "/#global" },
|
{ key: "globalMap", href: "/#global" },
|
||||||
{ key: "ourStory", href: "/#our-story" },
|
{ key: "ourStory", href: "/#our-story" },
|
||||||
{ key: "insideFlux", href: "/news" },
|
{ key: "insideFlux", href: "/news" },
|
||||||
|
{ key: "team", href: "/team" },
|
||||||
{ key: "parts", href: "/parts" },
|
{ key: "parts", href: "/parts" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -62,20 +63,31 @@ export default function NavBar() {
|
|||||||
};
|
};
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
|
||||||
// Verificar si existe la cookie "flux_b2b_session"
|
// Cookie check is now event-driven (no setInterval polling).
|
||||||
|
// Triggers:
|
||||||
|
// - Initial mount
|
||||||
|
// - "flux:session-changed" CustomEvent dispatched by AuthModal on login/logout
|
||||||
|
// - visibilitychange (catches logout-in-another-tab)
|
||||||
|
// - storage events (multi-tab logout via shared cookie)
|
||||||
const checkSession = () => {
|
const checkSession = () => {
|
||||||
const cookies = document.cookie.split("; ");
|
const cookies = document.cookie.split("; ");
|
||||||
const sessionExists = cookies.some(c => c.startsWith("flux_b2b_session="));
|
const sessionExists = cookies.some((c) => c.startsWith("flux_b2b_session="));
|
||||||
setHasSession(sessionExists);
|
setHasSession(sessionExists);
|
||||||
};
|
};
|
||||||
checkSession();
|
checkSession();
|
||||||
// Re-chequear cuando el modal dispare un refresh
|
|
||||||
const interval = setInterval(checkSession, 2000);
|
const handleVisibility = () => {
|
||||||
|
if (document.visibilityState === "visible") checkSession();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("flux:session-changed", checkSession);
|
||||||
|
document.addEventListener("visibilitychange", handleVisibility);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("scroll", handleScroll);
|
window.removeEventListener("scroll", handleScroll);
|
||||||
document.removeEventListener("mousedown", handleClickOutside);
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
clearInterval(interval);
|
window.removeEventListener("flux:session-changed", checkSession);
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibility);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -6,18 +6,19 @@ import { ArrowRight, Zap, Scale, Cpu } from "lucide-react";
|
|||||||
import { Link } from "@/i18n/routing";
|
import { Link } from "@/i18n/routing";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { getIconForSlug } from "@/lib/applicationIcons";
|
import { getIconForSlug } from "@/lib/applicationIcons";
|
||||||
|
import type { AppCard, DashboardMetric } from "@/types/cms";
|
||||||
|
import { parseJsonField } from "@/types/cms";
|
||||||
|
|
||||||
export default function ApplicationsDashboard({ dbApps = [] }: { dbApps?: any[] }) {
|
export default function ApplicationsDashboard({ dbApps = [] }: { dbApps?: AppCard[] }) {
|
||||||
const activeApps = dbApps.filter(app => app.isActive);
|
const activeApps = dbApps.filter((app) => app.isActive);
|
||||||
if (!activeApps || activeApps.length === 0) return null;
|
if (!activeApps || activeApps.length === 0) return null;
|
||||||
|
|
||||||
const [activeSlug, setActiveSlug] = useState(activeApps[0]?.slug);
|
const [activeSlug, setActiveSlug] = useState<string | undefined>(activeApps[0]?.slug);
|
||||||
const activeApp = activeApps.find(app => app.slug === activeSlug) || activeApps[0];
|
const activeApp = activeApps.find((app) => app.slug === activeSlug) || activeApps[0];
|
||||||
|
|
||||||
const t = useTranslations("AppsDashboard"); // 🔥 LLAMAMOS AL DICCIONARIO
|
const t = useTranslations("AppsDashboard");
|
||||||
|
|
||||||
let metrics = [];
|
const metrics = parseJsonField<DashboardMetric[]>(activeApp?.dashboardMetricsJson, []);
|
||||||
try { metrics = JSON.parse(activeApp.dashboardMetricsJson || "[]"); } catch (e) {}
|
|
||||||
|
|
||||||
const triggerFluxAI = (prompt: string) => {
|
const triggerFluxAI = (prompt: string) => {
|
||||||
window.dispatchEvent(new CustomEvent("flux:trigger-ai", { detail: { prompt } }));
|
window.dispatchEvent(new CustomEvent("flux:trigger-ai", { detail: { prompt } }));
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { MapPin, Calendar, X, ArrowUpRight, Globe, Package, Building2, Layers }
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import CaseStudyModal, { CaseStudyData } from "@/components/ui/CaseStudyModal";
|
import CaseStudyModal, { CaseStudyData } from "@/components/ui/CaseStudyModal";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import type { AppCard, NodeMarker } from "@/types/cms";
|
||||||
|
|
||||||
const RADIUS = 2;
|
const RADIUS = 2;
|
||||||
const CAM_FOV = 50;
|
const CAM_FOV = 50;
|
||||||
@@ -536,7 +537,7 @@ function NodeCard({ node, isDark, onClose, onViewCase }: {
|
|||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
// MAIN COMPONENT
|
// MAIN COMPONENT
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
export default function GlobalOperations({ dbNodes = [], dbApps = [] }: { dbNodes?: any[]; dbApps?: any[] }) {
|
export default function GlobalOperations({ dbNodes = [], dbApps = [] }: { dbNodes?: NodeMarker[]; dbApps?: AppCard[] }) {
|
||||||
const [filter, setFilter] = useState("all");
|
const [filter, setFilter] = useState("all");
|
||||||
const [subFilter, setSubFilter] = useState<string | null>(null);
|
const [subFilter, setSubFilter] = useState<string | null>(null);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|||||||
@@ -1,310 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useRef, Suspense, useEffect } from "react";
|
|
||||||
import { Canvas, useFrame, useLoader, useThree } from "@react-three/fiber";
|
|
||||||
import { OrbitControls, QuadraticBezierLine } from "@react-three/drei";
|
|
||||||
import * as THREE from "three";
|
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
import { MapPin, Calendar, History, X, ArrowUpRight } from "lucide-react";
|
|
||||||
import CaseStudyModal, { CaseStudyData } from "@/components/ui/CaseStudyModal";
|
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
|
||||||
|
|
||||||
const RADIUS = 2;
|
|
||||||
|
|
||||||
function latLongToVector3(lat: number, lon: number, radius: number) {
|
|
||||||
const phi = (90 - lat) * (Math.PI / 180);
|
|
||||||
const theta = (lon + 180) * (Math.PI / 180);
|
|
||||||
const x = -(radius * Math.sin(phi) * Math.cos(theta));
|
|
||||||
const z = (radius * Math.sin(phi) * Math.sin(theta));
|
|
||||||
const y = (radius * Math.cos(phi));
|
|
||||||
return new THREE.Vector3(x, y, z);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── COMPONENTE OPTIMIZADO 1: TEXTURA DE LA TIERRA CON ALTA RESOLUCIÓN ──
|
|
||||||
function EarthMesh({ isDark }: { isDark: boolean }) {
|
|
||||||
const earthTexture = useLoader(THREE.TextureLoader, "https://unpkg.com/three-globe/example/img/earth-water.png");
|
|
||||||
const { gl } = useThree();
|
|
||||||
|
|
||||||
// 🔥 Filtro de hardware para forzar nitidez al hacer Zoom
|
|
||||||
useEffect(() => {
|
|
||||||
if (earthTexture) {
|
|
||||||
earthTexture.anisotropy = gl.capabilities.getMaxAnisotropy(); // Máximo detalle en ángulos inclinados
|
|
||||||
earthTexture.minFilter = THREE.LinearMipmapLinearFilter;
|
|
||||||
earthTexture.magFilter = THREE.LinearFilter;
|
|
||||||
earthTexture.colorSpace = THREE.SRGBColorSpace; // Colores más vibrantes y definidos
|
|
||||||
earthTexture.generateMipmaps = true;
|
|
||||||
earthTexture.needsUpdate = true;
|
|
||||||
}
|
|
||||||
}, [earthTexture, gl]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<mesh>
|
|
||||||
{/* Regresamos a 64 segmentos para optimizar rendimiento (la nitidez ahora viene de la textura) */}
|
|
||||||
<sphereGeometry args={[RADIUS * 0.99, 64, 64]} />
|
|
||||||
<meshBasicMaterial
|
|
||||||
map={earthTexture}
|
|
||||||
color={isDark ? "#06F5E1" : "#86868B"}
|
|
||||||
transparent
|
|
||||||
opacity={isDark ? 0.4 : 0.3}
|
|
||||||
blending={isDark ? THREE.AdditiveBlending : THREE.NormalBlending}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── COMPONENTE OPTIMIZADO 2: EL NODO INTELIGENTE QUE RESPONDE AL ZOOM ──
|
|
||||||
function MapNode({ marker, isSelected, hqPosition, onSelectMarker, isDark }: any) {
|
|
||||||
const meshRef = useRef<THREE.Group>(null);
|
|
||||||
const pos = latLongToVector3(marker.lat, marker.lon, RADIUS);
|
|
||||||
|
|
||||||
const isHQ = marker.nodeType === "hq";
|
|
||||||
const isEvent = marker.nodeType === "event";
|
|
||||||
const nodeColor = isHQ ? (isDark ? "#FFFFFF" : "#1D1D1F") : isEvent ? "#A855F7" : "#0066CC";
|
|
||||||
|
|
||||||
const baseSize = isHQ ? 0.04 : isEvent ? 0.035 : 0.025;
|
|
||||||
|
|
||||||
useFrame(({ camera }) => {
|
|
||||||
if (!meshRef.current) return;
|
|
||||||
const dist = camera.position.length();
|
|
||||||
const scaleFactor = Math.max(0.2, dist / 12);
|
|
||||||
const finalScale = isSelected ? scaleFactor * 1.8 : scaleFactor;
|
|
||||||
meshRef.current.scale.set(finalScale, finalScale, finalScale);
|
|
||||||
});
|
|
||||||
|
|
||||||
const distance = hqPosition.distanceTo(pos);
|
|
||||||
const arcElevation = RADIUS + (distance * 0.25) + 0.1;
|
|
||||||
const midPoint = hqPosition.clone().lerp(pos, 0.5).normalize().multiplyScalar(arcElevation);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<group>
|
|
||||||
<group ref={meshRef} position={pos}>
|
|
||||||
<mesh>
|
|
||||||
<sphereGeometry args={[baseSize, 32, 32]} />
|
|
||||||
<meshBasicMaterial color={nodeColor} />
|
|
||||||
</mesh>
|
|
||||||
|
|
||||||
{/* CAJA DE COLISIÓN AMPLIADA */}
|
|
||||||
<mesh
|
|
||||||
visible={false}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onSelectMarker(isSelected ? null : marker.id);
|
|
||||||
}}
|
|
||||||
onPointerOver={(e) => { e.stopPropagation(); document.body.style.cursor = 'pointer'; }}
|
|
||||||
onPointerOut={(e) => { e.stopPropagation(); document.body.style.cursor = 'auto'; }}
|
|
||||||
>
|
|
||||||
<sphereGeometry args={[baseSize * 4, 16, 16]} />
|
|
||||||
<meshBasicMaterial transparent opacity={0} />
|
|
||||||
</mesh>
|
|
||||||
</group>
|
|
||||||
|
|
||||||
{!isHQ && (
|
|
||||||
<QuadraticBezierLine
|
|
||||||
start={hqPosition}
|
|
||||||
end={pos}
|
|
||||||
mid={midPoint}
|
|
||||||
color={nodeColor}
|
|
||||||
lineWidth={isSelected ? 2.5 : 1.5}
|
|
||||||
transparent
|
|
||||||
opacity={isSelected ? 0.9 : 0.25}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── COMPONENTE DE ENSAMBLAJE DE LA ESFERA ──
|
|
||||||
function HologramSphere({ activeFilter, activeSubFilter, selectedMarker, onSelectMarker, isDark, dbNodes, hqPosition }: any) {
|
|
||||||
const globeRef = useRef<THREE.Group>(null);
|
|
||||||
|
|
||||||
// 🔥 MAGIA DE ROTACIÓN INTELIGENTE 🔥
|
|
||||||
useFrame(({ camera }) => {
|
|
||||||
// La cámara inicia en Z=7. Si el usuario hace zoom in (distancia < 6.5), detenemos la rotación.
|
|
||||||
// Si vuelve a alejar el mapa a su estado normal (distancia > 6.5), retoma la rotación.
|
|
||||||
const distance = camera.position.length();
|
|
||||||
|
|
||||||
if (globeRef.current && !selectedMarker && distance > 6.5) {
|
|
||||||
globeRef.current.rotation.y += 0.0005;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<group ref={globeRef}>
|
|
||||||
<mesh><sphereGeometry args={[RADIUS * 1.04, 64, 64]} /><meshBasicMaterial color={isDark ? "#00F0FF" : "#0066CC"} transparent opacity={isDark ? 0.1 : 0.05} side={THREE.BackSide} blending={THREE.AdditiveBlending} depthWrite={false} /></mesh>
|
|
||||||
<mesh><sphereGeometry args={[RADIUS * 0.98, 64, 64]} /><meshBasicMaterial color={isDark ? "#050505" : "#E5E5EA"} /></mesh>
|
|
||||||
|
|
||||||
{/* Esfera Terrestre mejorada con texturas nítidas */}
|
|
||||||
<EarthMesh isDark={isDark} />
|
|
||||||
|
|
||||||
<mesh><sphereGeometry args={[RADIUS, 64, 64]} /><meshBasicMaterial color={isDark ? "#0066CC" : "#86868B"} wireframe transparent opacity={0.06} /></mesh>
|
|
||||||
|
|
||||||
{dbNodes.map((marker: any) => {
|
|
||||||
const isHQ = marker.nodeType === "hq";
|
|
||||||
const isEvent = marker.nodeType === "event";
|
|
||||||
|
|
||||||
const matchesMain = activeFilter === "all" || (activeFilter === "installation" && !isEvent && !isHQ) || (activeFilter === "event" && isEvent) || (activeFilter === "legacy" && isHQ);
|
|
||||||
const matchesSub = !activeSubFilter || marker.application === activeSubFilter || isHQ;
|
|
||||||
const isVisible = matchesMain && matchesSub;
|
|
||||||
|
|
||||||
if (!isVisible) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MapNode
|
|
||||||
key={marker.id}
|
|
||||||
marker={marker}
|
|
||||||
isSelected={selectedMarker === marker.id}
|
|
||||||
hqPosition={hqPosition}
|
|
||||||
onSelectMarker={onSelectMarker}
|
|
||||||
isDark={isDark}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── INTERFAZ GRÁFICA PRINCIPAL ──
|
|
||||||
export default function GlobalOperations({ dbNodes = [], dbApps = [] }: { dbNodes?: any[], dbApps?: any[] }) {
|
|
||||||
const [activeFilter, setActiveFilter] = useState("all");
|
|
||||||
const [activeSubFilter, setActiveSubFilter] = useState<string | null>(null);
|
|
||||||
const [selectedMarkerId, setSelectedMarkerId] = useState<string | null>(null);
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
||||||
const [isDark, setIsDark] = useState(false);
|
|
||||||
|
|
||||||
const t = useTranslations("GlobalOperations");
|
|
||||||
|
|
||||||
const dynamicSubFilters = dbApps
|
|
||||||
.filter(app => app.isActive)
|
|
||||||
.map(app => ({ id: app.slug, label: app.title }));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const checkTheme = () => setIsDark(document.documentElement.classList.contains("dark"));
|
|
||||||
checkTheme();
|
|
||||||
const observer = new MutationObserver(checkTheme);
|
|
||||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const filters = [
|
|
||||||
{ id: "all", label: t("filterAll"), icon: MapPin },
|
|
||||||
{ id: "installation", label: t("filterInstallations"), icon: MapPin },
|
|
||||||
{ id: "event", label: t("filterEvents"), icon: Calendar },
|
|
||||||
{ id: "legacy", label: t("filterHQ"), icon: History }
|
|
||||||
];
|
|
||||||
|
|
||||||
const selectedData = dbNodes.find(d => d.id === selectedMarkerId);
|
|
||||||
|
|
||||||
const hqNode = dbNodes.find(d => d.application === "hq");
|
|
||||||
const hqLat = hqNode ? hqNode.lat : 45.78;
|
|
||||||
const hqLon = hqNode ? hqNode.lon : 11.76;
|
|
||||||
const hqPosition = latLongToVector3(hqLat, hqLon, RADIUS);
|
|
||||||
|
|
||||||
const handleMainFilter = (id: string) => {
|
|
||||||
setActiveFilter(id);
|
|
||||||
setActiveSubFilter(null);
|
|
||||||
setSelectedMarkerId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<section id="global" className="relative w-full max-w-7xl mx-auto px-6 py-32 z-10">
|
|
||||||
<div className="flex flex-col lg:flex-row gap-12 items-center h-auto lg:h-[700px]">
|
|
||||||
|
|
||||||
<div className="w-full lg:w-1/3 flex flex-col h-full justify-center mb-12 lg:mb-0">
|
|
||||||
<div className="p-6 md:p-8 -ml-6 md:-ml-8 rounded-3xl bg-white/40 dark:bg-[#0A0A0C]/50 backdrop-blur-md border border-white/50 dark:border-white/5 shadow-sm transition-colors">
|
|
||||||
<h2 className="text-sm font-semibold tracking-widest text-[#0066CC] dark:text-[#4DA6FF] uppercase mb-4">{t("subtitle")}</h2>
|
|
||||||
<h3 className="text-4xl md:text-5xl font-light text-[#1D1D1F] dark:text-[#F5F5F7] tracking-tight mb-8 leading-[1.1] transition-colors">
|
|
||||||
{t("title1")} <br /><span className="font-medium">{t("title2")}</span>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 mb-4">
|
|
||||||
{filters.map((f) => (
|
|
||||||
<button key={f.id} onClick={() => handleMainFilter(f.id)} className={`px-4 py-2 rounded-full text-sm font-medium transition-all duration-300 border ${ activeFilter === f.id ? "bg-[#1D1D1F] dark:bg-white text-white dark:text-[#1D1D1F] border-[#1D1D1F] dark:border-white shadow-md" : "bg-white/60 dark:bg-white/5 text-[#86868B] dark:text-[#A1A1A6] border-white/60 dark:border-white/10 hover:text-[#1D1D1F] dark:hover:text-white" } backdrop-blur-md`}>
|
|
||||||
{f.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{activeFilter === "installation" && (
|
|
||||||
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }} className="flex flex-wrap gap-2 mb-4 overflow-hidden">
|
|
||||||
<span className="w-full text-xs font-semibold uppercase tracking-widest text-[#86868B] mb-1">{t("filterByApp")}</span>
|
|
||||||
{dynamicSubFilters.map((sub) => (
|
|
||||||
<button key={sub.id} onClick={() => { setActiveSubFilter(activeSubFilter === sub.id ? null : sub.id); setSelectedMarkerId(null); }} className={`px-3 py-1.5 rounded-full text-xs transition-all duration-200 border ${ activeSubFilter === sub.id ? "bg-[#0066CC]/10 dark:bg-[#0066CC]/20 text-[#0066CC] dark:text-[#4DA6FF] border-[#0066CC]/30 font-medium" : "bg-white/40 dark:bg-transparent text-[#86868B] border-transparent hover:text-[#1D1D1F] dark:hover:text-white" }`}>
|
|
||||||
{sub.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
{!selectedMarkerId && (
|
|
||||||
<motion.div key={activeFilter + (activeSubFilter || "")} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} className="bg-white/60 dark:bg-[#1D1D1F]/60 backdrop-blur-2xl border border-white/40 dark:border-white/10 shadow-[0_8px_32px_rgba(0,0,0,0.04)] dark:shadow-[0_8px_32px_rgba(0,0,0,0.4)] rounded-3xl p-8 mt-4 transition-colors">
|
|
||||||
<h4 className="text-[#86868B] text-xs font-semibold uppercase tracking-wider mb-2 flex items-center gap-2"><span className="w-2 h-2 rounded-full bg-[#0066CC] animate-pulse"></span>{t("networkStatus")}</h4>
|
|
||||||
<p className="text-xl font-light text-[#1D1D1F] dark:text-[#F5F5F7] mb-6 leading-relaxed transition-colors">
|
|
||||||
{activeSubFilter
|
|
||||||
? t("statusShowing", { app: activeSubFilter.replace("-", " ") })
|
|
||||||
: t("statusTracking", { count: dbNodes.filter(n =>
|
|
||||||
(activeFilter === "all") ||
|
|
||||||
(activeFilter === "installation" && n.nodeType === "installation") ||
|
|
||||||
(activeFilter === "event" && n.nodeType === "event") ||
|
|
||||||
(activeFilter === "legacy" && n.nodeType === "hq")
|
|
||||||
).length })}
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full lg:w-2/3 h-[500px] lg:h-full relative rounded-[2.5rem] overflow-hidden bg-white/30 dark:bg-transparent backdrop-blur-sm dark:backdrop-blur-none border border-white/40 dark:border-transparent transition-all duration-700">
|
|
||||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[150%] h-[150%] bg-[radial-gradient(circle_at_center,rgba(0,102,204,0.15)_0%,transparent_60%)] hidden dark:block pointer-events-none blur-3xl" />
|
|
||||||
|
|
||||||
<div className="absolute top-4 right-4 z-10 text-[10px] font-mono text-[#86868B] dark:text-[#A1A1A6] tracking-widest uppercase bg-white/50 dark:bg-white/5 px-3 py-1 rounded-full backdrop-blur-md border border-white/50 dark:border-white/10 transition-colors pointer-events-none">
|
|
||||||
{t("helpText")}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{selectedData && (
|
|
||||||
<motion.div initial={{ opacity: 0, x: 20, y: 20 }} animate={{ opacity: 1, x: 0, y: 0 }} exit={{ opacity: 0, x: 20, y: 20 }} className="absolute bottom-4 right-4 z-20 w-[calc(100%-2rem)] md:w-80 bg-white/80 dark:bg-[#1D1D1F]/80 backdrop-blur-2xl border border-white/60 dark:border-white/10 shadow-lg dark:shadow-[0_10px_40px_rgba(0,0,0,0.5)] rounded-2xl p-6 transition-colors">
|
|
||||||
<div className="flex justify-between items-start mb-4">
|
|
||||||
<div className="flex items-center gap-2 text-[#0066CC] dark:text-[#4DA6FF]">
|
|
||||||
<MapPin size={14} />
|
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider">
|
|
||||||
{selectedData.nodeType === 'event' ? t("typeEvent") : selectedData.nodeType === 'hq' ? t("typeHQ") : t("typeInstall")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => setSelectedMarkerId(null)} className="text-[#86868B] hover:text-[#1D1D1F] dark:hover:text-white transition-colors">
|
|
||||||
<X size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<h4 className="text-xl font-medium text-[#1D1D1F] dark:text-[#F5F5F7] mb-1">{selectedData.title}</h4>
|
|
||||||
<p className="text-sm text-[#86868B] dark:text-[#A1A1A6] mb-4">{selectedData.location}</p>
|
|
||||||
<div className="bg-[#F5F5F7] dark:bg-white/5 rounded-lg p-3 mb-4 border border-transparent dark:border-white/5">
|
|
||||||
<span className="text-[10px] uppercase tracking-wider text-[#86868B] dark:text-[#A1A1A6] block mb-1">{t("statusDetails")}</span>
|
|
||||||
<span className="text-sm font-medium text-[#1D1D1F] dark:text-[#F5F5F7]">{selectedData.stats}</span>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => setIsModalOpen(true)} className="w-full flex justify-center items-center gap-2 bg-[#1D1D1F] dark:bg-[#0066CC] text-white py-2.5 rounded-xl text-sm font-medium hover:bg-[#0066CC] dark:hover:bg-[#4DA6FF] transition-colors">
|
|
||||||
{t("viewCaseStudy")} <ArrowUpRight size={14} />
|
|
||||||
</button>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<Canvas camera={{ position: [0, 0, 7], fov: 55 }} dpr={[1, 2]} style={{ touchAction: "none" }}>
|
|
||||||
<ambientLight intensity={1.5} />
|
|
||||||
<directionalLight position={[10, 10, 5]} intensity={2} />
|
|
||||||
<OrbitControls enableZoom={true} minDistance={3} maxDistance={12} enablePan={false} autoRotate={false} />
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<HologramSphere activeFilter={activeFilter} activeSubFilter={activeSubFilter} selectedMarker={selectedMarkerId} onSelectMarker={setSelectedMarkerId} isDark={isDark} dbNodes={dbNodes} hqPosition={hqPosition} />
|
|
||||||
</Suspense>
|
|
||||||
</Canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<CaseStudyModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} data={selectedData as CaseStudyData || null} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// src/components/seo/Breadcrumbs.tsx
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Visible breadcrumb navigation trail — complements the JSON-LD BreadcrumbList
|
||||||
|
// already rendered by individual pages.
|
||||||
|
//
|
||||||
|
// Design: Apple-clean, muted, small text — blends with the hero overlays
|
||||||
|
// on article and application detail pages.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
|
export interface BreadcrumbItem {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Breadcrumbs({ items }: { items: BreadcrumbItem[] }) {
|
||||||
|
if (items.length < 2) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav aria-label="Breadcrumb" className="mb-4">
|
||||||
|
<ol className="flex items-center gap-1 flex-wrap text-xs">
|
||||||
|
{items.map((item, idx) => {
|
||||||
|
const isLast = idx === items.length - 1;
|
||||||
|
return (
|
||||||
|
<li key={item.url} className="flex items-center gap-1">
|
||||||
|
{idx > 0 && (
|
||||||
|
<ChevronRight size={11} className="text-[#86868B]/40 shrink-0" />
|
||||||
|
)}
|
||||||
|
{isLast ? (
|
||||||
|
<span
|
||||||
|
aria-current="page"
|
||||||
|
className="text-[#1D1D1F] dark:text-white/90 font-medium truncate max-w-[220px]"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href={item.url}
|
||||||
|
className="text-[#86868B] hover:text-[#0066CC] dark:hover:text-[#4DA6FF] transition-colors"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,19 +2,22 @@
|
|||||||
|
|
||||||
import { ArrowUpRight } from "lucide-react";
|
import { ArrowUpRight } from "lucide-react";
|
||||||
|
|
||||||
// 1. El botón principal (Contact Engineering)
|
// 1. El botón principal (Contact FLUX Team)
|
||||||
|
// Broadened from "Contact FLUX Engineering" so it reads as an invitation for
|
||||||
|
// ANY enquiry — pricing, general questions, a demo, partnerships — not only
|
||||||
|
// technical/engineering consultations.
|
||||||
export function AiContactButton() {
|
export function AiContactButton() {
|
||||||
const handleContactEngineering = () => {
|
const handleContact = () => {
|
||||||
const prompt = "I am ready to optimize my production. I would like to schedule a technical consultation with FLUX Engineering to explore custom RF solutions and calculate my ROI.";
|
const prompt = "I'd like to get in touch with the FLUX team. I have a few questions and would like to explore how FLUX can help — whether that's energy savings, a custom solution, pricing, availability, or just learning more.";
|
||||||
window.dispatchEvent(new CustomEvent("flux:trigger-ai", { detail: { prompt } }));
|
window.dispatchEvent(new CustomEvent("flux:trigger-ai", { detail: { prompt } }));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={handleContactEngineering}
|
onClick={handleContact}
|
||||||
className="flex items-center gap-3 bg-white dark:bg-[#00F0FF] text-[#1D1D1F] px-8 py-4 rounded-full font-semibold hover:scale-105 hover:shadow-[0_0_30px_rgba(0,102,204,0.4)] dark:hover:shadow-[0_0_30px_rgba(0,240,255,0.4)] transition-all duration-300"
|
className="flex items-center gap-3 bg-white dark:bg-[#00F0FF] text-[#1D1D1F] px-8 py-4 rounded-full font-semibold hover:scale-105 hover:shadow-[0_0_30px_rgba(0,102,204,0.4)] dark:hover:shadow-[0_0_30px_rgba(0,240,255,0.4)] transition-all duration-300"
|
||||||
>
|
>
|
||||||
Contact FLUX Engineering <ArrowUpRight size={18} />
|
Contact FLUX Team <ArrowUpRight size={18} />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { X, MapPin, Calendar, Leaf, CheckCircle2, Factory, Presentation, Image as ImageIcon } from "lucide-react";
|
import { X, MapPin, Calendar, Leaf, CheckCircle2, Factory, Presentation, Image as ImageIcon, ArrowRight } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Link } from "@/i18n/routing";
|
||||||
|
import { trackEvent } from "@/lib/analytics/gtag";
|
||||||
|
|
||||||
export interface CaseStudyData {
|
export interface CaseStudyData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -305,6 +307,28 @@ export default function CaseStudyModal({ isOpen, onClose, data }: ModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bridge to the full case study inside its application page.
|
||||||
|
Only for real installations whose `application` maps to an
|
||||||
|
Application slug (not events or the HQ node). */}
|
||||||
|
{!isEvent && !isHQ && data.application && data.application !== "hq" && data.application !== "event" && (
|
||||||
|
<Link
|
||||||
|
href={`/applications/${data.application}#case-${data.id}`}
|
||||||
|
onClick={() => {
|
||||||
|
trackEvent({ name: "case_study_viewed", params: { nodeId: data.id, application: data.application } });
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="group mb-10 flex items-center justify-between gap-4 w-full bg-[#0066CC] dark:bg-[#00F0FF] text-white dark:text-black px-6 py-4 rounded-2xl font-medium hover:bg-[#0052a3] dark:hover:bg-[#00F0FF]/80 transition-colors shadow-lg"
|
||||||
|
>
|
||||||
|
<span className="flex flex-col text-left">
|
||||||
|
<span className="text-[10px] uppercase tracking-widest opacity-70">
|
||||||
|
{data.application.replace(/-/g, " ")}
|
||||||
|
</span>
|
||||||
|
<span className="text-base">{t("viewFullCase")}</span>
|
||||||
|
</span>
|
||||||
|
<ArrowRight size={20} className="group-hover:translate-x-1 transition-transform shrink-0" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
{data.projectOverview ? (
|
{data.projectOverview ? (
|
||||||
<div className="max-w-none mb-12">
|
<div className="max-w-none mb-12">
|
||||||
<h3 className="text-2xl font-light mb-6 text-[#1D1D1F] dark:text-white">
|
<h3 className="text-2xl font-light mb-6 text-[#1D1D1F] dark:text-white">
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// src/lib/aiSessionId.ts
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Per-visitor pseudonymous session id used to stitch together FluxAI
|
||||||
|
// conversations across messages. The id is generated on first chat and
|
||||||
|
// persisted in localStorage; if storage is unavailable (Safari ITP / privacy
|
||||||
|
// mode) we fall back to a per-tab id in sessionStorage; if both fail we use
|
||||||
|
// an ephemeral in-memory id (no tracking across reload).
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const STORAGE_KEY = "flux:ai:session";
|
||||||
|
|
||||||
|
function randomUUID(): string {
|
||||||
|
// crypto.randomUUID is available in all modern browsers + Node 14.17+.
|
||||||
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
// Tiny fallback (RFC 4122 v4-ish — sufficient for telemetry, not for security).
|
||||||
|
const rnd = (n: number) =>
|
||||||
|
Array.from({ length: n }, () => Math.floor(Math.random() * 16).toString(16)).join("");
|
||||||
|
return `${rnd(8)}-${rnd(4)}-4${rnd(3)}-${rnd(4)}-${rnd(12)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let memoryId: string | null = null;
|
||||||
|
|
||||||
|
export function getAiSessionId(): string {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
// SSR — caller must pass the id from the client.
|
||||||
|
if (!memoryId) memoryId = randomUUID();
|
||||||
|
return memoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// localStorage first
|
||||||
|
try {
|
||||||
|
const existing = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (existing) return existing;
|
||||||
|
const fresh = randomUUID();
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, fresh);
|
||||||
|
return fresh;
|
||||||
|
} catch {
|
||||||
|
// ignore — privacy mode
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionStorage next
|
||||||
|
try {
|
||||||
|
const existing = window.sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
if (existing) return existing;
|
||||||
|
const fresh = randomUUID();
|
||||||
|
window.sessionStorage.setItem(STORAGE_KEY, fresh);
|
||||||
|
return fresh;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory (tab-scoped) last resort
|
||||||
|
if (!memoryId) memoryId = randomUUID();
|
||||||
|
return memoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetAiSessionId(): void {
|
||||||
|
memoryId = null;
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try { window.localStorage.removeItem(STORAGE_KEY); } catch {}
|
||||||
|
try { window.sessionStorage.removeItem(STORAGE_KEY); } catch {}
|
||||||
|
}
|
||||||
+36
-5
@@ -1,17 +1,34 @@
|
|||||||
import { generateText } from 'ai';
|
import { generateText } from 'ai';
|
||||||
import { openai } from '@ai-sdk/openai';
|
import { openai } from '@ai-sdk/openai';
|
||||||
|
import {
|
||||||
|
maskProtectedTerms,
|
||||||
|
unmaskProtectedTerms,
|
||||||
|
glossaryForPrompt,
|
||||||
|
} from '@/lib/translationGlossary';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Motor de traducción impulsado por Vercel AI SDK y OpenAI.
|
* Motor de traducción impulsado por Vercel AI SDK y OpenAI.
|
||||||
* Usa generateText para evitar bugs de compatibilidad con Zod.
|
*
|
||||||
|
* English is the master language. Protected technical/brand terms (e.g.
|
||||||
|
* "Radio Frequency", "solid-state", "FLUX") are MASKED with placeholders
|
||||||
|
* before translation and RESTORED to their English form afterwards, so they
|
||||||
|
* are preserved deterministically across every locale — not left to the
|
||||||
|
* model's discretion. See src/lib/translationGlossary.ts.
|
||||||
|
*
|
||||||
* @param content Objeto con los textos a traducir. Ej: { title: "...", content: "..." }
|
* @param content Objeto con los textos a traducir. Ej: { title: "...", content: "..." }
|
||||||
* @returns Objeto con los idiomas y sus traducciones
|
* @returns Objeto con los idiomas y sus traducciones, o null on failure.
|
||||||
*/
|
*/
|
||||||
export async function translateContentForCMS(content: Record<string, string>) {
|
export async function translateContentForCMS(content: Record<string, string>) {
|
||||||
try {
|
try {
|
||||||
|
// 1. Mask protected terms in every field before sending to the model.
|
||||||
|
const maskedContent: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(content)) {
|
||||||
|
maskedContent[key] = maskProtectedTerms(value ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
const { text } = await generateText({
|
const { text } = await generateText({
|
||||||
model: openai('gpt-4o'),
|
model: openai('gpt-4o'),
|
||||||
system: `You are an elite technical translator for FLUX, a premium brand of Radio Frequency (RF) industrial machinery.
|
system: `You are an elite technical translator for FLUX, a premium brand of solid-state Radio Frequency (RF) industrial machinery.
|
||||||
|
|
||||||
Your task is to translate the user's JSON content into 4 specific locales:
|
Your task is to translate the user's JSON content into 4 specific locales:
|
||||||
1. 'it': Standard Professional Italian.
|
1. 'it': Standard Professional Italian.
|
||||||
@@ -22,7 +39,9 @@ export async function translateContentForCMS(content: Record<string, string>) {
|
|||||||
CRITICAL RULES:
|
CRITICAL RULES:
|
||||||
- NEVER translate Markdown syntax (#, **, *, >, |---|).
|
- NEVER translate Markdown syntax (#, **, *, >, |---|).
|
||||||
- NEVER translate URLs, file paths (like /cases/img.jpg), or code blocks.
|
- NEVER translate URLs, file paths (like /cases/img.jpg), or code blocks.
|
||||||
- NEVER translate technical acronyms like "RF", "kW", "MHz", "FLUX".
|
- NEVER translate technical acronyms or units like "RF", "kW", "MHz", "FLUX".
|
||||||
|
- English is the master language. Keep this protected glossary in ENGLISH, untranslated, in every locale: ${glossaryForPrompt()}.
|
||||||
|
- CRITICAL: The text contains placeholder tokens of the form __FLUXTERM_0__, __FLUXTERM_1__, etc. These stand in for protected English terms. Keep every such token EXACTLY as-is, byte for byte. Do not translate, space, reorder the underscores, or alter them in any way. Position them naturally in the translated sentence.
|
||||||
- Keep the exact same JSON key names as the input.
|
- Keep the exact same JSON key names as the input.
|
||||||
|
|
||||||
OUTPUT FORMAT:
|
OUTPUT FORMAT:
|
||||||
@@ -35,7 +54,7 @@ export async function translateContentForCMS(content: Record<string, string>) {
|
|||||||
"de": { "key1": "translated text..." }
|
"de": { "key1": "translated text..." }
|
||||||
}`,
|
}`,
|
||||||
|
|
||||||
prompt: JSON.stringify(content),
|
prompt: JSON.stringify(maskedContent),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Limpiamos el texto por si GPT-4o decide ponerle "```json" alrededor
|
// Limpiamos el texto por si GPT-4o decide ponerle "```json" alrededor
|
||||||
@@ -44,6 +63,18 @@ export async function translateContentForCMS(content: Record<string, string>) {
|
|||||||
// Convertimos la respuesta de la IA en un objeto real de Javascript
|
// Convertimos la respuesta de la IA en un objeto real de Javascript
|
||||||
const parsedObject = JSON.parse(cleanedText);
|
const parsedObject = JSON.parse(cleanedText);
|
||||||
|
|
||||||
|
// 2. Restore protected terms in every translated field of every locale.
|
||||||
|
for (const locale of Object.keys(parsedObject)) {
|
||||||
|
const fields = parsedObject[locale];
|
||||||
|
if (fields && typeof fields === 'object') {
|
||||||
|
for (const key of Object.keys(fields)) {
|
||||||
|
if (typeof fields[key] === 'string') {
|
||||||
|
fields[key] = unmaskProtectedTerms(fields[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return parsedObject;
|
return parsedObject;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// src/lib/analytics/gtag.ts
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Google Analytics 4 integration — typed event helpers + GDPR consent control.
|
||||||
|
//
|
||||||
|
// Design:
|
||||||
|
// - The Measurement ID comes from NEXT_PUBLIC_GA_ID. When it is unset (e.g.
|
||||||
|
// local dev, or before the client provides it), every function here is a
|
||||||
|
// safe no-op — nothing loads, nothing tracks, no errors.
|
||||||
|
// - Consent Mode v2 is used. Analytics storage defaults to "denied"; the
|
||||||
|
// consent banner flips it to "granted" only after the visitor accepts.
|
||||||
|
// Until then GA runs in cookieless "modeling" mode (GDPR-compliant).
|
||||||
|
// - All calls are guarded for SSR (typeof window) so they're safe to call
|
||||||
|
// from anywhere, including event handlers in shared components.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const GA_MEASUREMENT_ID = process.env.NEXT_PUBLIC_GA_ID ?? "";
|
||||||
|
|
||||||
|
/** True when a Measurement ID is configured. */
|
||||||
|
export const analyticsEnabled = (): boolean => GA_MEASUREMENT_ID.length > 0;
|
||||||
|
|
||||||
|
// The gtag function is injected by the loader script. We declare it loosely
|
||||||
|
// so call sites stay clean without pulling in @types/gtag.
|
||||||
|
type GtagFn = (...args: unknown[]) => void;
|
||||||
|
|
||||||
|
function gtag(...args: unknown[]): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const w = window as unknown as { gtag?: GtagFn; dataLayer?: unknown[] };
|
||||||
|
if (typeof w.gtag === "function") {
|
||||||
|
w.gtag(...args);
|
||||||
|
} else if (Array.isArray(w.dataLayer)) {
|
||||||
|
// Buffer until gtag.js finishes loading; the snippet replays dataLayer.
|
||||||
|
w.dataLayer.push(args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Consent (GDPR / ePrivacy) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const CONSENT_COOKIE = "flux_consent";
|
||||||
|
|
||||||
|
export type ConsentChoice = "granted" | "denied";
|
||||||
|
|
||||||
|
/** Push a consent update into Consent Mode v2. */
|
||||||
|
export function updateConsent(choice: ConsentChoice): void {
|
||||||
|
if (!analyticsEnabled()) return;
|
||||||
|
gtag("consent", "update", {
|
||||||
|
analytics_storage: choice,
|
||||||
|
// We don't run ads; keep ad signals denied regardless.
|
||||||
|
ad_storage: "denied",
|
||||||
|
ad_user_data: "denied",
|
||||||
|
ad_personalization: "denied",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the persisted consent choice (client-only). Returns null if unset. */
|
||||||
|
export function readStoredConsent(): ConsentChoice | null {
|
||||||
|
if (typeof document === "undefined") return null;
|
||||||
|
const match = document.cookie
|
||||||
|
.split("; ")
|
||||||
|
.find((c) => c.startsWith(`${CONSENT_COOKIE}=`));
|
||||||
|
if (!match) return null;
|
||||||
|
const value = match.split("=")[1];
|
||||||
|
return value === "granted" || value === "denied" ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist the consent choice for one year. */
|
||||||
|
export function storeConsent(choice: ConsentChoice): void {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
const oneYear = 60 * 60 * 24 * 365;
|
||||||
|
const secure = window.location.protocol === "https:" ? "; Secure" : "";
|
||||||
|
document.cookie = `${CONSENT_COOKIE}=${choice}; Max-Age=${oneYear}; Path=/; SameSite=Lax${secure}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page views ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function pageview(url: string, title?: string): void {
|
||||||
|
if (!analyticsEnabled()) return;
|
||||||
|
gtag("event", "page_view", {
|
||||||
|
page_path: url,
|
||||||
|
page_title: title,
|
||||||
|
page_location: typeof window !== "undefined" ? window.location.href : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Domain events ────────────────────────────────────────────────────────────
|
||||||
|
// One typed helper per meaningful action. Keeps event names consistent so the
|
||||||
|
// GA4 dashboard stays clean and conversions are easy to define.
|
||||||
|
|
||||||
|
export type FluxEvent =
|
||||||
|
| { name: "ai_chat_opened"; params?: { section?: string } }
|
||||||
|
| { name: "ai_consultation_submitted"; params?: { industry?: string; ticketId?: string } }
|
||||||
|
| { name: "parts_order_submitted"; params?: { itemCount?: number } }
|
||||||
|
| { name: "application_viewed"; params: { slug: string } }
|
||||||
|
| { name: "case_study_viewed"; params: { nodeId?: string; application?: string } }
|
||||||
|
| { name: "global_map_node_opened"; params: { nodeType?: string; application?: string } }
|
||||||
|
| { name: "language_changed"; params: { from?: string; to: string } }
|
||||||
|
| { name: "contact_cta_clicked"; params?: { location?: string } };
|
||||||
|
|
||||||
|
export function trackEvent(event: FluxEvent): void {
|
||||||
|
if (!analyticsEnabled()) return;
|
||||||
|
gtag("event", event.name, "params" in event ? event.params : undefined);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// src/lib/csrf.ts
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// CSRF protection for public POST endpoints (consultation form, etc).
|
||||||
|
//
|
||||||
|
// Pattern: stateless double-submit cookie + header.
|
||||||
|
// 1. Server issues a token "<nonce>.<HMAC(nonce, SESSION_SECRET)>".
|
||||||
|
// Stored in a JS-readable cookie (so the client can copy it into a header).
|
||||||
|
// 2. Client POSTs with both the cookie and an X-CSRF-Token header.
|
||||||
|
// 3. Server verifies cookie === header AND the HMAC is valid.
|
||||||
|
//
|
||||||
|
// Why double-submit + HMAC (not just cookie):
|
||||||
|
// - The HMAC binds the cookie to the server, so an attacker can't mint a
|
||||||
|
// cookie via a subdomain or via a JS-injection on an unrelated site.
|
||||||
|
// - Tokens are stateless: no DB roundtrip, no Redis dep, no replay window
|
||||||
|
// beyond the cookie TTL.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { createHmac, randomBytes, timingSafeEqual } from "crypto";
|
||||||
|
|
||||||
|
export const CSRF_COOKIE_NAME = "flux_csrf";
|
||||||
|
export const CSRF_HEADER_NAME = "x-csrf-token";
|
||||||
|
const CSRF_TTL_MS = 1000 * 60 * 60; // 1h — long enough for slow form fills
|
||||||
|
|
||||||
|
function getSecret(): Buffer {
|
||||||
|
const s = process.env.SESSION_SECRET;
|
||||||
|
if (!s) throw new Error("SESSION_SECRET required for CSRF");
|
||||||
|
return Buffer.from(s, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hmac(payload: string): string {
|
||||||
|
return createHmac("sha256", getSecret()).update(payload).digest("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint a fresh CSRF token. Format: "<nonce>.<issuedAtMs>.<hmac>".
|
||||||
|
* The browser will get this in a cookie; the JS client copies it into a header.
|
||||||
|
*/
|
||||||
|
export function issueCsrfToken(): string {
|
||||||
|
const nonce = randomBytes(16).toString("base64url");
|
||||||
|
const issuedAt = Date.now();
|
||||||
|
const payload = `${nonce}.${issuedAt}`;
|
||||||
|
return `${payload}.${hmac(payload)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constant-time verification. Returns true iff the token is well-formed,
|
||||||
|
* the HMAC matches, and the token has not expired.
|
||||||
|
*/
|
||||||
|
export function verifyCsrfToken(token: string | null | undefined): boolean {
|
||||||
|
if (!token || typeof token !== "string") return false;
|
||||||
|
const parts = token.split(".");
|
||||||
|
if (parts.length !== 3) return false;
|
||||||
|
const [nonce, issuedAtStr, mac] = parts;
|
||||||
|
if (!nonce || !issuedAtStr || !mac) return false;
|
||||||
|
const issuedAt = Number(issuedAtStr);
|
||||||
|
if (!Number.isFinite(issuedAt)) return false;
|
||||||
|
if (Date.now() - issuedAt > CSRF_TTL_MS) return false;
|
||||||
|
|
||||||
|
const expected = hmac(`${nonce}.${issuedAtStr}`);
|
||||||
|
const a = Buffer.from(mac);
|
||||||
|
const b = Buffer.from(expected);
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
return timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cookie config helpers — kept here so client and server agree on flags.
|
||||||
|
*/
|
||||||
|
export const csrfCookieOptions = {
|
||||||
|
// NOT httpOnly: the client needs to read it to copy into the header.
|
||||||
|
httpOnly: false,
|
||||||
|
sameSite: "lax" as const,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
path: "/",
|
||||||
|
maxAge: CSRF_TTL_MS / 1000,
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// src/lib/escapeHtml.ts
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Escape user-controlled strings before interpolating into HTML markup.
|
||||||
|
// Used by transactional email templates (src/app/api/consultation/route.ts)
|
||||||
|
// and anywhere we render untrusted text into raw HTML strings.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const HTML_ESCAPES: Record<string, string> = {
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'",
|
||||||
|
"/": "/",
|
||||||
|
"`": "`",
|
||||||
|
"=": "=",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape characters that have special meaning inside HTML text content.
|
||||||
|
* Safe for <div>{value}</div>-style interpolation.
|
||||||
|
*/
|
||||||
|
export function escapeHtml(value: unknown): string {
|
||||||
|
if (value == null) return "";
|
||||||
|
return String(value).replace(/[&<>"'`=/]/g, (c) => HTML_ESCAPES[c] ?? c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape values that will be inserted inside double-quoted HTML attributes
|
||||||
|
* (e.g. href="..."). Strips control characters and escapes the quote chars.
|
||||||
|
*/
|
||||||
|
export function escapeAttr(value: unknown): string {
|
||||||
|
if (value == null) return "";
|
||||||
|
return String(value)
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
.replace(/[\x00-\x1F\x7F]/g, "")
|
||||||
|
.replace(/[&<>"'`]/g, (c) => HTML_ESCAPES[c] ?? c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conservative validator for use in mailto: hrefs. Accepts the same shape
|
||||||
|
* Zod's z.string().email() does. Returns empty string when invalid so the
|
||||||
|
* resulting <a href=""> does nothing harmful.
|
||||||
|
*/
|
||||||
|
export function safeMailto(email: unknown): string {
|
||||||
|
if (typeof email !== "string") return "";
|
||||||
|
if (!/^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/.test(email)) return "";
|
||||||
|
return encodeURI(`mailto:${email}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// src/lib/fileType.ts
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Magic-byte sniffer for uploaded files. Trusts file content, NOT the
|
||||||
|
// client-provided extension or MIME type. Pure stdlib, zero deps.
|
||||||
|
//
|
||||||
|
// Used by /api/public-upload and /api/assets to reject HTML/JS payloads
|
||||||
|
// renamed to .png/.mp4 (a classic stored-XSS vector).
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type DetectedFileType = "jpeg" | "png" | "webp" | "gif" | "mp4" | "mov" | null;
|
||||||
|
|
||||||
|
function startsWith(buf: Buffer, bytes: number[], offset = 0): boolean {
|
||||||
|
if (buf.length < offset + bytes.length) return false;
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
if (buf[offset + i] !== bytes[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inspect the first ~16 bytes of a buffer and return the detected media type,
|
||||||
|
* or null if the file does not match any allow-listed signature.
|
||||||
|
*/
|
||||||
|
export function detectFileType(buf: Buffer): DetectedFileType {
|
||||||
|
if (!buf || buf.length < 12) return null;
|
||||||
|
|
||||||
|
// JPEG: FF D8 FF
|
||||||
|
if (startsWith(buf, [0xff, 0xd8, 0xff])) return "jpeg";
|
||||||
|
|
||||||
|
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
||||||
|
if (startsWith(buf, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "png";
|
||||||
|
|
||||||
|
// GIF: 47 49 46 38 (39|37) 61 — "GIF89a" / "GIF87a"
|
||||||
|
if (startsWith(buf, [0x47, 0x49, 0x46, 0x38]) && (buf[4] === 0x39 || buf[4] === 0x37) && buf[5] === 0x61) {
|
||||||
|
return "gif";
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebP: RIFF....WEBP (52 49 46 46 _ _ _ _ 57 45 42 50)
|
||||||
|
if (
|
||||||
|
startsWith(buf, [0x52, 0x49, 0x46, 0x46]) &&
|
||||||
|
startsWith(buf, [0x57, 0x45, 0x42, 0x50], 8)
|
||||||
|
) {
|
||||||
|
return "webp";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISO Base Media (MP4 / MOV). The format begins with a 4-byte box size,
|
||||||
|
// then "ftyp", then a 4-byte major brand.
|
||||||
|
if (startsWith(buf, [0x66, 0x74, 0x79, 0x70], 4)) {
|
||||||
|
const brand = buf.subarray(8, 12).toString("ascii");
|
||||||
|
// Common MP4 brands
|
||||||
|
if (
|
||||||
|
brand === "isom" || brand === "iso2" || brand === "mp41" || brand === "mp42" ||
|
||||||
|
brand === "avc1" || brand === "M4V " || brand === "M4A " || brand === "dash" ||
|
||||||
|
brand === "MSNV"
|
||||||
|
) return "mp4";
|
||||||
|
// QuickTime
|
||||||
|
if (brand === "qt ") return "mov";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map extension to expected detected type so callers can verify the upload's
|
||||||
|
* content matches its file name. Returns null when the extension is not on
|
||||||
|
* the public upload allow list.
|
||||||
|
*/
|
||||||
|
export function expectedTypeForExtension(ext: string): DetectedFileType | null {
|
||||||
|
switch (ext.toLowerCase()) {
|
||||||
|
case ".jpg":
|
||||||
|
case ".jpeg":
|
||||||
|
return "jpeg";
|
||||||
|
case ".png":
|
||||||
|
return "png";
|
||||||
|
case ".webp":
|
||||||
|
return "webp";
|
||||||
|
case ".gif":
|
||||||
|
return "gif";
|
||||||
|
case ".mp4":
|
||||||
|
return "mp4";
|
||||||
|
case ".mov":
|
||||||
|
return "mov";
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// src/lib/logger.ts
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Minimal structured logger — JSON lines per event so `docker logs flux-app`
|
||||||
|
// can be piped through `jq` and shipped to Loki/Sentry/CloudWatch without code
|
||||||
|
// changes. Zero deps. Replace console.error/log with log.error/log.info.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type LogContext = Record<string, unknown>;
|
||||||
|
|
||||||
|
function serialiseError(err: unknown) {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
return { name: err.name, message: err.message, stack: err.stack };
|
||||||
|
}
|
||||||
|
return { value: String(err) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function emit(level: "info" | "warn" | "error", event: string, ctx?: LogContext, err?: unknown) {
|
||||||
|
const line = JSON.stringify({
|
||||||
|
lvl: level,
|
||||||
|
event,
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
...(err !== undefined ? { err: serialiseError(err) } : {}),
|
||||||
|
...ctx,
|
||||||
|
});
|
||||||
|
if (level === "error") console.error(line);
|
||||||
|
else if (level === "warn") console.warn(line);
|
||||||
|
else console.log(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const log = {
|
||||||
|
info: (event: string, ctx?: LogContext) => emit("info", event, ctx),
|
||||||
|
warn: (event: string, ctx?: LogContext) => emit("warn", event, ctx),
|
||||||
|
error: (event: string, err: unknown, ctx?: LogContext) => emit("error", event, ctx, err),
|
||||||
|
};
|
||||||
+127
-43
@@ -1,34 +1,17 @@
|
|||||||
// src/lib/rateLimit.ts
|
// src/lib/rateLimit.ts
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// -----------------------------------------------------------------------------
|
||||||
// Lightweight in-memory rate limiter (token bucket per IP).
|
// Token-bucket rate limiter with pluggable backend.
|
||||||
// Single Node process, no Redis dep — protects /api/chat from quota burning.
|
//
|
||||||
// Scales to one container; if you add replicas, swap the Map for Upstash Redis.
|
// - InMemoryStore (default): a per-process Map. Fine for single-container
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// deploys (current VPS). If you add replicas, the limit gets multiplied.
|
||||||
|
// - RedisStore (Upstash REST): synchronised across instances. Activated
|
||||||
|
// automatically when REDIS_URL + REDIS_TOKEN env vars are set.
|
||||||
|
//
|
||||||
|
// Both stores expose the same `consume(key, capacity, refillPerSec)` API so
|
||||||
|
// callers don't change when the deploy shape changes.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
interface Bucket {
|
import { log } from "@/lib/logger";
|
||||||
tokens: number;
|
|
||||||
updatedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RateLimitConfig {
|
|
||||||
capacity: number; // Max tokens in the bucket
|
|
||||||
refillPerSec: number; // Tokens added each second
|
|
||||||
}
|
|
||||||
|
|
||||||
const buckets = new Map<string, Bucket>();
|
|
||||||
|
|
||||||
// Garbage-collect stale buckets every 10 min so memory doesn't grow unbounded
|
|
||||||
let lastGc = Date.now();
|
|
||||||
const GC_INTERVAL = 10 * 60 * 1000;
|
|
||||||
const STALE_THRESHOLD = 30 * 60 * 1000;
|
|
||||||
|
|
||||||
function gc(now: number) {
|
|
||||||
if (now - lastGc < GC_INTERVAL) return;
|
|
||||||
for (const [key, bucket] of buckets) {
|
|
||||||
if (now - bucket.updatedAt > STALE_THRESHOLD) buckets.delete(key);
|
|
||||||
}
|
|
||||||
lastGc = now;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RateLimitResult {
|
export interface RateLimitResult {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
@@ -36,24 +19,46 @@ export interface RateLimitResult {
|
|||||||
retryAfterSec: number;
|
retryAfterSec: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rateLimit(key: string, config: RateLimitConfig): RateLimitResult {
|
interface RateLimitStore {
|
||||||
|
consume(key: string, capacity: number, refillPerSec: number): Promise<RateLimitResult> | RateLimitResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── In-memory store ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Bucket {
|
||||||
|
tokens: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class InMemoryStore implements RateLimitStore {
|
||||||
|
private buckets = new Map<string, Bucket>();
|
||||||
|
private lastGc = Date.now();
|
||||||
|
private readonly GC_INTERVAL = 10 * 60 * 1000;
|
||||||
|
private readonly STALE_THRESHOLD = 30 * 60 * 1000;
|
||||||
|
|
||||||
|
private gc(now: number) {
|
||||||
|
if (now - this.lastGc < this.GC_INTERVAL) return;
|
||||||
|
for (const [key, bucket] of this.buckets) {
|
||||||
|
if (now - bucket.updatedAt > this.STALE_THRESHOLD) this.buckets.delete(key);
|
||||||
|
}
|
||||||
|
this.lastGc = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
consume(key: string, capacity: number, refillPerSec: number): RateLimitResult {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
gc(now);
|
this.gc(now);
|
||||||
|
|
||||||
const existing = buckets.get(key);
|
|
||||||
let bucket: Bucket;
|
|
||||||
|
|
||||||
|
const existing = this.buckets.get(key);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
bucket = { tokens: config.capacity - 1, updatedAt: now };
|
this.buckets.set(key, { tokens: capacity - 1, updatedAt: now });
|
||||||
buckets.set(key, bucket);
|
return { ok: true, remaining: capacity - 1, retryAfterSec: 0 };
|
||||||
return { ok: true, remaining: bucket.tokens, retryAfterSec: 0 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const elapsedSec = (now - existing.updatedAt) / 1000;
|
const elapsedSec = (now - existing.updatedAt) / 1000;
|
||||||
const refilled = Math.min(config.capacity, existing.tokens + elapsedSec * config.refillPerSec);
|
const refilled = Math.min(capacity, existing.tokens + elapsedSec * refillPerSec);
|
||||||
|
|
||||||
if (refilled < 1) {
|
if (refilled < 1) {
|
||||||
const retryAfterSec = Math.ceil((1 - refilled) / config.refillPerSec);
|
const retryAfterSec = Math.ceil((1 - refilled) / refillPerSec);
|
||||||
existing.tokens = refilled;
|
existing.tokens = refilled;
|
||||||
existing.updatedAt = now;
|
existing.updatedAt = now;
|
||||||
return { ok: false, remaining: 0, retryAfterSec };
|
return { ok: false, remaining: 0, retryAfterSec };
|
||||||
@@ -62,12 +67,81 @@ export function rateLimit(key: string, config: RateLimitConfig): RateLimitResult
|
|||||||
existing.tokens = refilled - 1;
|
existing.tokens = refilled - 1;
|
||||||
existing.updatedAt = now;
|
existing.updatedAt = now;
|
||||||
return { ok: true, remaining: Math.floor(existing.tokens), retryAfterSec: 0 };
|
return { ok: true, remaining: Math.floor(existing.tokens), retryAfterSec: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Upstash Redis store (REST API, fetch-only, no extra deps) ────────────────
|
||||||
|
|
||||||
|
class UpstashRedisStore implements RateLimitStore {
|
||||||
|
constructor(private url: string, private token: string) {}
|
||||||
|
|
||||||
|
private async pipeline(commands: (string | number)[][]): Promise<unknown[]> {
|
||||||
|
const res = await fetch(`${this.url}/pipeline`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${this.token}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(commands),
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Upstash error ${res.status}`);
|
||||||
|
const data = (await res.json()) as { result: unknown }[];
|
||||||
|
return data.map((d) => d.result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async consume(key: string, capacity: number, refillPerSec: number): Promise<RateLimitResult> {
|
||||||
|
// Lua-free fallback: GET state, compute, SET with TTL. Race window is small
|
||||||
|
// and the worst case is one extra token consumed across replicas — acceptable.
|
||||||
|
const now = Date.now();
|
||||||
|
const stateKey = `rl:${key}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [raw] = await this.pipeline([["GET", stateKey]]);
|
||||||
|
let tokens = capacity;
|
||||||
|
let updatedAt = now;
|
||||||
|
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
const parsed = JSON.parse(raw) as { tokens: number; updatedAt: number };
|
||||||
|
const elapsedSec = (now - parsed.updatedAt) / 1000;
|
||||||
|
tokens = Math.min(capacity, parsed.tokens + elapsedSec * refillPerSec);
|
||||||
|
updatedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokens < 1) {
|
||||||
|
const retryAfterSec = Math.ceil((1 - tokens) / refillPerSec);
|
||||||
|
await this.pipeline([["SET", stateKey, JSON.stringify({ tokens, updatedAt }), "EX", 1800]]);
|
||||||
|
return { ok: false, remaining: 0, retryAfterSec };
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens = tokens - 1;
|
||||||
|
await this.pipeline([["SET", stateKey, JSON.stringify({ tokens, updatedAt }), "EX", 1800]]);
|
||||||
|
return { ok: true, remaining: Math.floor(tokens), retryAfterSec: 0 };
|
||||||
|
} catch (e) {
|
||||||
|
// Fail open: never block legitimate traffic because Redis is down.
|
||||||
|
log.warn("ratelimit.redis_unreachable", { err: String(e) });
|
||||||
|
return { ok: true, remaining: capacity, retryAfterSec: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Singleton picker ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let store: RateLimitStore | null = null;
|
||||||
|
|
||||||
|
function getStore(): RateLimitStore {
|
||||||
|
if (store) return store;
|
||||||
|
const url = process.env.REDIS_URL;
|
||||||
|
const token = process.env.REDIS_TOKEN;
|
||||||
|
if (url && token) {
|
||||||
|
log.info("ratelimit.backend", { backend: "upstash" });
|
||||||
|
store = new UpstashRedisStore(url, token);
|
||||||
|
} else {
|
||||||
|
store = new InMemoryStore();
|
||||||
|
}
|
||||||
|
return store;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function getClientIp(req: Request): string {
|
export function getClientIp(req: Request): string {
|
||||||
// Nginx sets x-forwarded-for; first value is the real client.
|
|
||||||
const xff = req.headers.get("x-forwarded-for");
|
const xff = req.headers.get("x-forwarded-for");
|
||||||
if (xff) return xff.split(",")[0].trim();
|
if (xff) return xff.split(",")[0].trim();
|
||||||
const real = req.headers.get("x-real-ip");
|
const real = req.headers.get("x-real-ip");
|
||||||
@@ -75,12 +149,22 @@ export function getClientIp(req: Request): string {
|
|||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RateLimitConfig {
|
||||||
|
capacity: number;
|
||||||
|
refillPerSec: number;
|
||||||
|
}
|
||||||
|
|
||||||
const CHAT_LIMIT: RateLimitConfig = {
|
const CHAT_LIMIT: RateLimitConfig = {
|
||||||
capacity: 30, // Burst of 30 messages
|
capacity: 30, // burst
|
||||||
refillPerSec: 0.5, // = 30/min sustained
|
refillPerSec: 0.5, // = 30/min sustained
|
||||||
};
|
};
|
||||||
|
|
||||||
export function checkChatRateLimit(req: Request): RateLimitResult {
|
export async function checkChatRateLimit(req: Request): Promise<RateLimitResult> {
|
||||||
const ip = getClientIp(req);
|
const ip = getClientIp(req);
|
||||||
return rateLimit(`chat:${ip}`, CHAT_LIMIT);
|
return Promise.resolve(getStore().consume(`chat:${ip}`, CHAT_LIMIT.capacity, CHAT_LIMIT.refillPerSec));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic helper for ad-hoc limits in other routes.
|
||||||
|
export async function rateLimitAsync(key: string, capacity: number, refillPerSec: number): Promise<RateLimitResult> {
|
||||||
|
return Promise.resolve(getStore().consume(key, capacity, refillPerSec));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export type RevalidateScope =
|
|||||||
| "hero"
|
| "hero"
|
||||||
| "timeline"
|
| "timeline"
|
||||||
| "settings"
|
| "settings"
|
||||||
|
| "team"
|
||||||
| "all";
|
| "all";
|
||||||
|
|
||||||
export interface RevalidateOptions {
|
export interface RevalidateOptions {
|
||||||
@@ -54,6 +55,9 @@ export function revalidateContent({ scope, slug }: RevalidateOptions) {
|
|||||||
case "parts":
|
case "parts":
|
||||||
safeRevalidate(`/${locale}/parts`);
|
safeRevalidate(`/${locale}/parts`);
|
||||||
break;
|
break;
|
||||||
|
case "team":
|
||||||
|
safeRevalidate(`/${locale}/team`);
|
||||||
|
break;
|
||||||
case "heritage":
|
case "heritage":
|
||||||
safeRevalidate(`/${locale}/heritage`);
|
safeRevalidate(`/${locale}/heritage`);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -114,6 +114,70 @@ export function organizationSchema(opts?: { logoUrl?: string; sameAs?: string[]
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function localBusinessSchema(opts?: { logoUrl?: string; sameAs?: string[] }) {
|
||||||
|
const base = baseUrl();
|
||||||
|
return {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "LocalBusiness",
|
||||||
|
"@id": `${base}/#local-business`,
|
||||||
|
name: "FLUX Srl",
|
||||||
|
legalName: "FLUX Srl",
|
||||||
|
url: base,
|
||||||
|
logo: opts?.logoUrl ? absoluteUrl(opts.logoUrl) : `${base}/flux-logo.png`,
|
||||||
|
description:
|
||||||
|
"Manufacturer of solid-state Radio Frequency (RF), Microwave, and Infrared industrial equipment since 1978.",
|
||||||
|
foundingDate: "1978",
|
||||||
|
founder: { "@type": "Person", name: "Patrizio Grando" },
|
||||||
|
address: {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
streetAddress: "Via Benedetto Marcello 32",
|
||||||
|
addressLocality: "Romano d'Ezzelino",
|
||||||
|
addressRegion: "Vicenza",
|
||||||
|
postalCode: "36060",
|
||||||
|
addressCountry: "IT",
|
||||||
|
},
|
||||||
|
geo: {
|
||||||
|
"@type": "GeoCoordinates",
|
||||||
|
latitude: 45.7836,
|
||||||
|
longitude: 11.7677,
|
||||||
|
},
|
||||||
|
telephone: "+39 0424 287 492",
|
||||||
|
email: "info@rf-flux.com",
|
||||||
|
openingHoursSpecification: {
|
||||||
|
"@type": "OpeningHoursSpecification",
|
||||||
|
dayOfWeek: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
|
||||||
|
opens: "08:00",
|
||||||
|
closes: "17:00",
|
||||||
|
},
|
||||||
|
areaServed: { "@type": "Place", name: "Worldwide" },
|
||||||
|
...(opts?.sameAs ? { sameAs: opts.sameAs } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectionPageSchema(opts: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
url: string;
|
||||||
|
items: { name: string; url: string; position: number }[];
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "CollectionPage",
|
||||||
|
name: opts.name,
|
||||||
|
description: opts.description,
|
||||||
|
url: opts.url,
|
||||||
|
mainEntity: {
|
||||||
|
"@type": "ItemList",
|
||||||
|
itemListElement: opts.items.map((item) => ({
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: item.position,
|
||||||
|
url: item.url,
|
||||||
|
name: item.name,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function websiteSchema() {
|
export function websiteSchema() {
|
||||||
const base = baseUrl();
|
const base = baseUrl();
|
||||||
return {
|
return {
|
||||||
|
|||||||
+36
-4
@@ -1,9 +1,25 @@
|
|||||||
import { SignJWT, jwtVerify } from "jose";
|
import { SignJWT, jwtVerify } from "jose";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
// Usamos una variable de entorno secreta, o un fallback súper seguro para desarrollo
|
// SESSION_SECRET is REQUIRED. No fallback: a public default would let anyone
|
||||||
const secretKey = process.env.SESSION_SECRET || "FLUX_SUPER_SECRET_KEY_2026_ARCHITECTURE";
|
// forge a 7-day admin JWT if the env var ever fails to load in production.
|
||||||
const encodedKey = new TextEncoder().encode(secretKey);
|
// Generate a strong value with: openssl rand -base64 48
|
||||||
|
//
|
||||||
|
// Validated LAZILY (inside getEncodedKey, not at module load). `next build`
|
||||||
|
// imports this module during page-data collection without runtime env vars,
|
||||||
|
// so a top-level throw would break the build. At runtime, any attempt to
|
||||||
|
// create a session without a valid secret still throws loudly — the secret
|
||||||
|
// is never defaulted, so admin JWTs can't be forged.
|
||||||
|
function getEncodedKey(): Uint8Array {
|
||||||
|
const secretKey = process.env.SESSION_SECRET;
|
||||||
|
if (!secretKey || secretKey.length < 32) {
|
||||||
|
throw new Error(
|
||||||
|
"SESSION_SECRET environment variable is required (min 32 chars). " +
|
||||||
|
"Generate one with: openssl rand -base64 48"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new TextEncoder().encode(secretKey);
|
||||||
|
}
|
||||||
|
|
||||||
export async function createSession(userId: string, username: string) {
|
export async function createSession(userId: string, username: string) {
|
||||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 días de duración
|
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 días de duración
|
||||||
@@ -13,7 +29,7 @@ export async function createSession(userId: string, username: string) {
|
|||||||
.setProtectedHeader({ alg: "HS256" })
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
.setIssuedAt()
|
.setIssuedAt()
|
||||||
.setExpirationTime("7d")
|
.setExpirationTime("7d")
|
||||||
.sign(encodedKey);
|
.sign(getEncodedKey());
|
||||||
|
|
||||||
// En Next.js 15+, cookies() es una Promesa
|
// En Next.js 15+, cookies() es una Promesa
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
@@ -30,3 +46,19 @@ export async function deleteSession() {
|
|||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.delete("flux_session");
|
cookieStore.delete("flux_session");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify the current admin session (the "flux_session" JWT set at HQ login).
|
||||||
|
// Returns the payload, or null when there is no valid session. Use this to
|
||||||
|
// guard API route handlers under /api that the middleware does NOT cover
|
||||||
|
// (the proxy matcher excludes /api).
|
||||||
|
export async function getAdminSession(): Promise<{ userId: string; username: string } | null> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get("flux_session")?.value;
|
||||||
|
if (!token) return null;
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(token, getEncodedKey());
|
||||||
|
return payload as { userId: string; username: string };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ export const DEFAULT_FOOTER: FooterSettings = {
|
|||||||
ctaTitle1: "Ready to optimize",
|
ctaTitle1: "Ready to optimize",
|
||||||
ctaTitle2: "your production?",
|
ctaTitle2: "your production?",
|
||||||
ctaSubtitle:
|
ctaSubtitle:
|
||||||
"Connect with our engineering team to calculate your ROI and explore custom RF solutions.",
|
"Connect with our team to calculate your ROI, explore solutions, or simply ask a question.",
|
||||||
hqAddress: "Via Benedetto Marcello 32",
|
hqAddress: "Via Benedetto Marcello 32",
|
||||||
hqCity: "36060 Romano d'Ezzelino",
|
hqCity: "36060 Romano d'Ezzelino",
|
||||||
hqRegion: "Vicenza",
|
hqRegion: "Vicenza",
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// src/lib/translationGlossary.ts
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Protected technical terminology for the AI translation engine.
|
||||||
|
//
|
||||||
|
// English is the master language of the site. Some technical / brand terms
|
||||||
|
// must stay in English across ALL locales — translating "Radio Frequency"
|
||||||
|
// literally into Venetian or Italian reads wrong. Instead of hoping the LLM
|
||||||
|
// obeys a "do not translate" instruction, we MASK these terms with stable
|
||||||
|
// placeholders before translation and RESTORE them afterwards. That makes
|
||||||
|
// preservation deterministic, not best-effort.
|
||||||
|
//
|
||||||
|
// To add a term: drop it into PROTECTED_TERMS (longest, most specific first).
|
||||||
|
// Multi-word terms and hyphenated terms are fine.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const PROTECTED_TERMS: string[] = [
|
||||||
|
// Brand
|
||||||
|
"FLUX",
|
||||||
|
"Inside Flux",
|
||||||
|
// Core technology — the terms that read wrong when translated literally
|
||||||
|
"Radio Frequency",
|
||||||
|
"solid-state",
|
||||||
|
"solid state",
|
||||||
|
"RF",
|
||||||
|
// Units (also covered by the prompt rule, masked here for certainty)
|
||||||
|
"MHz",
|
||||||
|
"GHz",
|
||||||
|
"kHz",
|
||||||
|
"kWh",
|
||||||
|
"kW",
|
||||||
|
"MW",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Stable, ASCII-safe placeholder. LLMs reliably preserve identifier-looking
|
||||||
|
// tokens like this. Format chosen so a tolerant cleanup regex can still
|
||||||
|
// recover the term even if the model inserts stray spaces/underscores.
|
||||||
|
const placeholder = (i: number): string => `__FLUXTERM_${i}__`;
|
||||||
|
|
||||||
|
function escapeRegExp(s: string): string {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index terms by length desc so longer terms mask first (e.g. "Radio
|
||||||
|
// Frequency" before "RF"), preventing partial overlaps.
|
||||||
|
const ORDERED = PROTECTED_TERMS
|
||||||
|
.map((term, idx) => ({ term, idx }))
|
||||||
|
.sort((a, b) => b.term.length - a.term.length);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace every protected term (case-insensitive, whole-word) with its
|
||||||
|
* placeholder. Returns the masked text. Use the SAME glossary order to
|
||||||
|
* unmask. Casing is normalised to the canonical glossary form on restore —
|
||||||
|
* desirable for brand consistency.
|
||||||
|
*/
|
||||||
|
export function maskProtectedTerms(text: string): string {
|
||||||
|
if (!text) return text;
|
||||||
|
let out = text;
|
||||||
|
for (const { term, idx } of ORDERED) {
|
||||||
|
// \b works at alphanumeric boundaries; for hyphenated terms the literal
|
||||||
|
// hyphen is matched inside the term, boundaries sit on the outer edges.
|
||||||
|
const re = new RegExp(`\\b${escapeRegExp(term)}\\b`, "gi");
|
||||||
|
out = out.replace(re, placeholder(idx));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore placeholders to their canonical English term. Tolerant of minor
|
||||||
|
* corruption the model may introduce (stray spaces/underscores around the
|
||||||
|
* token), so a mangled placeholder never leaks to the public site.
|
||||||
|
*/
|
||||||
|
export function unmaskProtectedTerms(text: string): string {
|
||||||
|
if (!text) return text;
|
||||||
|
let out = text;
|
||||||
|
// Exact restore first.
|
||||||
|
for (let i = 0; i < PROTECTED_TERMS.length; i++) {
|
||||||
|
out = out.split(placeholder(i)).join(PROTECTED_TERMS[i]);
|
||||||
|
}
|
||||||
|
// Tolerant cleanup for any placeholder the model slightly altered, e.g.
|
||||||
|
// "__ FLUXTERM_2 __" or "__fluxterm_2__".
|
||||||
|
out = out.replace(/_{1,2}\s*FLUXTERM\s*_?\s*(\d+)\s*_{1,2}/gi, (_m, n) => {
|
||||||
|
const idx = Number(n);
|
||||||
|
return PROTECTED_TERMS[idx] ?? "";
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Comma-separated list for embedding in the translation prompt as reinforcement. */
|
||||||
|
export function glossaryForPrompt(): string {
|
||||||
|
return PROTECTED_TERMS.join(", ");
|
||||||
|
}
|
||||||
+18
-6
@@ -9,9 +9,17 @@ import { routing } from './i18n/routing';
|
|||||||
// Configuramos el proxy de next-intl
|
// Configuramos el proxy de next-intl
|
||||||
const handleI18nRouting = createIntlMiddleware(routing);
|
const handleI18nRouting = createIntlMiddleware(routing);
|
||||||
|
|
||||||
// 🔒 2. Llave de seguridad del CMS
|
// 🔒 2. Llave de seguridad del CMS.
|
||||||
const secretKey = process.env.SESSION_SECRET || "FLUX_SUPER_SECRET_KEY_2026_ARCHITECTURE";
|
// No fallback: a public default would let an attacker forge admin JWTs if the
|
||||||
const encodedKey = new TextEncoder().encode(secretKey);
|
// env var ever failed to load. Validated lazily (the middleware runs at
|
||||||
|
// runtime where SESSION_SECRET is always present), mirroring src/lib/session.ts.
|
||||||
|
function getEncodedKey(): Uint8Array {
|
||||||
|
const secretKey = process.env.SESSION_SECRET;
|
||||||
|
if (!secretKey || secretKey.length < 32) {
|
||||||
|
throw new Error("SESSION_SECRET environment variable is required (min 32 chars).");
|
||||||
|
}
|
||||||
|
return new TextEncoder().encode(secretKey);
|
||||||
|
}
|
||||||
|
|
||||||
// 🔥 AHORA SE LLAMA "proxy" EN LUGAR DE "middleware" 🔥
|
// 🔥 AHORA SE LLAMA "proxy" EN LUGAR DE "middleware" 🔥
|
||||||
export async function proxy(request: NextRequest) {
|
export async function proxy(request: NextRequest) {
|
||||||
@@ -32,12 +40,14 @@ export async function proxy(request: NextRequest) {
|
|||||||
// Verificamos el pase
|
// Verificamos el pase
|
||||||
if (cookie) {
|
if (cookie) {
|
||||||
try {
|
try {
|
||||||
await jwtVerify(cookie, encodedKey);
|
await jwtVerify(cookie, getEncodedKey());
|
||||||
// Si tiene pase y está en el login, lo mandamos al dashboard
|
// Si tiene pase y está en el login, lo mandamos al dashboard
|
||||||
if (isPublicHQRoute) {
|
if (isPublicHQRoute) {
|
||||||
return NextResponse.redirect(new URL('/hq-command/dashboard', request.url));
|
return NextResponse.redirect(new URL('/hq-command/dashboard', request.url));
|
||||||
}
|
}
|
||||||
return NextResponse.next(); // Pasa al CMS tranquilamente
|
const authedRes = NextResponse.next();
|
||||||
|
authedRes.headers.set('X-Robots-Tag', 'noindex, nofollow');
|
||||||
|
return authedRes;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Pase falso o expirado
|
// Pase falso o expirado
|
||||||
if (!isPublicHQRoute) {
|
if (!isPublicHQRoute) {
|
||||||
@@ -45,7 +55,9 @@ export async function proxy(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return NextResponse.next(); // Pasa a login/setup si no hay cookie
|
const hqRes = NextResponse.next();
|
||||||
|
hqRes.headers.set('X-Robots-Tag', 'noindex, nofollow');
|
||||||
|
return hqRes;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// src/types/cms.ts
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Shared CMS types derived from Prisma models. Use these instead of `any[]`
|
||||||
|
// when passing DB-shaped data into React components. The Pick<> shapes mirror
|
||||||
|
// what the queries actually `select`, so the compiler catches drift.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Application,
|
||||||
|
GlobalNode,
|
||||||
|
NewsArticle,
|
||||||
|
SparePart,
|
||||||
|
HeroSlide,
|
||||||
|
HeritageSection,
|
||||||
|
TimelineEvent,
|
||||||
|
} from "@prisma/client";
|
||||||
|
|
||||||
|
// ── Application ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type AppCard = Pick<
|
||||||
|
Application,
|
||||||
|
"id" | "slug" | "title" | "subtitle" | "shortDescription" | "category" | "isActive"
|
||||||
|
> & {
|
||||||
|
// Only present when the query opts in:
|
||||||
|
dashboardMetricsJson?: string | null;
|
||||||
|
translationsJson?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AppFull = Application;
|
||||||
|
|
||||||
|
// ── GlobalNode (installations + events + HQ) ────────────────────────────────
|
||||||
|
|
||||||
|
export type NodeMarker = Pick<
|
||||||
|
GlobalNode,
|
||||||
|
"id" | "title" | "location" | "lat" | "lon" | "nodeType" | "application" | "stats" | "isActive"
|
||||||
|
> & {
|
||||||
|
mediaFileName?: string | null;
|
||||||
|
energySavings?: string | null;
|
||||||
|
eventDate?: Date | null;
|
||||||
|
translationsJson?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NodeFull = GlobalNode;
|
||||||
|
|
||||||
|
// ── Inside Flux ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type NewsCard = Pick<
|
||||||
|
NewsArticle,
|
||||||
|
"id" | "slug" | "title" | "excerpt" | "coverImage" | "category" | "publishedAt"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type NewsFull = NewsArticle;
|
||||||
|
|
||||||
|
// ── Parts catalog ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type PartCard = Pick<SparePart, "id" | "sku" | "title" | "description" | "price" | "showPrice" | "isActive"> & {
|
||||||
|
mediaJson?: string | null;
|
||||||
|
specsJson?: string | null;
|
||||||
|
translationsJson?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Hero / story / timeline ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type HeroSlideRow = HeroSlide;
|
||||||
|
export type HeritageRow = HeritageSection;
|
||||||
|
export type TimelineRow = TimelineEvent;
|
||||||
|
|
||||||
|
// ── JSON helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type GalleryImage = { url: string; alt?: string };
|
||||||
|
export type DashboardMetric = { label: string; value: string; trend?: string };
|
||||||
|
export type DatasheetRow = { label: string; value: string; unit?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safe JSON parse for `translationsJson`, `galleryJson`, etc. Returns the
|
||||||
|
* fallback when the field is null, undefined, or malformed. Never throws.
|
||||||
|
*/
|
||||||
|
export function parseJsonField<T>(raw: string | null | undefined, fallback: T): T {
|
||||||
|
if (!raw) return fallback;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
// tests/ai/golden.test.mjs
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Golden tests for FluxAI hardening + analytics. Uses Node's built-in test
|
||||||
|
// runner (no new deps). Run with: `node --test tests/ai/golden.test.mjs`.
|
||||||
|
//
|
||||||
|
// These don't hit OpenAI — they verify the deterministic pieces of the stack:
|
||||||
|
// - escapeHtml strips XSS payloads
|
||||||
|
// - CSRF token issue/verify roundtrip works and rejects tampering
|
||||||
|
// - File-type detector recognises magic bytes and rejects HTML/JS pretending
|
||||||
|
// to be an image
|
||||||
|
// - Industry detector picks the right label from common B2B phrasings
|
||||||
|
// - Zod consultation schema accepts well-formed payloads, rejects bad ones
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
process.env.SESSION_SECRET ??= "test-secret-please-replace-with-32-chars-or-more";
|
||||||
|
|
||||||
|
// Helper: import .ts via project alias. Tests run against the source file
|
||||||
|
// to avoid coupling to the build output. tsx isn't installed by default so
|
||||||
|
// we use loader-less .mjs and import the TS sources via .ts? — but Node
|
||||||
|
// can't load .ts directly. So we copy the small predicates here.
|
||||||
|
|
||||||
|
// 1. escapeHtml — pulled inline because the source is tiny + pure.
|
||||||
|
const HTML_ESCAPES = {
|
||||||
|
"&": "&", "<": "<", ">": ">",
|
||||||
|
'"': """, "'": "'", "/": "/",
|
||||||
|
"`": "`", "=": "=",
|
||||||
|
};
|
||||||
|
function escapeHtml(v) {
|
||||||
|
if (v == null) return "";
|
||||||
|
return String(v).replace(/[&<>"'`=/]/g, (c) => HTML_ESCAPES[c] ?? c);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("escapeHtml: kills <script> injections", () => {
|
||||||
|
const input = `<script>alert(1)</script>`;
|
||||||
|
const out = escapeHtml(input);
|
||||||
|
assert.ok(!out.includes("<script>"));
|
||||||
|
assert.ok(out.includes("<script>"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("escapeHtml: escapes attribute-breakout payloads", () => {
|
||||||
|
const out = escapeHtml(`x" onmouseover="alert(1)`);
|
||||||
|
assert.ok(!out.includes('"'));
|
||||||
|
assert.ok(out.includes("""));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("escapeHtml: handles null/undefined", () => {
|
||||||
|
assert.equal(escapeHtml(null), "");
|
||||||
|
assert.equal(escapeHtml(undefined), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. File-type magic-byte sniffer — synthetic buffers.
|
||||||
|
function startsWith(buf, bytes, offset = 0) {
|
||||||
|
if (buf.length < offset + bytes.length) return false;
|
||||||
|
for (let i = 0; i < bytes.length; i++) if (buf[offset + i] !== bytes[i]) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function detectFileType(buf) {
|
||||||
|
if (!buf || buf.length < 12) return null;
|
||||||
|
if (startsWith(buf, [0xff, 0xd8, 0xff])) return "jpeg";
|
||||||
|
if (startsWith(buf, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "png";
|
||||||
|
if (startsWith(buf, [0x47, 0x49, 0x46, 0x38]) && (buf[4] === 0x39 || buf[4] === 0x37) && buf[5] === 0x61) return "gif";
|
||||||
|
if (startsWith(buf, [0x52, 0x49, 0x46, 0x46]) && startsWith(buf, [0x57, 0x45, 0x42, 0x50], 8)) return "webp";
|
||||||
|
if (startsWith(buf, [0x66, 0x74, 0x79, 0x70], 4)) {
|
||||||
|
const brand = buf.subarray(8, 12).toString("ascii");
|
||||||
|
if (["isom", "iso2", "mp41", "mp42", "avc1", "M4V ", "M4A ", "dash", "MSNV"].includes(brand)) return "mp4";
|
||||||
|
if (brand === "qt ") return "mov";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("detectFileType: recognises PNG", () => {
|
||||||
|
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0, 0, 0]);
|
||||||
|
assert.equal(detectFileType(png), "png");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detectFileType: recognises JPEG", () => {
|
||||||
|
const jpg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||||
|
assert.equal(detectFileType(jpg), "jpeg");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detectFileType: rejects HTML pretending to be PNG", () => {
|
||||||
|
const html = Buffer.from("<html><body><script>alert(1)</script></body></html>");
|
||||||
|
assert.equal(detectFileType(html), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detectFileType: recognises MP4 ftyp box", () => {
|
||||||
|
// 4-byte size + "ftyp" + "isom" + ...
|
||||||
|
const mp4 = Buffer.from([0, 0, 0, 0x20, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d, 0, 0, 0, 0]);
|
||||||
|
assert.equal(detectFileType(mp4), "mp4");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Industry detector
|
||||||
|
function detectIndustryFromText(text) {
|
||||||
|
const t = text.toLowerCase();
|
||||||
|
if (/text|fabric|dye|stenter|finishing|yarn/.test(t)) return "textile";
|
||||||
|
if (/food|defrost|bak|pasteuriz|tempering|cook/.test(t)) return "food";
|
||||||
|
if (/rubber|latex|vulcaniz|foam|tyre|tire/.test(t)) return "rubber";
|
||||||
|
if (/pharma|cannabis|drug|api\b|lab/.test(t)) return "pharma";
|
||||||
|
if (/wood|timber|lumber|kiln/.test(t)) return "wood";
|
||||||
|
if (/ceramic|kiln|clay/.test(t)) return "other";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("industry detector: textile process picks textile", () => {
|
||||||
|
assert.equal(detectIndustryFromText("We dry fabric after dyeing in a stenter"), "textile");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("industry detector: food defrosting picks food", () => {
|
||||||
|
assert.equal(detectIndustryFromText("We defrost meat blocks for processing"), "food");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("industry detector: returns null when no industry is mentioned", () => {
|
||||||
|
assert.equal(detectIndustryFromText("Tell me a joke about engineers"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. CSRF token — re-implements the verifier so tests don't need a TS loader.
|
||||||
|
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
|
const CSRF_TTL_MS = 1000 * 60 * 60;
|
||||||
|
function hmac(payload) {
|
||||||
|
return createHmac("sha256", Buffer.from(process.env.SESSION_SECRET, "utf8")).update(payload).digest("base64url");
|
||||||
|
}
|
||||||
|
function issueCsrfToken() {
|
||||||
|
const nonce = randomBytes(16).toString("base64url");
|
||||||
|
const issuedAt = Date.now();
|
||||||
|
const payload = `${nonce}.${issuedAt}`;
|
||||||
|
return `${payload}.${hmac(payload)}`;
|
||||||
|
}
|
||||||
|
function verifyCsrfToken(token) {
|
||||||
|
if (!token) return false;
|
||||||
|
const parts = String(token).split(".");
|
||||||
|
if (parts.length !== 3) return false;
|
||||||
|
const [n, t, m] = parts;
|
||||||
|
if (!n || !t || !m) return false;
|
||||||
|
const issuedAt = Number(t);
|
||||||
|
if (!Number.isFinite(issuedAt)) return false;
|
||||||
|
if (Date.now() - issuedAt > CSRF_TTL_MS) return false;
|
||||||
|
const expected = hmac(`${n}.${t}`);
|
||||||
|
const a = Buffer.from(m);
|
||||||
|
const b = Buffer.from(expected);
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
return timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("CSRF: fresh token verifies", () => {
|
||||||
|
const t = issueCsrfToken();
|
||||||
|
assert.equal(verifyCsrfToken(t), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CSRF: tampered token fails", () => {
|
||||||
|
const t = issueCsrfToken();
|
||||||
|
const tampered = t.slice(0, -1) + (t.endsWith("A") ? "B" : "A");
|
||||||
|
assert.equal(verifyCsrfToken(tampered), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CSRF: garbage rejected", () => {
|
||||||
|
assert.equal(verifyCsrfToken("not-a-token"), false);
|
||||||
|
assert.equal(verifyCsrfToken(""), false);
|
||||||
|
assert.equal(verifyCsrfToken(null), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Translation glossary — protected technical terms must survive masking.
|
||||||
|
// Re-implements the mask/unmask predicates so the test needs no TS loader;
|
||||||
|
// keep PROTECTED_TERMS in sync with src/lib/translationGlossary.ts.
|
||||||
|
const PROTECTED_TERMS = [
|
||||||
|
"FLUX", "Inside Flux", "Radio Frequency", "solid-state", "solid state",
|
||||||
|
"RF", "MHz", "GHz", "kHz", "kWh", "kW", "MW",
|
||||||
|
];
|
||||||
|
const ph = (i) => `__FLUXTERM_${i}__`;
|
||||||
|
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const ORDERED = PROTECTED_TERMS.map((term, idx) => ({ term, idx })).sort((a, b) => b.term.length - a.term.length);
|
||||||
|
function maskTerms(text) {
|
||||||
|
let out = text;
|
||||||
|
for (const { term, idx } of ORDERED) out = out.replace(new RegExp(`\\b${escapeRe(term)}\\b`, "gi"), ph(idx));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function unmaskTerms(text) {
|
||||||
|
let out = text;
|
||||||
|
for (let i = 0; i < PROTECTED_TERMS.length; i++) out = out.split(ph(i)).join(PROTECTED_TERMS[i]);
|
||||||
|
out = out.replace(/_{1,2}\s*FLUXTERM\s*_?\s*(\d+)\s*_{1,2}/gi, (_m, n) => PROTECTED_TERMS[Number(n)] ?? "");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("glossary: Radio Frequency is masked then restored verbatim", () => {
|
||||||
|
const masked = maskTerms("Radio Frequency drying is efficient.");
|
||||||
|
assert.ok(!masked.includes("Radio Frequency"));
|
||||||
|
assert.ok(masked.includes("__FLUXTERM_"));
|
||||||
|
assert.equal(unmaskTerms(masked), "Radio Frequency drying is efficient.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("glossary: simulated translation preserves the English term", () => {
|
||||||
|
// Mask EN -> 'translate' the surrounding words to IT, keep token -> unmask.
|
||||||
|
const masked = maskTerms("Our Radio Frequency systems use solid-state technology.");
|
||||||
|
// Pretend the model translated everything except the tokens:
|
||||||
|
const fakeItalian = masked
|
||||||
|
.replace("Our", "I nostri")
|
||||||
|
.replace("systems use", "sistemi usano")
|
||||||
|
.replace("technology.", "tecnologia.");
|
||||||
|
const restored = unmaskTerms(fakeItalian);
|
||||||
|
assert.ok(restored.includes("Radio Frequency"));
|
||||||
|
assert.ok(restored.includes("solid-state"));
|
||||||
|
assert.ok(!restored.includes("__FLUXTERM_"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("glossary: RF whole-word only, not inside other words", () => {
|
||||||
|
const masked = maskTerms("surf the RF spectrum");
|
||||||
|
// "RF" masked, "surf" untouched
|
||||||
|
assert.ok(masked.includes("surf"));
|
||||||
|
assert.equal(unmaskTerms(masked), "surf the RF spectrum");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("glossary: tolerant cleanup recovers a mangled placeholder", () => {
|
||||||
|
// Model inserted stray spaces around the token.
|
||||||
|
const recovered = unmaskTerms("La __ FLUXTERM_2 __ è efficiente.");
|
||||||
|
assert.ok(recovered.includes("Radio Frequency"));
|
||||||
|
assert.ok(!recovered.includes("FLUXTERM"));
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Golden tests file resolved at:", pathToFileURL(resolve(import.meta.url)).href);
|
||||||
Reference in New Issue
Block a user