Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0399ccf3b | |||
| bf8b2aa631 | |||
| afcaf991b5 | |||
| fbfffb28d9 | |||
| 148aefc68f | |||
| 1ee8288c7e | |||
| 3a94e7c003 |
@@ -46,6 +46,14 @@ RUN DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy" npx prisma gene
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
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
|
||||
|
||||
# ── Stage 4: Production runner ──
|
||||
|
||||
@@ -42,6 +42,12 @@ services:
|
||||
build:
|
||||
context: .
|
||||
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
|
||||
depends_on:
|
||||
postgres:
|
||||
@@ -58,6 +64,10 @@ services:
|
||||
SMTP_FROM: ${SMTP_FROM}
|
||||
SMTP_SECURE: ${SMTP_SECURE}
|
||||
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:
|
||||
- ./public/footage:/app/public/footage
|
||||
- ./public/applications:/app/public/applications
|
||||
@@ -66,10 +76,19 @@ services:
|
||||
- ./public/parts:/app/public/parts
|
||||
- ./public/operations-inbox:/app/public/operations-inbox
|
||||
- ./public/branding:/app/public/branding
|
||||
- ./public/team:/app/public/team
|
||||
networks:
|
||||
- flux-net
|
||||
expose:
|
||||
- "3000"
|
||||
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:
|
||||
@@ -90,6 +109,7 @@ services:
|
||||
- ./public/footage:/srv/footage:ro
|
||||
- ./public/operations-inbox:/srv/operations-inbox:ro
|
||||
- ./public/branding:/srv/branding:ro
|
||||
- ./public/team:/srv/team:ro
|
||||
depends_on:
|
||||
- app
|
||||
networks:
|
||||
|
||||
@@ -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"
|
||||
DATABASE_URL="postgresql://flux_user:dev_password@localhost:5432/flux_db?schema=public"
|
||||
|
||||
#FLUX SECRET Esto no se que hace
|
||||
SESSION_SECRET="FLUX_SUPER_SECRET_KEY_2026_ARCHITECTURE"
|
||||
# SESSION_SECRET (REQUIRED, min 32 chars).
|
||||
# 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
|
||||
OPENAI_API_KEY=sk-proj-dsdsdsds-kaDAHhZXPYsK6-4uw0UWJZ0YuLnQfVoEA
|
||||
|
||||
+18
-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": {
|
||||
"applications": "Anwendungen",
|
||||
"globalMap": "Weltkarte",
|
||||
"ourStory": "Unsere Geschichte",
|
||||
"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": {
|
||||
"title1": "Innovation,",
|
||||
@@ -144,7 +159,8 @@
|
||||
"eventOverview": "Veranstaltungsübersicht",
|
||||
"projectChronicle": "Projektchronik",
|
||||
"pendingData": "[ Chronikdaten für diesen Knoten ausstehend ]",
|
||||
"mediaGallery": "Mediengalerie"
|
||||
"mediaGallery": "Mediengalerie",
|
||||
"viewFullCase": "Vollständige Fallstudie ansehen"
|
||||
},
|
||||
"Footer": {
|
||||
"madeInItaly": "Hergestellt in Italien",
|
||||
|
||||
+18
-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": {
|
||||
"applications": "Applications",
|
||||
"globalMap": "Global Map",
|
||||
"ourStory": "Our Story",
|
||||
"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": {
|
||||
"title1": "Innovation,",
|
||||
@@ -144,7 +159,8 @@
|
||||
"eventOverview": "Event Overview",
|
||||
"projectChronicle": "Project Chronicle",
|
||||
"pendingData": "[ Chronicle data pending for this node ]",
|
||||
"mediaGallery": "Media Gallery"
|
||||
"mediaGallery": "Media Gallery",
|
||||
"viewFullCase": "View full case study"
|
||||
},
|
||||
"Footer": {
|
||||
"madeInItaly": "Made in Italy",
|
||||
|
||||
+18
-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": {
|
||||
"applications": "Aplicaciones",
|
||||
"globalMap": "Mapa Global",
|
||||
"ourStory": "Nuestra Historia",
|
||||
"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": {
|
||||
"title1": "Innovación,",
|
||||
@@ -144,7 +159,8 @@
|
||||
"eventOverview": "Resumen del Evento",
|
||||
"projectChronicle": "Crónica del Proyecto",
|
||||
"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": {
|
||||
"madeInItaly": "Hecho en Italia",
|
||||
|
||||
+18
-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": {
|
||||
"applications": "Applicazioni",
|
||||
"globalMap": "Mappa Globale",
|
||||
"ourStory": "La nostra Storia",
|
||||
"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": {
|
||||
"title1": "Innovazione,",
|
||||
@@ -144,7 +159,8 @@
|
||||
"eventOverview": "Panoramica Evento",
|
||||
"projectChronicle": "Cronaca del Progetto",
|
||||
"pendingData": "[ Dati cronaca in attesa per questo nodo ]",
|
||||
"mediaGallery": "Galleria Media"
|
||||
"mediaGallery": "Galleria Media",
|
||||
"viewFullCase": "Vedi il caso completo"
|
||||
},
|
||||
"Footer": {
|
||||
"madeInItaly": "Made in Italy",
|
||||
|
||||
+18
-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": {
|
||||
"applications": "Applicaçion",
|
||||
"globalMap": "Mapa del Mondo",
|
||||
"ourStory": "La Nostra Storia",
|
||||
"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": {
|
||||
"title1": "Inovaçion,",
|
||||
@@ -144,7 +159,8 @@
|
||||
"eventOverview": "Detaji de l'evento",
|
||||
"projectChronicle": "Storia del projeto",
|
||||
"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": {
|
||||
"madeInItaly": "Fato in Itaia",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/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 {
|
||||
server app:3000;
|
||||
@@ -44,6 +46,14 @@ server {
|
||||
ssl_session_tickets off;
|
||||
|
||||
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;
|
||||
|
||||
# Next.js bundles use content hashing — safe to cache forever
|
||||
location /_next/static/ {
|
||||
@@ -75,6 +85,7 @@ server {
|
||||
|
||||
# Asset uploads (large files, long timeout)
|
||||
location /api/assets {
|
||||
limit_req zone=upload burst=10 nodelay;
|
||||
client_max_body_size 500M;
|
||||
proxy_pass http://nextjs;
|
||||
proxy_set_header Host $host;
|
||||
@@ -87,6 +98,7 @@ server {
|
||||
}
|
||||
|
||||
location /api/public-upload {
|
||||
limit_req zone=upload burst=10 nodelay;
|
||||
client_max_body_size 500M;
|
||||
proxy_pass http://nextjs;
|
||||
proxy_set_header Host $host;
|
||||
@@ -178,6 +190,12 @@ server {
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location /team/ {
|
||||
alias /srv/team/;
|
||||
add_header Cache-Control "public, max-age=300, must-revalidate" always;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://nextjs;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
+2
-1
@@ -6,7 +6,8 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"test:ai": "node --test tests/ai/golden.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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");
|
||||
@@ -60,6 +60,10 @@ model GlobalNode {
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([isActive])
|
||||
@@index([nodeType])
|
||||
@@index([nodeType, isActive])
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
@@ -85,11 +89,18 @@ model Application {
|
||||
dashboardMetricsJson String? @default("[]")
|
||||
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
|
||||
translationsJson String? @default("{}")
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([isActive])
|
||||
@@index([category])
|
||||
@@index([isActive, order])
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
@@ -134,6 +145,9 @@ model NewsArticle {
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([isActive])
|
||||
@@index([isActive, publishedAt(sort: Desc)])
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
@@ -177,6 +191,8 @@ model SparePart {
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([isActive])
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
@@ -210,6 +226,9 @@ model OperationsSignal {
|
||||
clientId String?
|
||||
client ClientUser? @relation(fields: [clientId], references: [id])
|
||||
|
||||
// FluxAI telemetry back-ref: which AI conversations converted into this ticket.
|
||||
conversations AiConversation[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -293,6 +312,58 @@ model SiteSetting {
|
||||
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)
|
||||
// ------------------------------------------------------
|
||||
@@ -314,3 +385,35 @@ model ClientUser {
|
||||
createdAt DateTime @default(now())
|
||||
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])
|
||||
}
|
||||
@@ -1004,6 +1004,21 @@ function ExpandedCaseStudy({ node }: { node: any }) {
|
||||
export default function ApplicationClient({ data, realCases, images, breadcrumbs }: { data: any, realCases: any[], images: any, breadcrumbs?: BreadcrumbItem[] }) {
|
||||
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 [mainLightboxImages, setMainLightboxImages] = useState<string[]>([]);
|
||||
const [mainLightboxInitialIndex, setMainLightboxInitialIndex] = useState(0);
|
||||
@@ -1147,7 +1162,7 @@ export default function ApplicationClient({ data, realCases, images, breadcrumbs
|
||||
{realCases.map((node) => {
|
||||
const isExpanded = expandedCase === node.id;
|
||||
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 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">
|
||||
|
||||
@@ -8,6 +8,9 @@ import NavigationManager from "@/components/layout/NavigationManager";
|
||||
import SilentObserver from "@/components/ai/SilentObserver";
|
||||
import Footer from "@/components/layout/Footer";
|
||||
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 { getMessages, setRequestLocale } from 'next-intl/server';
|
||||
@@ -76,12 +79,17 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
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 {
|
||||
metadataBase: new URL(APP_BASE_URL),
|
||||
title: "FLUX | Energy, Directed.",
|
||||
description: "Advanced Radio Frequency Solutions by Patrizio Grando.",
|
||||
icons,
|
||||
manifest: "/manifest.webmanifest",
|
||||
...(gscToken ? { verification: { google: gscToken } } : {}),
|
||||
openGraph: {
|
||||
title: "FLUX | Energy, Directed.",
|
||||
description: "Advanced Radio Frequency Solutions by Patrizio Grando.",
|
||||
@@ -173,15 +181,24 @@ export default async function RootLayout({
|
||||
<NavigationManager />
|
||||
</Suspense>
|
||||
|
||||
{/* Analytics — page-view tracker needs Suspense (useSearchParams) */}
|
||||
<Suspense fallback={null}>
|
||||
<PageViewTracker />
|
||||
</Suspense>
|
||||
|
||||
<div className="flex-grow w-full flex flex-col relative">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<Footer locale={locale} />
|
||||
<SilentObserver />
|
||||
<ConsentBanner />
|
||||
|
||||
</NextIntlClientProvider>
|
||||
|
||||
{/* GA4 loader (Consent Mode v2). No-ops when NEXT_PUBLIC_GA_ID unset. */}
|
||||
<GoogleAnalytics />
|
||||
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -150,7 +150,7 @@ export default async function Home({ params }: { params: Promise<{ locale: strin
|
||||
shortDescription: true, heroDescription: 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));
|
||||
} catch (error) {
|
||||
|
||||
@@ -43,6 +43,8 @@ export default function AuthModal({ session }: { session: any }) {
|
||||
setError(res.error);
|
||||
} else {
|
||||
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();
|
||||
}
|
||||
setIsLoading(false);
|
||||
@@ -87,6 +89,7 @@ export default function AuthModal({ session }: { session: any }) {
|
||||
setIsLoading(true);
|
||||
await logoutClient();
|
||||
setIsOpen(false);
|
||||
if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent("flux:session-changed"));
|
||||
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]">
|
||||
<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] tracking-tight">
|
||||
Privacy & Cookie <span className="font-medium">Policy</span>
|
||||
</h1>
|
||||
<p className="mt-3 text-sm text-[#86868B]">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 bg-amber-50 p-4 text-sm text-amber-900">
|
||||
<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]">
|
||||
<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] 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] 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] 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]">{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 border border-black/[0.06] shadow-[0_2px_20px_rgba(0,0,0,0.04)] hover:shadow-[0_12px_40px_rgba(0,0,0,0.10)] 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]">
|
||||
{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] tracking-tight">{m.name}</h3>
|
||||
<p className="text-[#0066CC] 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] 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] text-[#6E6E73] hover:text-white hover:bg-[#1D1D1F] hover:border-[#1D1D1F] 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] 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] 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] 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] leading-relaxed max-w-2xl">
|
||||
{t("description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{members.length === 0 ? (
|
||||
<div className="text-center py-24 text-[#86868B]">
|
||||
<p>{t("empty")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<TeamGrid members={members} />
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,13 @@ import bcrypt from "bcryptjs";
|
||||
import { cookies } from "next/headers";
|
||||
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) {
|
||||
const fullName = formData.get("fullName") as string;
|
||||
|
||||
@@ -43,10 +43,12 @@ const SCOPE_ROOTS: Record<string, string> = {
|
||||
footage: path.join(process.cwd(), "public", "footage", "main"),
|
||||
// 🔥 NUEVO: Site-wide brand assets (favicon, logo, OG image)
|
||||
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.
|
||||
const FLAT_SCOPES = new Set(["footage", "branding"]);
|
||||
const FLAT_SCOPES = new Set(["footage", "branding", "team"]);
|
||||
|
||||
const MEDIA_TYPES: Record<string, string[]> = {
|
||||
image: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg", ".avif"],
|
||||
@@ -104,6 +106,7 @@ function buildSafePath(scope: string, slug: string, subPath?: string): string |
|
||||
function buildPublicUrl(scope: string, slug: string, rel: string): string {
|
||||
if (scope === "footage") return `/footage/main/${rel}`;
|
||||
if (scope === "branding") return `/branding/${rel}`;
|
||||
if (scope === "team") return `/team/${rel}`;
|
||||
return `/${scope}/${slug}/${rel}`;
|
||||
}
|
||||
|
||||
|
||||
+174
-4
@@ -1,8 +1,10 @@
|
||||
import { openai } from '@ai-sdk/openai';
|
||||
import { streamText, stepCountIs, UIMessage, convertToModelMessages, tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { createHash } from 'crypto';
|
||||
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;
|
||||
|
||||
@@ -161,11 +163,24 @@ function industryFromSlug(slug: string): string {
|
||||
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 ──────────────────────────────────────────────
|
||||
|
||||
export async function POST(req: Request) {
|
||||
// ─── Rate limit (per-IP token bucket, 30 req/min) ──────────────
|
||||
const rate = checkChatRateLimit(req);
|
||||
const rate = await checkChatRateLimit(req);
|
||||
if (!rate.ok) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
@@ -183,16 +198,86 @@ export async function POST(req: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const { messages, context }: {
|
||||
const {
|
||||
messages,
|
||||
context,
|
||||
sessionId,
|
||||
locale,
|
||||
pageUrl,
|
||||
}: {
|
||||
messages: UIMessage[];
|
||||
context?: { section?: string; activeTab?: string };
|
||||
sessionId?: string;
|
||||
locale?: string;
|
||||
pageUrl?: string | null;
|
||||
} = await req.json();
|
||||
|
||||
const contextNote = context?.section
|
||||
? `\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 coreMessages = await convertToModelMessages(messages);
|
||||
@@ -201,6 +286,91 @@ export async function POST(req: Request) {
|
||||
model: openai('gpt-4o'),
|
||||
system: systemPrompt + contextNote,
|
||||
messages: coreMessages,
|
||||
providerOptions: { openai: { promptCacheKey: 'fluxai-v1' } },
|
||||
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
|
||||
// search → calculator → case-study → consultation in a single turn,
|
||||
// exactly as the SPIN methodology in the system prompt was designed for.
|
||||
|
||||
@@ -1,12 +1,50 @@
|
||||
// /src/app/api/consultation/route.ts
|
||||
// Public API endpoint for ConsultationScheduler → OperationsSignal
|
||||
// Uses SMTP mailer (no Resend dependency)
|
||||
// Public API endpoint for ConsultationScheduler -> OperationsSignal.
|
||||
// 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 { z } from "zod";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
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> {
|
||||
const year = new Date().getFullYear();
|
||||
const count = await prisma.operationsSignal.count({
|
||||
@@ -16,34 +54,54 @@ async function generateConsultationTicketId(): Promise<string> {
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { contact, aiContext, meta } = body;
|
||||
|
||||
if (!contact?.name || !contact?.email || !contact?.company) {
|
||||
return NextResponse.json({ error: "Missing required contact fields" }, { status: 400 });
|
||||
// ── CSRF: double-submit cookie + header must match ──────────────────────
|
||||
const csrfCookie = request.cookies.get(CSRF_COOKIE_NAME)?.value ?? null;
|
||||
const csrfHeader = request.headers.get(CSRF_HEADER_NAME);
|
||||
if (!csrfCookie || !csrfHeader || csrfCookie !== csrfHeader || !verifyCsrfToken(csrfHeader)) {
|
||||
log.warn("consultation.csrf_rejected", { hasCookie: !!csrfCookie, hasHeader: !!csrfHeader });
|
||||
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();
|
||||
|
||||
// Build structured AI analysis
|
||||
// Build structured AI analysis (plain text, no markup needed)
|
||||
const aiParts: string[] = [];
|
||||
if (aiContext?.industryLabel && aiContext?.process) aiParts.push(`[INDUSTRY] ${aiContext.industryLabel} — ${aiContext.process}`);
|
||||
if (aiContext?.estimatedSavingsPercent) aiParts.push(`[ESTIMATED SAVINGS] ~${aiContext.estimatedSavingsPercent}% energy reduction`);
|
||||
if (aiContext?.industryLabel && aiContext?.process)
|
||||
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?.conversationInsights?.length > 0) aiParts.push(`[AI DISCUSSION POINTS]\n${aiContext.conversationInsights.map((i: string) => `• ${i}`).join("\n")}`);
|
||||
if (aiContext?.suggestedTopics?.length > 0) aiParts.push(`[SUGGESTED ENGINEERING TOPICS]\n${aiContext.suggestedTopics.map((t: string) => `→ ${t}`).join("\n")}`);
|
||||
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"}`);
|
||||
if (aiContext?.conversationInsights?.length)
|
||||
aiParts.push(`[AI DISCUSSION POINTS]\n${aiContext.conversationInsights.map((i) => `- ${i}`).join("\n")}`);
|
||||
if (aiContext?.suggestedTopics?.length)
|
||||
aiParts.push(`[SUGGESTED ENGINEERING TOPICS]\n${aiContext.suggestedTopics.map((t) => `-> ${t}`).join("\n")}`);
|
||||
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 messageParts: string[] = [];
|
||||
if (contact.message) messageParts.push(contact.message);
|
||||
if (contact.timeframe) messageParts.push(`Timeframe: ${contact.timeframe}`);
|
||||
if (contact.preferredContact) messageParts.push(`Preferred contact: ${contact.preferredContact}`);
|
||||
|
||||
// Save to DB
|
||||
const signal = await prisma.operationsSignal.create({
|
||||
data: {
|
||||
ticketId,
|
||||
@@ -60,23 +118,33 @@ 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 targetEmails = route && route.isActive
|
||||
const targetEmails =
|
||||
route && route.isActive
|
||||
? route.emails.split(",").map((e: string) => e.trim()).filter(Boolean)
|
||||
: ["engineering@fluxsrl.com"];
|
||||
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://flux.com";
|
||||
|
||||
// Send via SMTP
|
||||
const emailResult = await sendEmail({
|
||||
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),
|
||||
replyTo: contact.email,
|
||||
});
|
||||
|
||||
// Track email delivery
|
||||
await prisma.operationsSignal.update({
|
||||
where: { id: signal.id },
|
||||
data: {
|
||||
@@ -86,6 +154,8 @@ export async function POST(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
log.info("consultation.submitted", { ticketId, emailSent: emailResult.success });
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
ticketId,
|
||||
@@ -93,33 +163,65 @@ export async function POST(request: NextRequest) {
|
||||
emailError: emailResult.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 });
|
||||
}
|
||||
}
|
||||
|
||||
function generateConsultationEmail(contact: any, aiContext: any, ticketId: string, appUrl: string) {
|
||||
const insights = (aiContext?.conversationInsights || []).map((i: string) => `<li style="margin-bottom: 6px;">${i}</li>`).join("");
|
||||
const topics = (aiContext?.suggestedTopics || []).map((t: string) => `<li style="margin-bottom: 4px; color: #0066CC;">${t}</li>`).join("");
|
||||
type ParsedContact = z.infer<typeof ConsultationSchema>["contact"];
|
||||
type ParsedAiContext = z.infer<typeof ConsultationSchema>["aiContext"];
|
||||
|
||||
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 `
|
||||
<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;">
|
||||
<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>
|
||||
<p style="font-family: monospace; color: #00F0FF;">${ticketId}</p>
|
||||
<p style="font-family: monospace; color: #00F0FF;">${safeTicketId}</p>
|
||||
</div>
|
||||
<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;">Email: <a href="mailto:${contact.email}" style="color: #0066CC;">${contact.email}</a></p>
|
||||
${contact.phone ? `<p style="margin: 4px 0;">Phone: ${contact.phone}</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;"><strong>${safeName}</strong> - ${safeCompany}</p>
|
||||
<p style="margin: 4px 0;">Email: <a href="${mailHref}" style="color: #0066CC;">${safeEmail}</a></p>
|
||||
${safePhone ? `<p style="margin: 4px 0;">Phone: ${safePhone}</p>` : ""}
|
||||
<p style="margin: 4px 0;">Preferred: <strong>${safePreferred}</strong> · Timeframe: <strong>${safeTimeframe}</strong></p>
|
||||
</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>` : ""}
|
||||
${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>` : ""}
|
||||
${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>
|
||||
${
|
||||
safeIndustry
|
||||
? `<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>`
|
||||
: ""
|
||||
}
|
||||
${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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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 path from "path";
|
||||
import { revalidateContent } from "@/lib/revalidate";
|
||||
import { detectFileType, expectedTypeForExtension } from "@/lib/fileType";
|
||||
import { log } from "@/lib/logger";
|
||||
|
||||
// 1. REGLAS DE SEGURIDAD ESTRICTAS
|
||||
const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp', '.mp4', '.mov'];
|
||||
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB Límite
|
||||
// 1. STRICT SECURITY RULES
|
||||
const ALLOWED_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".mp4", ".mov"];
|
||||
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file") as File;
|
||||
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) {
|
||||
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();
|
||||
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, "");
|
||||
// Convertimos "David Herran!" a "david-herran"
|
||||
const safeClientName = clientName.toLowerCase().replace(/[^a-z0-9]/g, "-").substring(0, 30);
|
||||
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);
|
||||
|
||||
// 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"))) {
|
||||
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)) {
|
||||
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 filePath = path.join(uploadDir, safeFileName);
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
|
||||
// 6. DEVOLVER LA URL PÚBLICA PARA GUARDARLA EN POSTGRES
|
||||
const publicUrl = `/operations-inbox/${folderName}/${safeFileName}`;
|
||||
|
||||
// Invalida caché del operations-inbox / dashboard
|
||||
revalidateContent({ scope: "operations-inbox", slug: folderName });
|
||||
|
||||
log.info("public_upload.saved", { ticketId: safeTicketId, file: safeFileName, detected, bytes: file.size });
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
url: publicUrl,
|
||||
fileName: safeFileName,
|
||||
type: ext === '.mp4' || ext === '.mov' ? 'video' : 'image'
|
||||
type: ext === ".mp4" || ext === ".mov" ? "video" : "image",
|
||||
});
|
||||
|
||||
} 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 });
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export async function getApplications() {
|
||||
noStore();
|
||||
try {
|
||||
const apps = await prisma.application.findMany({
|
||||
orderBy: { createdAt: "asc" }
|
||||
orderBy: [{ order: "asc" }, { createdAt: "asc" }]
|
||||
});
|
||||
return { success: true, apps };
|
||||
} 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)
|
||||
export async function seedInitialApplications() {
|
||||
// ... Tu código actual de la semilla se queda exactamente igual ...
|
||||
|
||||
@@ -7,9 +7,9 @@ import { useRouter } from "next/navigation";
|
||||
import {
|
||||
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,
|
||||
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";
|
||||
import { getApplications, createApplication, updateApplicationData, toggleApplication, deleteApplication } from "./actions";
|
||||
import { getApplications, createApplication, updateApplicationData, toggleApplication, deleteApplication, reorderApplications } from "./actions";
|
||||
import { useHqUi } from "@/components/hq/Toast";
|
||||
|
||||
|
||||
@@ -258,9 +258,29 @@ export default function ApplicationsManager() {
|
||||
const [sections, setSections] = 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); };
|
||||
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) => {
|
||||
setEditingApp(app); setActiveTab("basic");
|
||||
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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -308,14 +328,24 @@ export default function ApplicationsManager() {
|
||||
<div className="bg-[#111] border border-white/5 rounded-3xl overflow-hidden shadow-2xl">
|
||||
<div className="overflow-x-auto">
|
||||
<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>
|
||||
{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) => {
|
||||
const isPopulated = app.heroDescription && app.heroDescription.length > 10;
|
||||
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">{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>
|
||||
|
||||
@@ -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",
|
||||
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",
|
||||
description: "Manage the spare parts catalog, pricing, and SKUs.",
|
||||
@@ -177,6 +186,15 @@ export default async function DashboardPage() {
|
||||
color: "text-fuchsia-400",
|
||||
bg: "bg-fuchsia-500/10",
|
||||
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: "/news", priority: 0.7, changeFrequency: "daily" 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) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Calendar, User, Building2, Mail, Phone, MessageSquare, ArrowRight, CheckCircle2, Sparkles, ChevronDown } from "lucide-react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { trackEvent } from "@/lib/analytics/gtag";
|
||||
|
||||
// ── Data from the AI tool execute ──
|
||||
interface ConsultationData {
|
||||
@@ -251,9 +252,18 @@ export default function ConsultationScheduler({ data }: { data: ConsultationData
|
||||
};
|
||||
|
||||
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", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": csrfToken,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
@@ -263,6 +273,12 @@ export default function ConsultationScheduler({ data }: { data: ConsultationData
|
||||
setTicketId(result.ticketId);
|
||||
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
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("flux:consultation-submitted", { detail: { ...payload, ticketId: result.ticketId } })
|
||||
|
||||
@@ -18,6 +18,9 @@ import CaseStudyViewer from "./CaseStudyViewer";
|
||||
import EquipmentConfigurator from "./EquipmentConfigurator";
|
||||
import EfficiencyCard from "./EfficiencyCard";
|
||||
|
||||
import { getAiSessionId } from "@/lib/aiSessionId";
|
||||
import { trackEvent } from "@/lib/analytics/gtag";
|
||||
|
||||
export default function SilentObserver() {
|
||||
const {
|
||||
isAiExpanded, toggleAi, setAiExpanded,
|
||||
@@ -54,15 +57,20 @@ export default function SilentObserver() {
|
||||
};
|
||||
|
||||
// ═══ 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({
|
||||
api: "/api/chat",
|
||||
body: () => ({
|
||||
sessionId: getAiSessionId(),
|
||||
locale,
|
||||
pageUrl: typeof window !== "undefined" ? window.location.href : null,
|
||||
context: {
|
||||
section: sectionRef.current,
|
||||
activeTab: tabRef.current,
|
||||
},
|
||||
}),
|
||||
}), []);
|
||||
}), [locale]);
|
||||
|
||||
// ═══ AI SDK 6: useChat ═══
|
||||
const { messages, sendMessage, addToolOutput, status } = useChat({
|
||||
@@ -306,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))' }}>
|
||||
<AnimatePresence mode="wait">
|
||||
{!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)" }}
|
||||
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">
|
||||
|
||||
@@ -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 {
|
||||
const rawApps = await prisma.application.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
orderBy: [{ order: "asc" }, { createdAt: "asc" }],
|
||||
take: 4,
|
||||
});
|
||||
activeApps = rawApps.map((app: any) => getLocalizedData(app, locale));
|
||||
|
||||
@@ -13,6 +13,7 @@ const NAV_KEYS = [
|
||||
{ key: "globalMap", href: "/#global" },
|
||||
{ key: "ourStory", href: "/#our-story" },
|
||||
{ key: "insideFlux", href: "/news" },
|
||||
{ key: "team", href: "/team" },
|
||||
{ key: "parts", href: "/parts" },
|
||||
];
|
||||
|
||||
@@ -62,20 +63,31 @@ export default function NavBar() {
|
||||
};
|
||||
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 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);
|
||||
};
|
||||
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 () => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
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 { useTranslations } from "next-intl";
|
||||
import { getIconForSlug } from "@/lib/applicationIcons";
|
||||
import type { AppCard, DashboardMetric } from "@/types/cms";
|
||||
import { parseJsonField } from "@/types/cms";
|
||||
|
||||
export default function ApplicationsDashboard({ dbApps = [] }: { dbApps?: any[] }) {
|
||||
const activeApps = dbApps.filter(app => app.isActive);
|
||||
export default function ApplicationsDashboard({ dbApps = [] }: { dbApps?: AppCard[] }) {
|
||||
const activeApps = dbApps.filter((app) => app.isActive);
|
||||
if (!activeApps || activeApps.length === 0) return null;
|
||||
|
||||
const [activeSlug, setActiveSlug] = useState(activeApps[0]?.slug);
|
||||
const activeApp = activeApps.find(app => app.slug === activeSlug) || activeApps[0];
|
||||
const [activeSlug, setActiveSlug] = useState<string | undefined>(activeApps[0]?.slug);
|
||||
const activeApp = activeApps.find((app) => app.slug === activeSlug) || activeApps[0];
|
||||
|
||||
const t = useTranslations("AppsDashboard"); // 🔥 LLAMAMOS AL DICCIONARIO
|
||||
const t = useTranslations("AppsDashboard");
|
||||
|
||||
let metrics = [];
|
||||
try { metrics = JSON.parse(activeApp.dashboardMetricsJson || "[]"); } catch (e) {}
|
||||
const metrics = parseJsonField<DashboardMetric[]>(activeApp?.dashboardMetricsJson, []);
|
||||
|
||||
const triggerFluxAI = (prompt: string) => {
|
||||
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 CaseStudyModal, { CaseStudyData } from "@/components/ui/CaseStudyModal";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { AppCard, NodeMarker } from "@/types/cms";
|
||||
|
||||
const RADIUS = 2;
|
||||
const CAM_FOV = 50;
|
||||
@@ -536,7 +537,7 @@ function NodeCard({ node, isDark, onClose, onViewCase }: {
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 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 [subFilter, setSubFilter] = 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
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 { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Link } from "@/i18n/routing";
|
||||
import { trackEvent } from "@/lib/analytics/gtag";
|
||||
|
||||
export interface CaseStudyData {
|
||||
id: string;
|
||||
@@ -305,6 +307,28 @@ export default function CaseStudyModal({ isOpen, onClose, data }: ModalProps) {
|
||||
</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 ? (
|
||||
<div className="max-w-none mb-12">
|
||||
<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 { openai } from '@ai-sdk/openai';
|
||||
import {
|
||||
maskProtectedTerms,
|
||||
unmaskProtectedTerms,
|
||||
glossaryForPrompt,
|
||||
} from '@/lib/translationGlossary';
|
||||
|
||||
/**
|
||||
* 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: "..." }
|
||||
* @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>) {
|
||||
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({
|
||||
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:
|
||||
1. 'it': Standard Professional Italian.
|
||||
@@ -22,7 +39,9 @@ export async function translateContentForCMS(content: Record<string, string>) {
|
||||
CRITICAL RULES:
|
||||
- NEVER translate Markdown syntax (#, **, *, >, |---|).
|
||||
- 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.
|
||||
|
||||
OUTPUT FORMAT:
|
||||
@@ -35,7 +54,7 @@ export async function translateContentForCMS(content: Record<string, string>) {
|
||||
"de": { "key1": "translated text..." }
|
||||
}`,
|
||||
|
||||
prompt: JSON.stringify(content),
|
||||
prompt: JSON.stringify(maskedContent),
|
||||
});
|
||||
|
||||
// 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
|
||||
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;
|
||||
|
||||
} 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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Lightweight in-memory rate limiter (token bucket per IP).
|
||||
// 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.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// -----------------------------------------------------------------------------
|
||||
// Token-bucket rate limiter with pluggable backend.
|
||||
//
|
||||
// - 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 {
|
||||
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;
|
||||
}
|
||||
import { log } from "@/lib/logger";
|
||||
|
||||
export interface RateLimitResult {
|
||||
ok: boolean;
|
||||
@@ -36,24 +19,46 @@ export interface RateLimitResult {
|
||||
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();
|
||||
gc(now);
|
||||
|
||||
const existing = buckets.get(key);
|
||||
let bucket: Bucket;
|
||||
this.gc(now);
|
||||
|
||||
const existing = this.buckets.get(key);
|
||||
if (!existing) {
|
||||
bucket = { tokens: config.capacity - 1, updatedAt: now };
|
||||
buckets.set(key, bucket);
|
||||
return { ok: true, remaining: bucket.tokens, retryAfterSec: 0 };
|
||||
this.buckets.set(key, { tokens: capacity - 1, updatedAt: now });
|
||||
return { ok: true, remaining: capacity - 1, retryAfterSec: 0 };
|
||||
}
|
||||
|
||||
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) {
|
||||
const retryAfterSec = Math.ceil((1 - refilled) / config.refillPerSec);
|
||||
const retryAfterSec = Math.ceil((1 - refilled) / refillPerSec);
|
||||
existing.tokens = refilled;
|
||||
existing.updatedAt = now;
|
||||
return { ok: false, remaining: 0, retryAfterSec };
|
||||
@@ -62,12 +67,81 @@ export function rateLimit(key: string, config: RateLimitConfig): RateLimitResult
|
||||
existing.tokens = refilled - 1;
|
||||
existing.updatedAt = now;
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
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");
|
||||
if (xff) return xff.split(",")[0].trim();
|
||||
const real = req.headers.get("x-real-ip");
|
||||
@@ -75,12 +149,22 @@ export function getClientIp(req: Request): string {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
interface RateLimitConfig {
|
||||
capacity: number;
|
||||
refillPerSec: number;
|
||||
}
|
||||
|
||||
const CHAT_LIMIT: RateLimitConfig = {
|
||||
capacity: 30, // Burst of 30 messages
|
||||
capacity: 30, // burst
|
||||
refillPerSec: 0.5, // = 30/min sustained
|
||||
};
|
||||
|
||||
export function checkChatRateLimit(req: Request): RateLimitResult {
|
||||
export async function checkChatRateLimit(req: Request): Promise<RateLimitResult> {
|
||||
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"
|
||||
| "timeline"
|
||||
| "settings"
|
||||
| "team"
|
||||
| "all";
|
||||
|
||||
export interface RevalidateOptions {
|
||||
@@ -54,6 +55,9 @@ export function revalidateContent({ scope, slug }: RevalidateOptions) {
|
||||
case "parts":
|
||||
safeRevalidate(`/${locale}/parts`);
|
||||
break;
|
||||
case "team":
|
||||
safeRevalidate(`/${locale}/team`);
|
||||
break;
|
||||
case "heritage":
|
||||
safeRevalidate(`/${locale}/heritage`);
|
||||
break;
|
||||
|
||||
+10
-2
@@ -1,8 +1,16 @@
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
// Usamos una variable de entorno secreta, o un fallback súper seguro para desarrollo
|
||||
const secretKey = process.env.SESSION_SECRET || "FLUX_SUPER_SECRET_KEY_2026_ARCHITECTURE";
|
||||
// SESSION_SECRET is REQUIRED. No fallback: a public default would let anyone
|
||||
// forge a 7-day admin JWT if the env var ever fails to load in production.
|
||||
// Generate a strong value with: openssl rand -base64 48
|
||||
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"
|
||||
);
|
||||
}
|
||||
const encodedKey = new TextEncoder().encode(secretKey);
|
||||
|
||||
export async function createSession(userId: string, username: string) {
|
||||
|
||||
@@ -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(", ");
|
||||
}
|
||||
@@ -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