feat(analytics): GA4 with GDPR Consent Mode v2
Google Analytics integration, off by default and GDPR-compliant for EU:
- src/lib/analytics/gtag.ts: typed event helpers + consent control. Every
function is a safe no-op when NEXT_PUBLIC_GA_ID is unset.
- GoogleAnalytics.tsx: loads gtag.js with Consent Mode v2, all storage
defaulting to "denied". anonymize_ip on, send_page_view off.
- ConsentBanner.tsx: on-brand cookie banner, localized to all 5 locales,
persists choice for one year, flips analytics_storage to granted on accept.
- PageViewTracker.tsx: fires page_view on App Router client navigation
(inside Suspense for useSearchParams).
- Key conversion events wired: ai_consultation_submitted (primary funnel
goal) and ai_chat_opened.
- Consent strings added to messages/{en,it,vec,es,de}.json.
Build plumbing:
- NEXT_PUBLIC_GA_ID inlined at build time via Dockerfile ARG +
docker-compose build.args (NEXT_PUBLIC_* must exist during next build,
not just runtime).
- Nginx CSP extended to allow googletagmanager.com + google-analytics.com.
- env template documents NEXT_PUBLIC_GA_ID (empty = analytics disabled).
Verified: production build inlines the Measurement ID into the client
bundle; site builds cleanly both with and without the ID set.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -46,6 +46,12 @@ RUN DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy" npx prisma gene
|
|||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy"
|
ENV DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy"
|
||||||
|
|
||||||
|
# NEXT_PUBLIC_* vars are inlined into the client bundle at BUILD time, so the
|
||||||
|
# GA Measurement ID must be present here (not just at runtime). Passed from
|
||||||
|
# docker-compose build.args -> .env. Empty by default = analytics disabled.
|
||||||
|
ARG NEXT_PUBLIC_GA_ID=""
|
||||||
|
ENV NEXT_PUBLIC_GA_ID=$NEXT_PUBLIC_GA_ID
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ── Stage 4: Production runner ──
|
# ── Stage 4: Production runner ──
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
# NEXT_PUBLIC_GA_ID must be available at build time (Next.js inlines
|
||||||
|
# NEXT_PUBLIC_* into the client bundle). Sourced from .env on the host.
|
||||||
|
NEXT_PUBLIC_GA_ID: ${NEXT_PUBLIC_GA_ID:-}
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
|
|||||||
@@ -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.*
|
||||||
@@ -16,6 +16,11 @@ SESSION_SECRET="CHANGE_ME_openssl_rand_base64_48_min_32_chars"
|
|||||||
#REDIS_URL="https://xxx.upstash.io"
|
#REDIS_URL="https://xxx.upstash.io"
|
||||||
#REDIS_TOKEN="xxxxx"
|
#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.
|
||||||
|
NEXT_PUBLIC_GA_ID=""
|
||||||
|
|
||||||
# OPEN AI KEY
|
# OPEN AI KEY
|
||||||
OPENAI_API_KEY=sk-proj-dsdsdsds-kaDAHhZXPYsK6-4uw0UWJZ0YuLnQfVoEA
|
OPENAI_API_KEY=sk-proj-dsdsdsds-kaDAHhZXPYsK6-4uw0UWJZ0YuLnQfVoEA
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
{
|
{
|
||||||
|
"Consent": {
|
||||||
|
"title": "Wir schätzen Ihre Privatsphäre",
|
||||||
|
"body": "Wir verwenden Analyse-Cookies, um zu verstehen, wie Besucher unsere Website nutzen, und um sie zu verbessern. Es werden keine Daten erfasst, bis Sie zustimmen.",
|
||||||
|
"learnMore": "Datenschutzerklärung",
|
||||||
|
"accept": "Akzeptieren",
|
||||||
|
"decline": "Ablehnen"
|
||||||
|
},
|
||||||
"Navigation": {
|
"Navigation": {
|
||||||
"applications": "Anwendungen",
|
"applications": "Anwendungen",
|
||||||
"globalMap": "Weltkarte",
|
"globalMap": "Weltkarte",
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
{
|
{
|
||||||
|
"Consent": {
|
||||||
|
"title": "We value your privacy",
|
||||||
|
"body": "We use analytics cookies to understand how visitors use our site and to improve it. No data is collected until you accept.",
|
||||||
|
"learnMore": "Privacy Policy",
|
||||||
|
"accept": "Accept",
|
||||||
|
"decline": "Decline"
|
||||||
|
},
|
||||||
"Navigation": {
|
"Navigation": {
|
||||||
"applications": "Applications",
|
"applications": "Applications",
|
||||||
"globalMap": "Global Map",
|
"globalMap": "Global Map",
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
{
|
{
|
||||||
|
"Consent": {
|
||||||
|
"title": "Respetamos tu privacidad",
|
||||||
|
"body": "Usamos cookies analíticas para entender cómo los visitantes usan nuestro sitio y mejorarlo. No se recopila ningún dato hasta que aceptes.",
|
||||||
|
"learnMore": "Política de privacidad",
|
||||||
|
"accept": "Aceptar",
|
||||||
|
"decline": "Rechazar"
|
||||||
|
},
|
||||||
"Navigation": {
|
"Navigation": {
|
||||||
"applications": "Aplicaciones",
|
"applications": "Aplicaciones",
|
||||||
"globalMap": "Mapa Global",
|
"globalMap": "Mapa Global",
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
{
|
{
|
||||||
|
"Consent": {
|
||||||
|
"title": "Rispettiamo la tua privacy",
|
||||||
|
"body": "Utilizziamo cookie analitici per capire come i visitatori usano il nostro sito e per migliorarlo. Nessun dato viene raccolto finché non accetti.",
|
||||||
|
"learnMore": "Informativa sulla privacy",
|
||||||
|
"accept": "Accetta",
|
||||||
|
"decline": "Rifiuta"
|
||||||
|
},
|
||||||
"Navigation": {
|
"Navigation": {
|
||||||
"applications": "Applicazioni",
|
"applications": "Applicazioni",
|
||||||
"globalMap": "Mappa Globale",
|
"globalMap": "Mappa Globale",
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
{
|
{
|
||||||
|
"Consent": {
|
||||||
|
"title": "Tegnémo cara ła to privacy",
|
||||||
|
"body": "Doperémo cookie analitici par capir come che i visitadori i dòpara el nostro sito e par mejorarlo. Nissun dato vien racolto fin che no te aceti.",
|
||||||
|
"learnMore": "Informativa privacy",
|
||||||
|
"accept": "Aceta",
|
||||||
|
"decline": "Refuda"
|
||||||
|
},
|
||||||
"Navigation": {
|
"Navigation": {
|
||||||
"applications": "Applicaçion",
|
"applications": "Applicaçion",
|
||||||
"globalMap": "Mapa del Mondo",
|
"globalMap": "Mapa del Mondo",
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ server {
|
|||||||
# ── Security headers ────────────────────────────────────────────────
|
# ── Security headers ────────────────────────────────────────────────
|
||||||
# 'unsafe-inline' / 'unsafe-eval' on script-src are required by Next.js
|
# 'unsafe-inline' / 'unsafe-eval' on script-src are required by Next.js
|
||||||
# for hydration. Tightening to nonces is tracked as future work.
|
# 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'; 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; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
|
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-Frame-Options "DENY" always;
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import NavigationManager from "@/components/layout/NavigationManager";
|
|||||||
import SilentObserver from "@/components/ai/SilentObserver";
|
import SilentObserver from "@/components/ai/SilentObserver";
|
||||||
import Footer from "@/components/layout/Footer";
|
import Footer from "@/components/layout/Footer";
|
||||||
import CartDrawer from "@/components/layout/CartDrawer";
|
import CartDrawer from "@/components/layout/CartDrawer";
|
||||||
|
import GoogleAnalytics from "@/components/analytics/GoogleAnalytics";
|
||||||
|
import PageViewTracker from "@/components/analytics/PageViewTracker";
|
||||||
|
import ConsentBanner from "@/components/analytics/ConsentBanner";
|
||||||
|
|
||||||
import { NextIntlClientProvider } from 'next-intl';
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
import { getMessages, setRequestLocale } from 'next-intl/server';
|
import { getMessages, setRequestLocale } from 'next-intl/server';
|
||||||
@@ -173,15 +176,24 @@ export default async function RootLayout({
|
|||||||
<NavigationManager />
|
<NavigationManager />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
|
{/* Analytics — page-view tracker needs Suspense (useSearchParams) */}
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<PageViewTracker />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
<div className="flex-grow w-full flex flex-col relative">
|
<div className="flex-grow w-full flex flex-col relative">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Footer locale={locale} />
|
<Footer locale={locale} />
|
||||||
<SilentObserver />
|
<SilentObserver />
|
||||||
|
<ConsentBanner />
|
||||||
|
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
|
|
||||||
|
{/* GA4 loader (Consent Mode v2). No-ops when NEXT_PUBLIC_GA_ID unset. */}
|
||||||
|
<GoogleAnalytics />
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { Calendar, User, Building2, Mail, Phone, MessageSquare, ArrowRight, CheckCircle2, Sparkles, ChevronDown } from "lucide-react";
|
import { Calendar, User, Building2, Mail, Phone, MessageSquare, ArrowRight, CheckCircle2, Sparkles, ChevronDown } from "lucide-react";
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { trackEvent } from "@/lib/analytics/gtag";
|
||||||
|
|
||||||
// ── Data from the AI tool execute ──
|
// ── Data from the AI tool execute ──
|
||||||
interface ConsultationData {
|
interface ConsultationData {
|
||||||
@@ -272,6 +273,12 @@ export default function ConsultationScheduler({ data }: { data: ConsultationData
|
|||||||
setTicketId(result.ticketId);
|
setTicketId(result.ticketId);
|
||||||
setSubmitted(true);
|
setSubmitted(true);
|
||||||
|
|
||||||
|
// GA4 conversion event — the primary funnel goal.
|
||||||
|
trackEvent({
|
||||||
|
name: "ai_consultation_submitted",
|
||||||
|
params: { industry: data.industry, ticketId: result.ticketId },
|
||||||
|
});
|
||||||
|
|
||||||
// Also dispatch the event for any external integrations
|
// Also dispatch the event for any external integrations
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent("flux:consultation-submitted", { detail: { ...payload, ticketId: result.ticketId } })
|
new CustomEvent("flux:consultation-submitted", { detail: { ...payload, ticketId: result.ticketId } })
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import EquipmentConfigurator from "./EquipmentConfigurator";
|
|||||||
import EfficiencyCard from "./EfficiencyCard";
|
import EfficiencyCard from "./EfficiencyCard";
|
||||||
|
|
||||||
import { getAiSessionId } from "@/lib/aiSessionId";
|
import { getAiSessionId } from "@/lib/aiSessionId";
|
||||||
|
import { trackEvent } from "@/lib/analytics/gtag";
|
||||||
|
|
||||||
export default function SilentObserver() {
|
export default function SilentObserver() {
|
||||||
const {
|
const {
|
||||||
@@ -313,7 +314,7 @@ export default function SilentObserver() {
|
|||||||
<div className={`fixed inset-0 z-50 flex pointer-events-none px-3 pb-3 md:p-6 transition-all duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] ${isWideMode ? "items-center justify-center" : "items-end justify-center md:justify-end"}`} style={{ paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))' }}>
|
<div className={`fixed inset-0 z-50 flex pointer-events-none px-3 pb-3 md:p-6 transition-all duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] ${isWideMode ? "items-center justify-center" : "items-end justify-center md:justify-end"}`} style={{ paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))' }}>
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{!isAiExpanded ? (
|
{!isAiExpanded ? (
|
||||||
<motion.button key="pill" layoutId="flux-ai-shell" onClick={toggleAi}
|
<motion.button key="pill" layoutId="flux-ai-shell" onClick={() => { trackEvent({ name: "ai_chat_opened", params: { section: currentSection } }); toggleAi(); }}
|
||||||
initial={{ opacity: 0, scale: 0.8, filter: "blur(10px)" }} animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }} exit={{ opacity: 0, scale: 0.9, filter: "blur(10px)" }}
|
initial={{ opacity: 0, scale: 0.8, filter: "blur(10px)" }} animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }} exit={{ opacity: 0, scale: 0.9, filter: "blur(10px)" }}
|
||||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||||
className="pointer-events-auto flex items-center gap-3 px-5 py-3.5 rounded-full cursor-pointer select-none bg-white/70 dark:bg-[#1D1D1F]/80 backdrop-blur-2xl backdrop-saturate-150 border border-white/60 dark:border-white/10 shadow-[0_8px_32px_rgba(0,0,0,0.08)] dark:shadow-[0_8px_32px_rgba(0,0,0,0.5)] hover:shadow-[0_12px_40px_rgba(0,0,0,0.12)] dark:hover:shadow-[0_12px_40px_rgba(0,0,0,0.6)] hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 group">
|
className="pointer-events-auto flex items-center gap-3 px-5 py-3.5 rounded-full cursor-pointer select-none bg-white/70 dark:bg-[#1D1D1F]/80 backdrop-blur-2xl backdrop-saturate-150 border border-white/60 dark:border-white/10 shadow-[0_8px_32px_rgba(0,0,0,0.08)] dark:shadow-[0_8px_32px_rgba(0,0,0,0.5)] hover:shadow-[0_12px_40px_rgba(0,0,0,0.12)] dark:hover:shadow-[0_12px_40px_rgba(0,0,0,0.6)] hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 group">
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"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 {
|
||||||
|
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")}{" "}
|
||||||
|
<a
|
||||||
|
href="/privacy"
|
||||||
|
className="underline decoration-[#00B8CC] underline-offset-2 hover:text-[#1D1D1F]"
|
||||||
|
>
|
||||||
|
{t("learnMore")}
|
||||||
|
</a>
|
||||||
|
</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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user