Compare commits

...

56 Commits

Author SHA1 Message Date
davidherran a81ee50ed8 feat(resilience): operational hardening (NEXT phase of the audit)
Deploy to VPS / deploy (push) Has been cancelled
Acts on the audit's NEXT block — operational resilience.

Backups (N1):
- New `backup` compose service (postgres:16-alpine) runs scripts/backup-loop.sh:
  immediate pg_dump on start, then nightly, gzip, 14-day rotation into
  ./backups on the host. Configurable via BACKUP_RETENTION_DAYS /
  BACKUP_INTERVAL_SECONDS. (Offsite copy is the documented next step.)

Resource limits + healthchecks (N2):
- deploy.resources.limits.memory on postgres (2g), app (1500m), nginx (256m),
  backup (256m) so no container can starve the others (the Nginx outage was a
  reminder).
- Nginx now has a healthcheck hitting a new self-served `/nginx-health`
  endpoint on the default_server (no upstream dependency).

Chat resilience (N3):
- buildSystemPrompt() wraps its 4 Prisma queries in try/catch with safe
  defaults — if Postgres is down the assistant degrades instead of 500-ing.
- Result is cached for 60s (only on healthy builds) so we don't run 4 queries
  per message; CMS edits still appear within the TTL.
- POST fails fast with 503 if OPENAI_API_KEY is missing (instead of breaking
  mid-stream after headers are sent).
- streamText gets an onError handler that logs + persists an `error` AiEvent.

Idempotent submissions (N4):
- consultation/route.ts and operations.ts now wrap the email-tracking UPDATE
  in try/catch — the lead/signal is already saved, so a telemetry hiccup can't
  500 the request and trigger a duplicate retry. operations.ts also returns
  emailError.

Performance (N5):
- Index GlobalNode(application, isActive) — backs the case-study join on every
  application page. Migration 20260609130000_index_globalnode_application.

Verified: next build compiles (Docker parity, SESSION_SECRET unset),
TypeScript clean, prisma schema valid, golden tests 17/17,
`docker compose config` valid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:07:38 -05:00
davidherran 18d5ed87c8 fix(security+db): close the real audit findings (SEC-04/05/01, DB-01)
Deploy to VPS / deploy (push) Has been cancelled
Acts on the verified findings from the 2026-06 audit (docs/AUDIT_2026-06_
VERIFIED.md). The audit's #1 "middleware never runs" was a false positive
(verified in prod: /hq-command redirects to login). These are the genuine
gaps:

- SEC-04 (HIGH): /api/assets (GET/POST/PUT/DELETE/PATCH) and
  /api/branding/favicon (POST) had NO auth. The middleware matcher excludes
  /api, so they were world-reachable — anyone could list/upload/rename/
  delete CMS files or regenerate the favicon. Added a new getAdminSession()
  helper (src/lib/session.ts) and a requireAdmin() guard on every handler.

- DB-01 (HIGH): the ClientUser table (B2B client portal) was defined in the
  schema but NEVER created by any migration, and OperationsSignal.clientId +
  its FK were missing too. B2B register/login failed at runtime; the
  dashboard silently showed 0 clients. New additive migration
  20260609120000_add_client_user creates the table, the unique email index,
  the clientId column (IF NOT EXISTS), and the FK (duplicate-object guarded).

- SEC-05 (MED-HIGH): operations.ts generateRichEmailHtml() interpolated
  item.title/sku/quantity, clientName/Company/Email/Phone and the free-text
  message straight into HTML — stored XSS into the team's internal inbox.
  Now escaped via escapeHtml/escapeAttr/safeMailto; file links validated to
  internal paths only.

- SEC-01 (MED): removed the hardcoded SESSION_SECRET fallback in src/proxy.ts;
  it now validates lazily and throws if the secret is missing (mirrors
  session.ts), so a runtime env failure can't fall back to a public key.

Verified: next build compiles with SESSION_SECRET unset (Docker parity),
TypeScript clean, prisma schema valid, golden tests 17/17.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:40:20 -05:00
davidherran b76c14b780 feat(ui): friendlier wording in the contact form
Deploy to VPS / deploy (push) Has been cancelled
Match the broadened "Contact FLUX Team" CTA — the consultation form read as
engineering-only, which can deter non-technical enquiries.

- Form header: "Engineering Consultation" -> "Get in Touch"
- Subtitle now reassures "our team will get back to you"
- Submit button: "Request Consultation" -> "Send Request"
- Success title: "Consultation Requested" -> "Request Sent"
- Next-steps step 1: "Engineer reviews..." -> "Our team reviews..."

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 09:58:13 -05:00
davidherran 63a896b017 feat(ui): broaden footer CTA from "Contact FLUX Engineering" to "Contact FLUX Team"
Deploy to VPS / deploy (push) Has been cancelled
"Engineering" framed the CTA as technical-only and could deter non-technical
visitors (pricing, general questions, partnerships) from reaching out.

- Button label: "Contact FLUX Engineering" -> "Contact FLUX Team"
- AI trigger prompt broadened to invite any enquiry (energy savings, custom
  solution, pricing, availability, or just learning more), not only a
  technical consultation.
- DEFAULT_FOOTER ctaSubtitle softened to "Connect with our team to calculate
  your ROI, explore solutions, or simply ask a question." (the live footer
  can still override this from Site Settings in the HQ).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 09:06:04 -05:00
davidherran 673c32d0e1 feat(nginx): canonical-host guard + scanner-probe blocking
Deploy to VPS / deploy (push) Has been cancelled
Hardens the edge against the bot noise and IP-based access seen in the
production logs (raw-IP hits, SSRF probes to 169.254.169.254 / localhost /
metadata.google.internal, scans for /config/database.php, /.git-credentials,
wp-admin, etc.).

1. Canonical-host guard — default_server blocks on 80 and 443 that catch
   any Host that is NOT rf-flux.com/www.rf-flux.com and return 444 (drop).
   - Kills the redirect-to-raw-IP bug at the edge: IP requests never reach
     Next.js, so the middleware can't build an IP-based redirect.
   - Blocks SSRF probes and most bot scans before they touch the app.
   - ACME HTTP-01 still works (acme-challenge location kept on :80).
   - Legitimate traffic is unaffected: exact server_name beats
     default_server, so the rf-flux.com blocks always win.

2. Scanner-probe blocking — a regex location in the rf-flux.com server
   that returns 444 for .php/.env/.git/wp-admin/etc. This is a Next.js app
   so none of those are real; the patterns never match real assets
   (.jpg/.png/.webp/.mp4/.glb/.pdf) or app routes.

Apply with `nginx -t` then `nginx -s reload` — no rebuild, no downtime.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 08:29:39 -05:00
davidherran 8a98f88047 fix(ui): dark mode support on Team and Privacy pages
Deploy to VPS / deploy (push) Has been cancelled
The new public pages were built light-mode only, so toggling dark mode
(which adds .dark to <html>) left them unchanged. Add dark: variants
matching the rest of the site:

- team/page.tsx: page bg, eyebrow, title, description, empty state
- team/TeamGrid.tsx: card bg/border/shadow, portrait gradient, name,
  role, bio, social-link buttons (cyan accent on hover in dark)
- privacy/page.tsx: page bg, title, dates, the template-notice callout,
  section headings, body text, mailto links

Palette consistent with ApplicationClient/CaseStudyModal:
light #F5F5F7/#1D1D1F/#0066CC -> dark #050505/#fff/#00F0FF, secondary
#A1A1A6, cards #111.

Verified: build compiles, TypeScript clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:33:53 -05:00
davidherran 7c689e034e fix(session): validate SESSION_SECRET lazily, not at module load
Deploy to VPS / deploy (push) Has been cancelled
The module-level SESSION_SECRET check threw during `next build` (page-data
collection imports session.ts but has no runtime env vars), breaking the
Docker build at /hq-command/dashboard.

Move the check inside getEncodedKey() so it runs at runtime when a session
is actually created. Security is unchanged: the secret is still never
defaulted, and any attempt to mint a session without a valid 32+ char
secret throws loudly. It just no longer blocks the build.

Verified: `next build` now compiles with SESSION_SECRET unset (replicating
the Docker builder stage) — 56/56 pages generated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:14:05 -05:00
davidherran 3e0b286f1a fix(deploy): stop tracking public/team + public/branding
Deploy to VPS / deploy (push) Has been cancelled
These upload directories are owned by root on the VPS (the container
entrypoint chowns mounted volumes to 1001:1001), so `git reset --hard`
failed trying to create public/team/.gitkeep as the deploy user.

Treat them like the other upload dirs (applications, cases, news, parts,
operations-inbox, footage) — gitignored, created by the Docker volume
mount at runtime. Removes public/team/.gitkeep from tracking and adds
public/team/ + public/branding/ to .gitignore.

This unblocks clean `git reset --hard origin/main` on the VPS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:57:07 -05:00
davidherran e0399ccf3b feat(i18n): preserve English technical terms in AI translation
Deploy to VPS / deploy (push) Has been cancelled
Fixes terms like "Radio Frequency" and "solid-state" being mistranslated
into each locale. English is the master language, so brand/technical terms
must stay in English everywhere.

- New src/lib/translationGlossary.ts: a curated PROTECTED_TERMS list plus
  deterministic mask/unmask helpers. Before translation each term is
  replaced with a stable __FLUXTERM_n__ placeholder; after translation the
  placeholders are restored to their canonical English form. Preservation
  is therefore guaranteed, not left to the model's discretion.
- aiTranslator.ts now masks every field before sending, restores every
  field of every locale afterwards, and reinforces the rule in the prompt
  (explicit glossary + "keep tokens byte-for-byte"). A tolerant cleanup
  regex recovers placeholders even if the model adds stray spaces, so a
  mangled token never leaks to the public site.
- Whole-word, case-insensitive matching ("RF" in "surf" is not touched);
  longest terms masked first to avoid overlaps; casing normalised to the
  canonical brand form on restore.
- 4 new golden tests (17 total) cover round-trip, simulated translation,
  whole-word safety, and mangled-token recovery.

To extend: add terms to PROTECTED_TERMS — no other change needed.

Verified: build compiles, TypeScript clean, npm run test:ai 17/17 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:15:34 -05:00
davidherran bf8b2aa631 feat(map): connect Global Map cases to their full application case pages
Closes the disconnect where a case study opened from the 3D globe had no
path to its full write-up — you had to leave the globe, open the right
application, and hunt for it.

- CaseStudyModal: new "View full case study" CTA for real installations
  (not events / HQ). It deep-links via the locale-aware next-intl Link to
  /applications/{application}#case-{nodeId}, closes the modal, and fires a
  case_study_viewed GA event.
- ApplicationClient: on mount it reads a "#case-<id>" hash, auto-expands the
  matching case in the "Proven Solutions" wall, and smooth-scrolls to it.
  Each case row now carries id="case-<id>" + scroll-mt for correct offset.
- viewFullCase string added to the CaseStudyModal namespace in all 5 locales.

The GlobalNode.application field (already equal to the Application slug) is
the join key — no schema change needed.

Verified: production build compiles, TypeScript clean, 5 message files valid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:17:08 -05:00
davidherran afcaf991b5 feat(applications): drag-to-reorder on the public site
Editors can now control the order applications appear in, the same way they
already reorder Hero slides.

- Application gains an `order Int @default(0)` column (additive migration
  20260605120000_add_application_order, IF NOT EXISTS, safe for deploy) plus
  an (isActive, order) index.
- New reorderApplications(orderedSlugs) server action — single $transaction
  renumbering, mirrors reorderHeroSlides.
- HQ applications panel: rows are now draggable by a grip handle (HTML5 DnD,
  optimistic local reorder, persisted on drop, toast feedback).
- All public-facing queries now order by [order asc, createdAt asc]: home
  ApplicationsDashboard + GlobalOperations, the footer apps list, and the
  HQ list itself. Existing rows default to 0 so current order is preserved
  until the editor drags something.

Verified: production build compiles, TypeScript clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:04:10 -05:00
davidherran fbfffb28d9 feat(analytics): activate GA4 (G-KQ1JRV3KN7) + GDPR privacy page + GSC support
Client provided the GA4 Measurement ID and approved the standard policy.

- Activate analytics: NEXT_PUBLIC_GA_ID set to the FLUX property
  G-KQ1JRV3KN7 in the env template, with the same value as the
  docker-compose build-arg fallback so it works out of the box on deploy.
  (GA Measurement IDs are public — they ship in page HTML — safe to commit.)
- New GDPR-compliant Privacy & Cookie Policy page at /[locale]/privacy
  (all 5 locales), linked from the consent banner. Includes a clearly
  marked template disclaimer for legal review and a TODO on the contact
  email. Added to sitemap.
- Consent banner now links via the locale-aware next-intl Link.
- Google Search Console: optional NEXT_PUBLIC_GSC_VERIFICATION env var
  emits the google-site-verification meta tag (Dockerfile arg +
  docker-compose wired). Empty by default.

Verified: build inlines G-KQ1JRV3KN7 into the client bundle; the 5
/privacy routes render; TypeScript clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:00:44 -05:00
davidherran 148aefc68f feat(team): public Team page + HQ CMS panel
New "Team" section — a LinkedIn-style minimal profile page for the FLUX
team, fully editable from the HQ Command Center.

Data model:
- New TeamMember model (name, role, bio, photoUrl, optional social links:
  email/linkedin/x/website, order, isActive, translationsJson).
- Additive migration 20260602120000_add_team_member (IF NOT EXISTS guards).
- Name stays as written; role + bio are translatable via the AI engine.

HQ panel (/hq-command/dashboard/team):
- Drag-to-reorder (same HTML5 pattern as the Hero panel).
- Inline auto-save for name/role/visibility; expandable editor for photo
  upload, bio, social links, and AI auto-translate to IT/VEC/ES/DE.
- Photo upload reuses /api/assets with a new flat "team" scope -> /public/team/.
- Dashboard tile added.

Public page (/[locale]/team):
- Responsive card grid (framer-motion stagger), portrait + name + role +
  bio + social icons (only the links that exist render).
- Per-member Person JSON-LD + breadcrumb for SEO.
- Localized via getLocalizedData; new TeamPage namespace in all 5 locales.
- NavBar item "Team" inserted before "Spare Parts" (translated 5 locales).
- Added to sitemap.

Infra:
- "team" scope registered in /api/assets (SCOPE_ROOTS + FLAT_SCOPES +
  buildPublicUrl) and revalidate.ts (RevalidateScope + path).
- Nginx serves /team/ from disk; docker-compose mounts public/team in both
  app and nginx (rw + ro).

Verified: production build compiles, all 5 /[locale]/team routes + the HQ
panel render; TypeScript clean; 5 message files valid JSON.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 07:17:09 -05:00
davidherran 1ee8288c7e 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>
2026-06-02 06:53:04 -05:00
davidherran 3a94e7c003 feat(security+ai): security hardening + FluxAI conversation analytics
Security (critical):
- SESSION_SECRET fail-fast: refuse to boot without a 32+ char secret
  (src/lib/session.ts, src/app/actions/clientAuth.ts)
- Rate limit with pluggable backend: in-memory by default, auto-promotes
  to Upstash Redis when REDIS_URL is set (src/lib/rateLimit.ts)
- CSRF (double-submit HMAC) + Zod validation on /api/consultation;
  new /api/csrf endpoint mints tokens (src/lib/csrf.ts)
- escapeHtml + safeMailto helpers; consultation email template now
  fully escapes user-controlled fields (src/lib/escapeHtml.ts)
- Magic-byte validation for /api/public-upload — rejects HTML/JS
  payloads renamed to .png/.mp4 (src/lib/fileType.ts)
- Nginx: CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy,
  Permissions-Policy + 5r/m upload zone for /api/public-upload and
  /api/assets (nginx/conf.d/flux.conf)

Quality:
- Delete GlobalOperations_old.tsx dead code (310 LOC)
- NavBar: replace 2s session polling with CustomEvent("flux:session-
  changed") + visibilitychange listener (no more interval leaks)
- Type-safe CMS shapes via src/types/cms.ts (replaces any[] in
  ApplicationsDashboard + GlobalOperations)
- /api/health now pings Postgres; docker-compose healthcheck added
- Structured JSON logger (src/lib/logger.ts) — drop-in replacement
  for console.error across API routes
- Prisma indices on isActive/category/nodeType filters

FluxAI persistence + analytics:
- New models AiConversation + AiEvent with funnel stage detection
  (DISCOVERY -> QUALIFY -> RECOMMEND -> HANDOFF) and OperationsSignal
  back-ref so converted chats link to their consultation ticket
- /api/chat persists every user msg, ai msg, tool call, tool result;
  IP is sha256-hashed with SESSION_SECRET salt; promptCacheKey wired
  for when @ai-sdk/openai lands the feature
- New HQ dashboard at /hq-command/dashboard/conversations: 4 KPIs
  (total, conversion rate, avg messages, avg tools), funnel + industry
  breakdowns, last-50 table, per-id transcript with tool timeline
- SilentObserver sends sessionId/locale/pageUrl in transport body so
  the route can stitch messages into the same conversation
- src/lib/aiSessionId.ts: localStorage UUID with sessionStorage +
  in-memory fallbacks for privacy mode
- Golden tests via node --test (13 cases, no new deps);
  npm run test:ai

Migration:
- prisma/migrations/20260526180000_add_indexes_and_ai_telemetry —
  additive only, IF NOT EXISTS guards, safe for migrate deploy

env template hardened: SESSION_SECRET documented as required + how
to generate; REDIS_URL/REDIS_TOKEN documented as opt-in for multi-
instance deploys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:10:19 -05:00
davidherran 792dd6794b fix(ai): guard datasheet/gallery/videos against non-array values
Deploy to VPS / deploy (push) Has been cancelled
EquipmentConfigurator and CaseStudyViewer crashed with
"e.datasheet.find is not a function" when DB nodes had
malformed JSON in specificDatasheetJson. Both components now
normalize datasheet, gallery, and videos to safe arrays via
Array.isArray before any array method calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 09:12:55 -05:00
davidherran 7278d5d00f fix(ai): defensive navigation — auto-resolve phantom section IDs to page routes
Deploy to VPS / deploy (push) Has been cancelled
GPT-4o ignores tool description instructions and sends section:"news"
instead of url:"/news". The client now maintains a whitelist of valid
homepage DOM IDs and a fallback map (news→/news, heritage→/heritage,
parts→/parts). Any section value not in the homepage whitelist gets
auto-resolved to the correct page route via router.push.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 08:49:31 -05:00
davidherran 8941d1a2c3 feat(ai): FluxAI Level 2 — smart recommender, funnel-aware SPIN, contextual quick-replies
Deploy to VPS / deploy (push) Has been cancelled
Three improvements to the FluxAI sales intelligence:

1. New `recommend_application` tool: analyzes prospect's industry,
   problem, and process keywords against the database to rank-match
   the best FLUX products with confidence scores. Bridges the gap
   between "I have a problem" and "here's our solution."

2. Funnel-aware system prompt: replaces flat SPIN with 4-stage
   pipeline (Qualify → Recommend+Educate → Quantify+Prove →
   Specify+Convert) with clear rules for when to ask vs. act.

3. Contextual quick-reply buttons: after each AI response, dynamic
   suggestions appear based on which tools were used — guiding the
   prospect through the natural next step in the funnel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 08:36:30 -05:00
davidherran 95132476ae feat(ai): extend FluxAI navigation with cross-page routing
The navigate_to_section tool now supports two modes:
A) Same-page scroll — scrollIntoView to real homepage DOM IDs
   (technology, applications-dashboard, applications-deep, global,
   our-story, legacy)
B) Cross-page routing — router.push to /applications/{slug},
   /news, /heritage, /parts with automatic locale prefix.

Fixed: system prompt listed phantom section IDs (hero, news,
heritage, timeline, parts-catalog, contact) that don't exist in
the DOM — causing all non-homepage navigations to silently fail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 07:58:05 -05:00
davidherran c45a5be99e feat(i18n): translate hardcoded article page strings to 5 locales
Deploy to VPS / deploy (push) Has been cancelled
- Add ArticlePage namespace (backToNewsHub, backToNews, mediaGallery,
  joinLinkedIn, internalRelease) to all 5 locale message files
  (en, it, es, de, vec)
- Replace 5 hardcoded English strings in news/[slug]/page.tsx with
  getTranslations() calls

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 18:14:46 -05:00
davidherran cb7458cded feat(seo): visual breadcrumb navigation on article + application pages
- Create Breadcrumbs.tsx server component — semantic <nav> + <ol>/<li>
  with aria-current, ChevronRight separators, Apple-clean styling
- Add breadcrumbs to news article hero overlay (reuses JSON-LD crumbs)
- Add breadcrumbs to application detail hero (passed as prop to client
  component)
- Refactor breadcrumb data into shared array for JSON-LD + visual nav

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 18:10:49 -05:00
davidherran 7ec99734c5 feat(seo): LocalBusiness + CollectionPage structured data schemas
- Add localBusinessSchema() with geo coords, phone, opening hours for
  Google Local Pack and Knowledge Panel visibility
- Add collectionPageSchema() with ItemList for article listing pages
- Inject LocalBusiness alongside Organization+WebSite in root layout
- Inject CollectionPage in /news hub page with article items

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 18:06:12 -05:00
davidherran 8d80cbbc27 perf(seo): image sizes, semantic HTML, X-Robots-Tag headers
- Add `sizes` prop to 8 <Image> components across news, heritage, and
  application pages — tells the browser which srcset variant to download,
  improving LCP and reducing bandwidth
- Replace date <span> with <time dateTime={ISO}> on news pages —
  Google uses datetime for article freshness signals
- Wrap news cards and article content in <article> tags — semantic
  boundary for crawlers
- Add X-Robots-Tag: noindex, nofollow header to all /hq-command
  responses in proxy.ts — defense-in-depth alongside meta robots

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 18:04:40 -05:00
davidherran 6b9a94490b fix: add dedicated square favicon for HQ Command Center
Deploy to VPS / deploy (push) Has been cancelled
Programmatic icon.tsx using Next.js convention — generates a 32×32 PNG
with dark bg + cyan "F" glyph.  Replaces the inherited 16:9 fallback
that appeared stretched in browser tabs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 17:30:32 -05:00
davidherran c3d196df03 feat: auto-optimize images on CMS upload via AssetBucketBrowser
Deploy to VPS / deploy (push) Has been cancelled
Enables the existing Sharp pipeline for all uploads — WebP conversion,
auto-orient, 2560px cap, content-hash filenames.  Upload toast now
shows compression savings percentage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 15:21:08 -05:00
davidherran 8ac372125a feat: dedicated 3D Models bucket in AssetBucketBrowser
Deploy to VPS / deploy (push) Has been cancelled
Add a "3D Models" bucket (path: models, accept: .glb/.gltf/.usdz)
to the cases scope. Previously 3D files were mixed into the Media
bucket at the root. Now they have their own tab with proper file
type hints and purple accent color matching the AR viewer UI.

- cases/media bucket no longer lists .glb/.gltf/.usdz in accept
- New bucketHint for models bucket warns on non-3D file uploads
- Network page 3D tab updated with purple accent + "3D Models"
  button label pointing to the dedicated bucket

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 15:15:44 -05:00
davidherran f6c3b89e08 feat: dashboard operations intelligence — analytics cards + signal breakdown
Deploy to VPS / deploy (push) Has been cancelled
Add real-time operational metrics to the HQ dashboard:

- Signals This Month: count with trend % vs previous 30 days
- Pending Actions: highlights when tickets need attention (rose border)
- Email Delivery: success rate percentage + sent/failed counts
- B2B Clients: approved vs total registered

Plus a Signal Breakdown panel showing all-time distribution by type
(Orders, Diagnostics, Consultations, B2B Access) with proportional
bars. All queries run in parallel via Promise.all for minimal latency.
Wrapped in try/catch so the dashboard never breaks if DB is slow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 15:02:06 -05:00
davidherran 9c1e0cce01 feat: inbox UX polish — auto-save routing, search filter, email badges
Deploy to VPS / deploy (push) Has been cancelled
Three improvements to the Operations Inbox:

1. Auto-save email routing: typing in the routing config auto-saves
   after 800ms with inline "Saving…" / "✓ Saved" indicator. No more
   manual Save button per route.

2. Search filter: instant client-side search across name, company,
   email, and ticket ID. Composes with existing type/status filters.

3. No-email badge: signals without email delivery now show a dim
   MailQuestion icon in the list view instead of nothing, making it
   clear at a glance which tickets need attention.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 14:52:57 -05:00
davidherran 7502c9c674 feat: generateStaticParams for application + news slug pages
Deploy to VPS / deploy (push) Has been cancelled
Pre-render all known slugs at build time so first visits are instant
from cache. New slugs added after deploy render on-demand and get
cached by ISR (revalidate=60). try/catch ensures the build never
fails if the DB is unreachable during docker build — pages just
fall back to on-demand rendering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 14:39:57 -05:00
davidherran ce8a13d7f8 fix: restore ISR on public pages — isolate DYNAMIC_SERVER_USAGE root cause
Deploy to VPS / deploy (push) Has been cancelled
Root cause: next-intl's getMessages/getTranslations internally resolves
requestLocale by reading cookies/headers, which trips DYNAMIC_SERVER_USAGE
under ISR. Fixed by calling setRequestLocale(locale) in layout + every
public page — caches the locale in React cache so next-intl never reads
cookies.

Changes:
- [locale]/layout.tsx: +setRequestLocale, +generateStaticParams (5 locales),
  wrap NavigationManager in <Suspense> (uses useSearchParams)
- 5 public pages: force-dynamic → revalidate=60, +setRequestLocale
- HQ dashboard pages: unchanged (still force-dynamic for auth)

Build verified: home/heritage/news pre-render as SSG with 1m revalidation,
slug pages render on-demand with ISR cache. Nginx s-maxage=60 remains as
safety net. Zero DYNAMIC_SERVER_USAGE errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 14:06:20 -05:00
davidherran 59a146ef10 feat: HQ-wide Toast + Confirm — no more browser alert()/confirm() popups
Deploy to VPS / deploy (push) Has been cancelled
The Inbox panel got the polished Toast + Confirm primitives a few
commits back. This commit propagates them across every other panel in
HQ Command so the editor experience is uniformly on-brand. No more
1990s browser dialogs interrupting the dark CMS look.

NINE PANELS, ELEVEN CALL SITES UPGRADED
- health/page.tsx       — DB export errors → toast
- network/page.tsx      — Delete deployment → confirm + toast
- heritage/page.tsx     — Delete section → confirm + toast
- users/page.tsx        — Revoke architect → confirm + error toast
- news/page.tsx         — Delete article → confirm + toast
- applications/page.tsx — Save error toast + delete-app confirm
- parts/page.tsx        — Delete component → confirm + toast
- hero/page.tsx         — Delete slide → confirm + toast
- timeline/page.tsx     — Delete milestone → confirm + toast

Each destructive confirm now spells out what it does ('Permanently
remove this case from the global map. The asset folder on disk is
kept for safety') instead of a generic 'Delete?' prompt — much
clearer for editors who aren't sure whether files get nuked too.

Each success toast names the action ('Component deleted', 'Slide
deleted', 'Architect access revoked') so the editor sees exactly
what fired. Errors come in as red toasts with the actual error text.

NO BACKEND CHANGES. Pure UX layer on top of existing actions.
The HqUiProvider was already mounted in src/app/hq-command/layout.tsx,
so wiring up was just useHqUi() per page + the replacement calls.
2026-05-05 21:16:02 -05:00
davidherran e177bca92f feat: HQ Hero — import existing /footage/main files as managed slides
Deploy to VPS / deploy (push) Has been cancelled
The hero CMS only listed HeroSlide rows from the database. Files dropped
directly into /public/footage/main were rendering on the live site (via
the home page's filesystem fallback) but invisible in the editor — so
the editor couldn't manage their focal point, alt text, ordering or
on/off toggle without re-uploading.

NEW SERVER ACTIONS (src/app/hq-command/dashboard/hero/actions.ts)
- listImportableFootage()
  Scans /public/footage/main, returns the list of files that aren't
  already referenced by a HeroSlide row. Each entry has filename,
  publicUrl, mediaType (image/video), file size and mtime.
- importFootageFiles(filenames[])
  For each selected filename: validates extension, checks existence,
  skips already-imported files, derives a sensible alt text (filename
  with extension stripped, dashes/underscores → spaces, leading
  numeric prefix removed), and creates a HeroSlide row at the next
  available order position. Returns {created, skipped} so the UI can
  show a precise toast.

UI (src/app/hq-command/dashboard/hero/page.tsx)
- New amber-tinted panel above the slide list, visible only when
  importable.length > 0. Shows every uncovered file as a thumbnail
  card with checkbox-style selection.
- 'Select all' / 'Clear' toggle, 'Import all' for one-click bulk
  import, 'Import N selected' once a subset is picked.
- After import: panel re-renders empty (since those files now have
  HeroSlide rows) and the new slides appear in the regular list,
  ready for focal-point and caption editing.

NO BACKEND-DATA CHANGES BEYOND ROW CREATION
- Filesystem untouched (no rename, no move). Files keep their original
  path under /public/footage/main, the new HeroSlide row simply points
  at /footage/main/<filename>.
- Public-facing pages render the same images either way (they read
  from HeroSlide first, footage scan second).
- After import: the home page now reads the slides from the DB and
  uses the focal-point + alt text data the editor sets.
2026-05-05 20:05:29 -05:00
davidherran 778b35f15a feat: AssetBucketBrowser polish — bulk select, drag-move, rename in place
Deploy to VPS / deploy (push) Has been cancelled
The unified bucket browser graduated from "acceptable" to "actually
useful for bulk work". Editors can now manage dozens of files in a
single session without dragging each one through a modal.

NEW FEATURES (frontend)

1. BULK SELECTION
   - Click on a file when nothing's selected → opens it as before.
   - Click on the corner checkbox, or click the file once selection is
     active → toggle that one in/out.
   - Shift-click → range select between last anchor and current item.
   - Cmd/Ctrl-click → toggle without affecting others.
   - "Select all" toggle in the toolbar respects the search filter.

2. BULK ACTIONS TOOLBAR
   When at least one file is selected the toolbar morphs into:
     [N selected] [Delete] [Move to: Videos | Renders | …]
   Delete fires the new bulk DELETE endpoint with filePaths[], shows
   a single toast for the whole batch + per-file failure breakdown.
   Move iterates PATCH /api/assets per file (sequential, with a 'Moving…'
   indicator in the bucket helper bar).

3. DRAG TO MOVE BETWEEN BUCKETS
   Drag any file (or the whole selection if you started the drag from
   a selected file) onto another bucket tab. The tab highlights green
   with 'drop to move' while you hover. Drop fires the same per-file
   PATCH flow. No dialog, no friction.

4. RENAME IN PLACE
   Double-click a filename (in either grid or list view) → input opens
   in place. Enter saves, Escape cancels, blur saves. Sanitizes to
   safe characters. PATCH endpoint refuses to overwrite an existing
   file (returns 409, surfaced as a toast).

5. KEYBOARD HINT FOOTER
   Bottom-of-modal cheat sheet: Click / ⇧Click / ⌘Click / 2× click /
   drag onto another tab. So new editors don't have to discover the
   power-user features.

NEW BACKEND (src/app/api/assets/route.ts)

PATCH method
   { scope, slug, fromPath, toPath } → fs.renameSync.
   Used for both rename (same dir, new name) and move (different bucket).
   Refuses to overwrite an existing destination (409 conflict).
   Creates intermediate folders if needed.

DELETE extended
   Now accepts either { filePath: "x" } or { filePaths: ["a", "b"] }.
   Bulk path deletes one-by-one and returns per-file success/failure
   so the UI can show a precise toast.

REVIEWED FOR REGRESSIONS
- Single-file API still works — old { filePath } DELETE shape preserved.
- The 4 inline AssetManager call sites (network, news, applications,
  parts) use AssetBucketBrowser via the alias added in the previous
  commit; their integration is unchanged. Same props, same onSelect
  callback shape.
- Toast/Confirm calls go through the existing HqUiProvider mounted in
  hq-command/layout.tsx — no extra wiring.
2026-05-05 19:40:06 -05:00
davidherran 330fecc3cc feat: favicon multi-variant — 1 upload generates 6 sizes + PWA manifest
Deploy to VPS / deploy (push) Has been cancelled
The single-favicon upload was leaving most platforms with the wrong icon:
Android Chrome looks for 192×192, iOS for 180×180, Windows for 48×48,
HiDPI tabs for 32×32, legacy tabs for 16×16, PWA splash for 512×512 —
six different files, all from the same source image.

ONE EDITOR ACTION → SIX FILES
- POST /api/branding/favicon takes a single PNG/JPG/WebP (≥ 192×192,
  ideally 512×512+) and runs it through sharp to produce:
    /branding/favicon-16.png    /branding/favicon-32.png
    /branding/favicon-48.png    /branding/favicon-180.png
    /branding/favicon-192.png   /branding/favicon-512.png
    /branding/favicon-master.png  (kept for re-generation)
- Square-ish source images get center-cropped automatically.
- Returns the variant list + soft warnings ("not square — center-cropped",
  "upload at least 512×512 for retina") so the editor sees what happened.
- Validates dimensions and file type, caps at 20MB.

ROOT LAYOUT (src/app/[locale]/layout.tsx)
- Detects whether the multi-variant set exists on disk. If yes, emits
  <link rel="icon" sizes="16x16 / 32x32 / 192x192">, an explicit
  Apple touch link, and a Safari pinned-tab mask-icon. If no, falls
  back to the legacy single faviconUrl from SiteSetting.
- Adds <link rel="manifest" href="/manifest.webmanifest"> so Android
  Chrome surfaces "Add to Home Screen" properly.

PWA MANIFEST (src/app/manifest.ts)
- Auto-served at /manifest.webmanifest via Next.js's MetadataRoute.
- Pulls themeColor from SiteSetting.branding so editor changes there
  cascade to the standalone-app theme.
- Lists 192 + 512 icons with both 'any' and 'maskable' purposes for
  Android adaptive icons.

HQ SETTINGS UX (src/app/hq-command/dashboard/settings/page.tsx)
- New <FaviconMasterField/> at the top of the Branding tab — replaces
  the old standalone Favicon ImageField. Shows a 32×32 preview, an
  upload button, the generation result with each variant's preview,
  and warnings when the source isn't ideal.
- The Apple Touch Icon ImageField below it stays as an override path
  for editors who want a different icon on iOS (rare, but supported).
- Cache buster query string on previews so re-uploads visibly refresh
  without forcing the editor to hard-reload.

NO BACKEND/DB CHANGES OUTSIDE FILE I/O.
The SiteSetting.branding row is unchanged. The favicon variants are
plain files on disk under public/branding/, served by Nginx with the
existing 5-min cache headers.
2026-05-05 19:07:16 -05:00
davidherran ea1300bfdc feat: HQ Toast + Confirm dialog primitives, replace browser alert()/confirm()
Deploy to VPS / deploy (push) Has been cancelled
The Operations Inbox panel was using browser-native alert() and
confirm() — those popups break out of the dark-themed CMS aesthetic
and look like a 1998 form validation. Worse, they're modal blocking,
so the editor can't see the surrounding context (the ticket card,
the activity feed) while the dialog is up.

NEW PRIMITIVES (src/components/hq/Toast.tsx)
- <HqUiProvider> mounts a global toast stack (bottom-right) and a
  global confirm dialog. Mounted in src/app/hq-command/layout.tsx so
  every panel under /hq-command/* can use it.
- useHqUi() returns { toast, confirm }:
    ui.toast("Saved", "success")        // ephemeral, 3s
    ui.toast("Save failed: ...", "error")  // 5s
    await ui.confirm({                     // returns boolean
      title: "Delete ticket",
      message: "This permanently...",
      confirmLabel: "Delete",
      destructive: true,
    })
- Toasts auto-dismiss with a manual close button. Confirm dialog
  uses red accent for destructive actions.
- Zero deps. ~140 lines total.

INBOX (src/app/hq-command/dashboard/inbox/page.tsx)
- All 5 alert() calls replaced with ui.toast() — success / error
  toned and persisting just long enough to read.
- All 4 confirm() calls replaced with ui.confirm() — destructive
  ones (delete ticket, purge files, delete client) get the red
  accent + 'cannot be undone' copy.
- Action descriptions are richer ('Resolve & purge attachments'
  instead of 'Resolve') so the editor knows exactly what fires.

NO BACKEND CHANGES. Pure UX layer on top of the existing actions.
2026-05-05 19:02:41 -05:00
davidherran aebcabd767 fix: nginx cache ignores Set-Cookie + adds explicit valid 60s
Deploy to VPS / deploy (push) Has been cancelled
The HTTP cache was always reporting MISS on /en even on consecutive
hits. Two reasons converging:

1) next-intl writes a NEXT_LOCALE cookie on response, so every
   upstream reply included a Set-Cookie header. Nginx refuses to
   cache responses with Set-Cookie by default — that's a safe
   default to avoid leaking session cookies, but it's the wrong
   default for our public marketing pages, where the cookie just
   records a locale preference and the HTML body is identical for
   every visitor on the same URL.

2) proxy_cache_valid wasn't set, so even when Cache-Control would
   have authorised caching, Nginx fell back to its conservative
   no-cache stance.

Fix:
- proxy_ignore_headers Set-Cookie X-Accel-Expires Expires;
- proxy_hide_header Set-Cookie;
- proxy_cache_valid 200 60s;

Net result: marketing pages now actually cache. The Set-Cookie is
still emitted by Next.js (the upstream is unchanged), Nginx just
strips it before storing/relaying — locale detection still works
because next-intl persists locale through the URL prefix anyway.

DEPLOY (David)
  cd /opt/flux-srl
  git pull
  docker compose restart nginx
  docker compose exec nginx nginx -s reload

Then verify:
  curl -sI https://rf-flux.com/en | grep -iE 'x-cache|cache-control|set-cookie'
  curl -sI https://rf-flux.com/en | grep -iE 'x-cache|cache-control|set-cookie'

Second hit should show: x-cache-status: HIT
2026-05-05 13:48:43 -05:00
davidherran 1a4abfc7f2 nginx: include lethepowerflux.com → rf-flux.com 301 redirect
Deploy to VPS / deploy (push) Has been cancelled
The VPS already had a server block redirecting lethepowerflux.com and
www.lethepowerflux.com to https://www.rf-flux.com, but it lived only on
the live config — not in git. That's why the latest pull complained
about local changes that would be overwritten.

Adding it here so the repo is the single source of truth for the Nginx
config again. Behaviour is unchanged on the VPS (redirect was already
in place) — this commit just lets future git pulls flow without
manual intervention.
2026-05-05 13:17:34 -05:00
davidherran abd75798ef feat: timeline + heritage HQ panels — drag-drop + inline auto-save
Deploy to VPS / deploy (push) Has been cancelled
Bringing the same UX pattern we shipped on Hero and Settings to the
Company Legacy and Our Heritage panels. No more modal-driven editing
where you have to click "Edit" → modify → click "Save" → wait → close
modal. Every field is now editable in place; saves fire on blur and
flash a "Saved ✓" confirmation; rows reorder via drag-drop.

NEW SERVER ACTIONS (additive — old formData-style actions still exist)
- timeline/actions.ts:
    patchTimelineEvent(id, partial)        — granular field update
    reorderTimelineEvents(ids[])           — single transaction
    createTimelineStub()                   — instant blank row
- heritage/actions.ts:
    patchHeritageSection(id, partial)      — granular field update
    reorderHeritageSections(ids[])         — single transaction
    createHeritageStub(type)               — text/image/video stubs

PAGES REWRITTEN
- timeline/page.tsx: 208 → 215 lines, but radically simpler — drag
  handle + inline year input + inline title + inline description
  textarea per row, plus eye toggle for isActive and a trash button.
  Global "Auto-translate edits" checkbox at top applies to every patch.
- heritage/page.tsx: 209 → 270 lines (more functionality fits in less
  cognitive load). Three coloured "Add" buttons (Text / Image / Video)
  spawn a stub that renders the right card type. Image/Video cards have
  a built-in MediaPicker with upload-or-paste-filename + live preview.

UX UPGRADES THE EDITOR SEES
- Type-and-tab to save. No modals, no submit buttons per row.
- Saving / Saved ✓ badge per row so it's obvious when a field has
  hit the database.
- Drag handle + drop target on every card → reordering becomes
  "grab and pull", same metaphor as Hero slides.
- AI translation is one toggle at the top, not a per-modal switch.
- Soft-warning empty state with a clear "do this first" hint instead
  of a blank page.

NO BREAKING CHANGES
- Old createTimelineEvent / updateTimelineEvent / createHeritageSection
  / updateHeritageSection actions are kept (in case anything still
  imports them — nothing in the repo does, but external scripts
  might). Internal call sites all use the new patch flow.
- Database schema unchanged.
- Public-facing /heritage page unchanged.
2026-05-05 12:28:57 -05:00
davidherran 7fe5108f66 feat: HTTP shared cache for public marketing pages
Deploy to VPS / deploy (push) Has been cancelled
Pages got fast again. Public marketing routes are still rendered
per-request by Next.js (force-dynamic, until the ISR bug gets isolated),
but their HTML is now cached at the Nginx layer for 60s with a 5-minute
stale-while-revalidate window. Result: only the first hit on a URL
inside a 60s window pays the SSR cost; every other visitor in that
window gets a sub-10ms cached response. While a cached entry is
revalidating, peers keep getting the stale copy — no cold starts, no
thundering herds.

NEXT.JS MIDDLEWARE (src/proxy.ts)
- isCacheablePublicPath() identifies routes safe to share-cache:
  /, /<locale>, /<locale>/applications, /<locale>/news,
  /<locale>/heritage. Excludes /<locale>/parts (auth-gated B2B portal)
  and /hq-command/*, /api/*, /_next/*.
- hasAuthCookie() short-circuits caching when the request carries a
  flux_session (admin CMS) or flux_b2b_session (client portal) cookie.
  Authenticated users always get a fresh per-account render.
- When both checks pass, the response gets:
    Cache-Control: public, s-maxage=60, stale-while-revalidate=300

NGINX (nginx/nginx.conf)
- New shared zone:
    proxy_cache_path /var/cache/nginx/flux levels=1:2
                     keys_zone=flux_html:50m max_size=1g inactive=24h
                     use_temp_path=off;
- Access log gets a `cache=$upstream_cache_status` field so we can
  audit hit/miss ratios in the live logs.

NGINX (nginx/conf.d/flux.conf — location /)
- proxy_cache flux_html + proxy_cache_revalidate on
- proxy_cache_use_stale: serves stale on backend errors / timeout /
  during update, so 502s during a Next.js restart never reach users.
- proxy_cache_background_update + proxy_cache_lock: only one upstream
  request fires when a cached entry expires; others keep getting stale.
- proxy_cache_bypass / proxy_no_cache wired to flux_session +
  flux_b2b_session cookies — admin and B2B traffic skips the shared
  cache entirely.
- X-Cache-Status response header (HIT/MISS/EXPIRED/STALE/UPDATING/BYPASS)
  for live debugging — open dev tools, refresh, watch the value flip.

WHAT YOU'LL FEEL
- First visitor on /en within a 60s window: ~150-300ms (SSR + DB).
- Second through Nth visitors in the same window: <10ms.
- Editor publishes a change in HQ Command → revalidatePath() inside
  the existing actions invalidates the Next.js cache; the next
  marketing-page request rebuilds and primes Nginx fresh. The 60s
  TTL bounds how long stale content can linger if revalidation is
  ever skipped.

NO BREAKING CHANGES
- Auth flows untouched (cookies bypass cache).
- HQ Command + API endpoints untouched (separate Nginx locations).
- Static assets (cases/, applications/, /branding/, /_next/static)
  unaffected — they had their own cache headers already.
- Server-side cache invalidation via revalidatePath() still works.

DEPLOY (David)
  cd /opt/flux-srl
  git pull
  docker compose up -d --build app
  docker compose exec nginx nginx -t
  docker compose exec nginx nginx -s reload
2026-05-05 12:20:39 -05:00
davidherran fece168486 fix: FluxAI image paths now include the node slug
Deploy to VPS / deploy (push) Has been cancelled
The two AI tool cards rendered inside the chat — CaseStudyViewer (the
"Show me proven installations" card) and EquipmentConfigurator (the
"Show equipment specs" card) — were composing image URLs without the
node-slug segment:

  /cases/<filename>            ← what the cards were emitting (404)
  /cases/<nodeSlug>/<filename> ← what the public site actually serves

Result: cover images and gallery thumbnails inside chat cards came back
as broken-image icons, while the same files rendered fine on
/en/applications/<slug> and inside the CaseStudyModal (those
already used the correct path).

Fix: both components now derive the slug from data.title with the same
nodeToSlug() the public pages use, and prefix it on every /cases/
URL — cover and gallery thumbnails alike.

CHANGES (2 files)
- src/components/ai/CaseStudyViewer.tsx
  - Added local nodeToSlug() helper (mirrors ApplicationClient + assetFolders)
  - coverSrc:        /cases/${nodeSlug}/${mediaFileName}
  - gallery image:   /cases/${nodeSlug}/${img}

- src/components/ai/EquipmentConfigurator.tsx
  - Added local nodeToSlug() helper
  - coverSrc:        /cases/${nodeSlug}/${mediaFileName}

No backend / API / DB changes. Pure client-side path correction.
2026-05-05 09:25:45 -05:00
davidherran 014a9eb094 refactor: unified AssetBucketBrowser replaces 4 inline AssetManagers
Deploy to VPS / deploy (push) Has been cancelled
Every HQ Command panel had its own ~210-line AssetManager component
copy-pasted into the page file. Same UI, same API, four diverging
implementations — and no consistent metaphor for "where does this file
go?". Editors had to think about subfolder names (videos/, renders/)
that the front-end implicitly expects.

ONE COMPONENT. CLEAR BUCKETS. SAME PATHS.

src/components/hq/AssetBucketBrowser.tsx — the single picker. Takes
scope + slug, shows bucket tabs (Media / Videos / Renders / etc.) and
maps each to the on-disk path the public site already reads from:

  cases:        Media (root) | Videos (/videos) | Renders (/renders)
  applications: Media (root) | Videos (/videos) | Renders (/renders)
  news:         Media (root)
  parts:        Media (root) | Renders (/renders)
  footage:      Hero Reel (root)
  branding:     Brand Assets (root)

Drop a file into the Videos tab → POSTs to /api/assets with path=videos
→ lands at /public/{scope}/{slug}/videos/<file> — exactly where
ApplicationClient.tsx and CaseStudyModal.tsx already look. Zero
front-end path changes, zero data migration.

UX upgrades the editor sees:
- Tabs make the bucket layout discoverable instead of buried in folder
  navigation. Each tab has its own description and accent colour.
- Soft-warning hints flag obvious mismatches ("Videos bucket usually
  holds .mp4 — this file may not display correctly") without blocking
  the upload.
- Search + grid/list views.
- Hover actions per file: copy URL, delete (with confirm).
- Persistent on-screen path (/{scope}/{slug}/{bucket}) so editors can
  always see the canonical location.

REPLACEMENT (4 page files)
- network/page.tsx:      719 → 513 lines (-206) — direct alias
- news/page.tsx:         484 → 313 lines (-171) — direct alias
- applications/page.tsx: 555 → 433 lines (-122) — adapter wraps the
  picker's onSelect into the markdown-syntax onInsert callback this
  panel uses. No call-site changes.
- parts/page.tsx:        413 → 320 lines  (-93) — direct alias

Net: -592 lines of duplicated UI, +560 lines of single shared component.
Future bucket-layout changes live in one file instead of four.

NO PATH/API CHANGES — /api/assets is unchanged. The on-disk layout is
unchanged. Existing assets keep rendering on the public site. Existing
DB rows (mediaFileName, galleryJson, videosJson, rendersJson) are
unaffected because we never moved files.
2026-05-05 08:55:20 -05:00
davidherran 9b28f8ffaf fix: nextjs primary group + auto-create asset folders on entity create
Deploy to VPS / deploy (push) Has been cancelled
THREE INTERLOCKING FIXES so editors stop hitting permission walls.

1) DOCKERFILE — gid 65533 (nogroup) on uploaded files
The container was creating files as 1001:65533 because Alpine's
`adduser --system --uid 1001 nextjs` doesn't set a primary group.
Files written through /api/assets ended up with `nogroup` ownership,
which surprised host sysadmins and made `chown -R 1001:1001` revert
on each fresh container start.

Fix: `adduser --system --uid 1001 --ingroup nodejs nextjs`. Now
every file written by the container is 1001:1001 (nextjs:nodejs),
matching the host conventions and the existing chown automation.

2) ENTRYPOINT — recursively normalise existing files
The recursive chown in scripts/docker-entrypoint.sh now sweeps every
subfolder of /app/public/branding|footage|applications|cases|news|
parts|operations-inbox|heritage on each container start, fixing any
files that previously slipped through with the wrong group. Single
fast pass, idempotent. Adds /app/public/heritage to the list (was
missing).

3) AUTO-CREATE ASSET BUCKETS on entity create
The big editor UX win: when an admin creates a Case (GlobalNode), an
Application or a News article in HQ Command, the server now also
mkdir's the well-known asset subfolders for that entity. So after
creating "Acme Industries" as a case, the editor immediately gets
/public/cases/acme-industries/{videos,renders,gallery,datasheet,models}
ready — no more "EACCES because the dir wasn't created" gotcha
when they upload their first video.

Implementation:
- src/lib/assetFolders.ts: typed helper with per-scope bucket lists
  + a titleToSlug helper that mirrors the front-end's slugger so the
  folder name matches what ApplicationClient expects when rendering
  /cases/<slug>/videos/<file>.
- network/actions.ts: createNode -> ensureAssetFolders("cases", slug).
  Plus a new server action ensureNodeAssetFolders(id) so the editor
  can fix existing nodes without recreating them (one-click "Repair").
- news/actions.ts: createNewsArticle -> ensureAssetFolders("news",slug)
- applications/actions.ts: createApplication -> ensureAssetFolders(...)

DEPLOY (David)
  cd /opt/flux-srl
  git pull
  docker compose up -d --build app
  # The entrypoint will fix existing 1001:65533 files automatically
  # as the container boots — no manual chown needed.
2026-05-05 08:01:45 -05:00
davidherran aa95be45d0 fix: stop ignoring scripts/ in .dockerignore
Deploy to VPS / deploy (push) Has been cancelled
The new docker-entrypoint.sh lives in scripts/, but .dockerignore was
excluding the whole folder from the build context — so the COPY in the
Dockerfile resolved to a missing file:

  COPY scripts/docker-entrypoint.sh: not found

Removing the `scripts/` line lets the build context include it. The
other ignored paths stay (node_modules, .next, .git, env files,
Dockerfile, docker-compose, nginx, certbot, prisma dev.db) — those
genuinely don't need to be sent to the daemon.
2026-05-04 18:29:18 -05:00
davidherran ba002ea9e6 fix: auto-chown mounted volumes + metadataBase warning
Deploy to VPS / deploy (push) Has been cancelled
THREE FIXES IN ONE SHOT.

1. UPLOAD EACCES (the crashing one)
The /app/public/branding upload was failing with EACCES because the
folder on the host was created by `debian` (uid 1000) but the container
runs as nextjs (uid 1001). Docker bind mounts preserve host ownership,
so the container couldn't write into branding/.

Fix: introduce a docker-entrypoint.sh that runs the container briefly
as root, chowns every public/* mount to uid 1001, runs Prisma migrate
deploy, then drops to nextjs via `su-exec`. From now on every deploy
self-heals permissions across all asset folders (branding, footage,
applications, cases, news, parts, operations-inbox) — even if a future
volume gets added with the wrong owner.

Dockerfile changes:
- Adds `su-exec` package (lightweight gosu equivalent for Alpine)
- Removes the static USER directive (entrypoint manages user transitions)
- Replaces CMD with an ENTRYPOINT pointing at the new script

2. metadataBase WARNING
Server logs were emitting:
  ⚠ metadataBase property in metadata export is not set ... using "http://localhost:3000"
That's the layout's generateMetadata not declaring metadataBase, so
Next.js couldn't resolve relative OG/Twitter image URLs to absolute
ones. Reading NEXT_PUBLIC_APP_URL (already set in docker-compose env)
and feeding it as `metadataBase: new URL(...)` silences the warning
and produces correct absolute URLs in social previews.

3. PERMISSIONS DOCS
The entrypoint chown is idempotent and silent on non-existent folders,
so future volumes added to docker-compose just work. No more "did you
sudo chown the new folder" gotchas.

DEPLOY (David)
  cd /opt/flux-srl
  # one-time fix for the existing branding folder so the next deploy
  # doesn't have to chown 65MB of data — but the entrypoint now handles
  # this automatically anyway:
  sudo chown -R 1001:1001 /opt/flux-srl/public/branding
  git pull
  docker compose up -d --build app
2026-05-04 18:17:39 -05:00
davidherran 1f4a95cc47 fix: revert ISR to force-dynamic + drop generateStaticParams (DYNAMIC_SERVER_USAGE)
Deploy to VPS / deploy (push) Has been cancelled
The DYNAMIC_SERVER_USAGE errors persisted even after passing locale
explicitly to next-intl. Some other Server Component in the tree is
still triggering an implicit dynamic API read under ISR — and chasing
it across next-intl, Prisma, the @ai-sdk libs, and the standalone
build was eating the deploy. Pragmatic call: stop trying to keep ISR
while we still have unstable bug surface, take the runtime back to
puro SSR (the working state from before the SEO commit), then bring
ISR back surgically once the site is stable.

CHANGES (5 page.tsx files)
- /[locale]/page.tsx                         revalidate=60 → dynamic="force-dynamic"
- /[locale]/news/page.tsx                    revalidate=60 → dynamic="force-dynamic"
- /[locale]/news/[slug]/page.tsx             revalidate=60 → dynamic="force-dynamic"
- /[locale]/heritage/page.tsx                revalidate=60 → dynamic="force-dynamic"
- /[locale]/applications/[slug]/page.tsx     revalidate=60 → dynamic="force-dynamic"

ALSO: removed generateStaticParams from news/[slug] and applications/[slug].
With it present (even returning [] in prod), Next.js still classified
those routes as SSG-eligible, which conflicted with the force-dynamic
flag and kept the ISR/dynamic boundary ambiguous. Removing it makes
the build output show all locale routes as ƒ (Dynamic) — pure SSR.

WHAT WE KEEP
- generateMetadata still runs per request, so all SEO benefits (canonical
  URLs, hreflang, OG tags, Twitter cards) remain.
- sitemap.xml and robots.txt are unaffected.
- JSON-LD still emits.
- revalidatePath() in /api/assets still works (just becomes a no-op for
  these pages since they're already dynamic — no cache to invalidate).
- Caching at the Nginx layer (max-age=300 + must-revalidate on /_next/image
  and /branding|/cases|/applications|/news|/parts|/footage) is unchanged,
  so static asset performance stays optimal.

WHAT WE LOSE TEMPORARILY
- Page HTML is generated on every request instead of every 60 seconds.
  At Flux's traffic levels this is negligible — Prisma queries are sub-50ms
  and Postgres has connection pooling. We'll move back to ISR once we've
  isolated the offending dynamic read.

DEPLOY (David — IMPORTANT, force a real rebuild this time)
  cd /opt/flux-srl
  git pull
  docker compose build --no-cache app
  docker compose up -d app
  docker compose logs app --tail=30
2026-05-04 17:38:59 -05:00
davidherran e879016879 fix: pass locale explicitly to next-intl helpers (DYNAMIC_SERVER_USAGE)
Deploy to VPS / deploy (push) Has been cancelled
Production logs were spamming "DYNAMIC_SERVER_USAGE" errors and every
ISR-cached page (home, news, heritage, applications, news/[slug]) was
500-ing. Root cause: when force-dynamic was replaced with revalidate=60
last commit batch, the implicit locale-resolution path through
next-intl's getTranslations() / getLocale() / getMessages() became a
problem. Without an explicit locale arg, next-intl reads cookies()
under the hood — which trips Next.js's "dynamic API used inside a
static / ISR render" guard and aborts with DYNAMIC_SERVER_USAGE.

Fix: pass the locale (already known from the URL via params) every
time we call next-intl on the server.

CHANGES
- src/components/layout/Footer.tsx
  - Now accepts `locale` as a prop instead of awaiting getLocale()
  - getTranslations({ locale, namespace: "Footer" })
- src/app/[locale]/layout.tsx
  - Passes locale prop to <Footer locale={locale} />
  - getMessages({ locale }) instead of getMessages()
- src/components/sections/PatrizioLegacy.tsx
  - Accepts `locale` prop, passes it to getTranslations
- src/app/[locale]/page.tsx
  - Renders <PatrizioLegacy locale={locale} />
- src/app/[locale]/news/page.tsx (body)
  - getTranslations({ locale, namespace: "NewsHub" })
- src/app/[locale]/heritage/page.tsx (body)
  - getTranslations({ locale, namespace: "HeritagePage" })

The generateMetadata / generateStaticParams paths in news/[slug] and
heritage already passed locale correctly — only the page bodies and
shared components were leaking dynamic reads.

Net effect: all five ISR pages render statically again, the error spam
in `docker compose logs app` clears, and /en/applications/digital-print
loads its server component without falling through to the error boundary.
2026-05-04 16:53:20 -05:00
davidherran 3d066fa67e fix: error boundaries + defensive try/catch on dynamic pages
Deploy to VPS / deploy (push) Has been cancelled
The /en/applications/digital-print page was still 500-ing after the
previous fixes. Without an error boundary, Next.js shows a generic
"Internal Server Error" with no detail — making remote diagnosis
require a `docker compose logs` round-trip every time.

ERROR BOUNDARIES (visible diagnostics)
- src/app/global-error.tsx: catches errors that bubble past every
  route's error.tsx, including ones from the root layout. Renders
  its own <html>/<body>.
- src/app/[locale]/error.tsx: locale-scoped boundary so the NavBar
  and Footer keep rendering around the error UI. Shows the actual
  error message + digest in a code block — much faster to diagnose
  than a blank 500.

DEFENSIVE WRAPPING (every async + every transform)
- applications/[slug]/page.tsx
  - getApplicationImages: try/catch around fs ops
  - generateMetadata: full body wrapped, falls back to safe defaults
  - getLocalizedData call wrapped (returns rawData if it throws)
  - Cases query already had try/catch — adds same for the locale map
  - JSON-LD build wrapped, falls back to empty array (still renders)
  - Default fallbacks for title/description/category to avoid
    productSchema receiving undefined fields
- news/[slug]/page.tsx
  - prisma.newsArticle.findUnique now has try/catch
  - getLocalizedData wrapped
  - JSON-LD build wrapped, only rendered if non-empty
  - publishedAt / updatedAt fallback to new Date() to avoid
    "Invalid time value" from articleSchema's date conversion

The combination means: if the underlying bug is in any of the SEO
helpers, JSON-LD generation, or i18n merging, the page now degrades
gracefully and shows the actual error in the UI instead of 500-ing.
2026-05-04 16:45:37 -05:00
davidherran 62506f10b4 fix: strip internal container port from redirect URLs
Deploy to VPS / deploy (push) Has been cancelled
The site was redirecting / -> https://rf-flux.com:3000/en, where :3000
is the container's internal port (only "expose"d, not published) — so
the browser saw ERR_CONNECTION_REFUSED.

Root cause: when running behind Nginx in standalone mode, Next.js (via
next-intl in this case) can build absolute redirect URLs that leak the
container's internal PORT/HOSTNAME env into the Location header.

TWO LAYERS OF DEFENCE
1. Nginx (nginx/conf.d/flux.conf)
   - Adds X-Forwarded-Host + X-Forwarded-Port so the upstream knows
     the public port (443) and host
   - proxy_redirect rewrites any Location header that still slips
     through with :3000 back to the public https://$host

2. Middleware (src/proxy.ts)
   - sanitizeRedirectLocation() runs after handleI18nRouting and
     scrubs Location headers that point at internal hostnames (app /
     localhost / 0.0.0.0) or the container port :3000, replacing them
     with the public host derived from x-forwarded-host / host header.

Either layer alone would fix the immediate symptom; together they
also prevent the same class of bug from showing up in any future
redirect path.
2026-05-04 16:32:45 -05:00
davidherran 5abd3a02f6 fix: ship full prod node_modules to runner so prisma migrate deploy works
Deploy to VPS / deploy (push) Has been cancelled
The previous container restart loop ("Error: Cannot find module 'effect'")
happened because the runner stage cherry-picked only specific Prisma
subdirs (.prisma, @prisma, prisma) but missed transitive runtime deps
of the Prisma CLI — like @prisma/config's dep on `effect`.

Cherry-picking is fragile: any minor Prisma upgrade changes the
required dep set and the container stops booting.

Real fix: introduce a dedicated prod-deps stage that runs
`npm ci --omit=dev --include=optional` and ship the resulting
node_modules wholesale to the runner. Trade-off: the runner image
grows by ~200-300MB, gaining bullet-proof prod CLI execution in
exchange. Subsequent rebuilds are fully cached after the first run.

What changed in Dockerfile:
- New stage `prod-deps` produces a prod-only node_modules tree
- Runner stage drops the explicit @prisma/prisma/sharp/@img copies
  (they're already in prod-deps' node_modules)
- Still copies prisma/, prisma.config.ts, .prisma generated client,
  and Next.js standalone artefacts
- CMD unchanged: migrate deploy + server.js
2026-05-04 16:22:47 -05:00
davidherran 320c0862df fix: pin all sharp platform binaries so npm ci works on any host
Deploy to VPS / deploy (push) Has been cancelled
The previous attempts (--include=optional, then a separate npm install
fallback) failed because npm ci runs sharp's install script DURING
installation — and that script crashes ("Please add node-gyp to your
dependencies") before the next Dockerfile step gets to run.

Real fix: pin every sharp platform binary as an optionalDependency in
package.json. npm now records URL+hash for all of them in the lock
file regardless of which OS generated the lock. On any build host,
npm ci picks the matching binary via the os/cpu/libc filters in those
packages and silently skips the rest.

Pinned binaries (sharp 0.34.5):
- @img/sharp-linuxmusl-x64   (Alpine x64 — our VPS)
- @img/sharp-linuxmusl-arm64 (Alpine arm64)
- @img/sharp-linux-x64       (glibc x64)
- @img/sharp-linux-arm64     (glibc arm64)
- @img/sharp-darwin-arm64    (Apple Silicon dev)
- @img/sharp-darwin-x64      (Intel Mac dev)

Side benefit: simplifies the Dockerfile. Drops the secondary
`npm install --no-save --cpu=x64 --os=linux --libc=musl sharp` step
and the vips-dev system package (no source compilation needed when
the prebuilt binary is guaranteed present). The runner stage still
needs `vips` runtime, that stays.
2026-05-04 15:52:22 -05:00
davidherran 4f75943317 fix: force sharp Alpine binary download in Docker build
Deploy to VPS / deploy (push) Has been cancelled
Sharp 0.34 ships a separate prebuilt binary per platform tuple as
optionalDependencies (@img/sharp-linuxmusl-x64 for Alpine, -darwin-arm64
for Apple Silicon, etc).

The lock file only records the dev machine's platform binary. `npm ci`
is strict — it installs exactly what's locked and refuses to add
platform-specific entries that weren't there at lock time. Result on
the VPS Alpine x64 build:

  sharp: Attempting to build from source via node-gyp
  sharp: Please add node-gyp to your dependencies

Fix: after the strict `npm ci`, run a separate `npm install --no-save
--cpu=x64 --os=linux --libc=musl sharp`. This downloads the Alpine
binary into node_modules without modifying package.json or the lock,
so the dev's Mac install stays untouched on the next git pull.

This is the recommended pattern from sharp's own docs for cross-target
Docker builds: https://sharp.pixelplumbing.com/install#cross-platform
2026-05-04 15:43:30 -05:00
davidherran 89505c73cc fix: pin @swc/helpers explicitly so npm ci works on npm 10 (Alpine)
Deploy to VPS / deploy (push) Has been cancelled
The Docker build on the VPS failed because:
  npm error Missing: @swc/helpers@0.5.21 from lock file

Cause: Next.js 16.1.6 declares @swc/helpers both as a direct dep at
0.5.15 AND as a peer dep ">=0.5.17". npm 11 (my local) accepts the
0.5.15 install and considers the peer satisfied. npm 10.9.7 (the
Alpine container in the VPS) is stricter — it tries to resolve the
peer to its own version and demands 0.5.21, which our lock file
didn't have.

Fix:
- Add @swc/helpers ^0.5.17 as a direct dependency so both npm 10 and
  npm 11 pick the same up-to-date version (resolved to 0.5.21).
- Regenerate package-lock.json from a clean install so it matches.

`npm ci` now succeeds in the docker-compose build.
2026-05-04 15:35:20 -05:00
davidherran f8606a45ff feat: branding asset serving + footer email/phone fields
Deploy to VPS / deploy (push) Has been cancelled
Two changes that together make Site Settings actually work end-to-end.

BRANDING ASSET SERVING (the broken thumbnails fix)
The favicon/logo previews were broken because uploaded files in
/public/branding had no path to reach the browser:
  1. The folder wasn't mounted into the app container, so uploads
     vanished on next deploy
  2. Nginx had no location block, so /branding/foo.png returned 404
     (everything not in cases/applications/news/parts/footage was a
     proxy_pass to Next.js, which doesn't serve from /public/branding
     in standalone mode)

Fix:
- docker-compose.yml: ./public/branding mounted to /app/public/branding
  (write side) AND /srv/branding (read-only side for Nginx)
- nginx/conf.d/flux.conf: new "location /branding/" block, same
  cache strategy as the other asset locations (max-age=300, must-revalidate)

FOOTER EMAIL + PHONE (David's request)
- siteSettingsTypes.ts: hqEmail and hqPhone fields added to FooterSettings,
  pre-filled with sales@lethepowerflux.com and +39 0424 287 492
- Footer.tsx: clickable mailto: and tel: links with Mail / Phone icons
  shown right under the HQ address. Hidden when fields are empty so the
  layout stays clean for editors who want to suppress contact info.
- /hq-command/dashboard/settings: new "Headquarters contact" group in
  the Footer tab with the two fields (auto-translate ignores them, since
  emails and phone numbers don't need translation).

DEPLOY (David)
  cd /opt/flux-srl
  mkdir -p public/branding   # one-time, creates the folder if missing
  git pull
  docker compose up -d --build app
  docker compose exec nginx nginx -t
  docker compose exec nginx nginx -s reload
2026-05-04 15:24:06 -05:00
davidherran 01a84edee9 fix: prisma migrate now runs at container startup + dotenv optional
Deploy to VPS / deploy (push) Has been cancelled
Two related fixes for the deploy pipeline so DB schema changes never
again leave the site half-deployed.

PRISMA CONFIG (prisma.config.ts)
- "import 'dotenv/config'" was hard-required, but dotenv isn't installed
  in the production runtime image (env vars come from docker-compose).
- Wrapped in try/catch so it loads .env locally and silently no-ops in
  the container — `prisma migrate deploy` works in both environments.

DOCKERFILE
- Copies node_modules/prisma + prisma.config.ts to the runner stage so
  the CLI is available at runtime, not just at build.
- New CMD runs `prisma migrate deploy` before booting the server.
  Idempotent — already-applied migrations are skipped. If the DB is
  unreachable, the container exits and docker-compose retries.
- This means: from now on, `git pull && docker compose up -d --build app`
  is the entire deploy. No more "did you remember to run migrations?".

DEFENSIVE TRY/CATCH (applications/[slug]/page.tsx)
- prisma.application.findUnique and prisma.globalNode.findMany now have
  try/catch with logged errors. A transient DB hiccup or missing
  Application slug now degrades gracefully (renders "not found" or empty
  cases wall) instead of triggering a 500 Internal Server Error.

DEPLOY (David, this is the recovery sequence on the VPS)
  cd /opt/flux-srl
  git pull
  docker compose up -d --build app
  # The container will run pending migrations on its own.
  # No need to run `prisma migrate deploy` manually anymore.
2026-05-04 15:04:35 -05:00
davidherran a199891a3c feat: FluxAI multi-step autonomy + rate limiting + image pipeline
Deploy to VPS / deploy (push) Has been cancelled
Two production-grade hardening additions and one cost optimisation.

FLUXAI AUTONOMY RESTORED (api/chat)
- Brings back the multi-step agentic flow that the system prompt was
  always designed for. The "temporarily removed maxSteps" comment is
  gone — replaced with the AI SDK 6 equivalent stopWhen: stepCountIs(5).
- Cap at 5 chained tool calls per turn bounds latency + LLM cost.
- maxDuration raised 30s → 60s to absorb tool-chain runs.
- Result: one user prompt now triggers, e.g. search_installations →
  energy_savings_calculator → show_case_study → schedule_consultation
  in a single turn — exactly the SPIN methodology in the prompt.

RATE LIMITING (src/lib/rateLimit.ts + api/chat)
- Token-bucket per IP: 30 messages burst, sustained 30/minute. Trips
  to 429 with Retry-After + X-RateLimit-Remaining headers when abused.
- IP extracted from x-forwarded-for (Nginx already passes this).
- In-memory Map with 10-min GC of stale buckets — no Redis dep.
  If we scale to multiple replicas later, swap the Map for Upstash.
- Protects the OpenAI quota from someone hammering the chat endpoint.

IMAGE PIPELINE (src/lib/imageOptimizer.ts)
- sharp-based optimizer: auto-orient (EXIF), cap at 2560px long side,
  re-encode WebP@85, content-hash filename. Re-uploads with same
  content reuse the same hash; new content gets a new URL — perfect
  cache invalidation without header tricks.
- Opt-in via optimize=1 form/query param on /api/assets POST.
- Hero CMS and Site Settings uploads turn it on automatically (those
  are user-facing brand assets where compression matters most).
- App/news/parts uploads remain untouched (editors may be uploading
  CAD drawings, datasheets, etc. that shouldn't be transcoded).
- Falls back gracefully to a no-op for unsupported formats (SVG, GIF,
  videos, anything sharp can't decode) so it never breaks an upload.

DOCKERFILE
- Adds vips/vips-dev for sharp on Alpine + --include=optional so the
  @img/sharp-linuxmusl-x64 prebuilt is downloaded
- Explicitly copies node_modules/sharp + node_modules/@img to the
  runner stage (Next.js trace can miss conditional deps).

NO DB SCHEMA CHANGES.
2026-05-04 14:48:37 -05:00
davidherran 09e6d0c7cf seo: dynamic sitemap + robots + per-page metadata + JSON-LD
Brings the site up to enterprise SEO standards. Google now gets a complete
machine-readable map of the content, with multilingual hreflang tags,
structured data for the knowledge panel, and rich Open Graph cards on
LinkedIn / WhatsApp / Twitter.

NEW
- src/app/sitemap.ts: dynamic sitemap.xml from Prisma. Emits 5 locales x
  every active application + every active news article, with hreflang
  alternates linking each translation. Hourly revalidation.
- src/app/robots.ts: robots.txt blocks /hq-command/, /api/, /parts (B2B
  auth-gated), points crawlers at the sitemap.
- src/lib/seo.ts: helpers for canonical URLs, hreflang alternates, and
  JSON-LD schemas (Organization, WebSite, Article, Product, BreadcrumbList).
- src/components/seo/JsonLd.tsx: server component that emits one
  application/ld+json script tag per page.

PER-PAGE generateMetadata
- Home: localized titles + descriptions in EN/IT/VEC/ES/DE
- News hub: title built from translations, hreflang tags
- News article: title/description from DB, OG image = cover, type=article,
  publishedTime + modifiedTime for date freshness signals
- Applications: title/description from DB, type=product, hero image
- Heritage: localized title/description

JSON-LD STRUCTURED DATA
- Site-wide (in root layout): Organization (with HQ address, founder,
  contact, social profiles) + WebSite — drives Google knowledge panel
- Article pages: Article schema with publisher/datePublished/dateModified
  — required for Google News / Discover eligibility
- Application pages: Product schema (FLUX brand, RF Industrial category)
  + BreadcrumbList — drives rich-snippet breadcrumb in search results

NOTES
- Open Graph metadataBase set from NEXT_PUBLIC_APP_URL so absolute URLs
  for OG images are correct (LinkedIn previews require absolute paths)
- All pages have canonical URLs to prevent duplicate-content penalties
- /parts already has noindex meta (B2B portal) — also blocked in robots
- No DB schema changes. Pure additions to /src/lib and /src/app.
2026-05-04 14:42:43 -05:00
112 changed files with 11216 additions and 3026 deletions
-1
View File
@@ -10,7 +10,6 @@ docker-compose*.yml
Dockerfile Dockerfile
nginx/ nginx/
certbot/ certbot/
scripts/
prisma/dev.db prisma/dev.db
prisma/dev.db-journal prisma/dev.db-journal
prisma/migrations/migration_lock.toml prisma/migrations/migration_lock.toml
+3
View File
@@ -50,7 +50,10 @@ public/news/
public/parts/ public/parts/
public/operations-inbox/ public/operations-inbox/
public/footage/ public/footage/
public/team/
public/branding/
# Local Claude Code / MCP config — agent-specific, not project # Local Claude Code / MCP config — agent-specific, not project
.mcp.json .mcp.json
.claude/ .claude/
backups/
+61 -18
View File
@@ -3,63 +3,106 @@
# Next.js 16 + Prisma + next-intl + AI SDK # Next.js 16 + Prisma + next-intl + AI SDK
# ═══════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════
# ── Stage 1: Install dependencies ── # ── Stage 1: Install ALL dependencies (dev + prod) ──
# Used by the builder to compile, type-check and bundle.
FROM node:22-alpine AS deps FROM node:22-alpine AS deps
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci
# ── Stage 2: Build the application ── # Sharp's per-platform binaries (@img/sharp-linuxmusl-x64, etc.) are pinned
# as optionalDependencies in package.json, so the lock file records every
# supported platform. `npm ci` then picks the matching one for the build
# host (Alpine x64) and skips the rest — no source compilation needed,
# no extra Dockerfile gymnastics.
RUN npm ci --include=optional --no-audit --no-fund
# ── Stage 2: Production-only dependencies ──
# Same install but trimmed to prod tree. The runner stage uses this
# instead of cherry-picking individual node_modules subdirs — that
# approach broke when prisma's CLI tried to require its transitive
# deps (e.g. "effect") at startup. With the full prod tree present,
# `prisma migrate deploy` and any other prod CLI just works.
FROM node:22-alpine AS prod-deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --include=optional --no-audit --no-fund
# ── Stage 3: Build the application ──
FROM node:22-alpine AS builder FROM node:22-alpine AS builder
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
# Prisma: generate client for linux-musl (Alpine) # Prisma: generate client for linux-musl (Alpine).
# NOTE: dummy URL required because prisma.config.ts calls env("DATABASE_URL") # Dummy URL required because prisma.config.ts calls env("DATABASE_URL")
# during generate. The real URL is injected at runtime via docker-compose. # during generate. The real URL is injected at runtime via docker-compose.
RUN DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy" npx prisma generate RUN DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy" npx prisma generate
# Disable telemetry during build
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
ENV DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy" ENV DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy"
# NEXT_PUBLIC_* vars are inlined into the client bundle at BUILD time, so the
# GA Measurement ID must be present here (not just at runtime). Passed from
# docker-compose build.args -> .env. Empty by default = analytics disabled.
ARG NEXT_PUBLIC_GA_ID=""
ENV NEXT_PUBLIC_GA_ID=$NEXT_PUBLIC_GA_ID
ARG NEXT_PUBLIC_GSC_VERIFICATION=""
ENV NEXT_PUBLIC_GSC_VERIFICATION=$NEXT_PUBLIC_GSC_VERIFICATION
RUN npm run build RUN npm run build
# ── Stage 3: Production runner ── # ── Stage 4: Production runner ──
FROM node:22-alpine AS runner FROM node:22-alpine AS runner
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
# Security: run as non-root user # vips runtime — required for sharp at runtime, not just build
RUN addgroup --system --gid 1001 nodejs # su-exec — drops privileges from root to nextjs in the entrypoint
RUN adduser --system --uid 1001 nextjs RUN apk add --no-cache vips su-exec
# Copy public assets (footage, images, GLB models) # Security: run as non-root user (entrypoint chowns volumes as root, then drops).
# `--ingroup nodejs` makes nodejs the primary group of nextjs — without this
# Alpine assigns gid 65533 (nogroup) and every file the container writes ends
# up as 1001:65533, which is confusing and surprises sudoers on the host.
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 --ingroup nodejs nextjs
# Public assets (logos, brand SVGs, model files)
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
# Copy standalone build # Next.js standalone server + its compiled tree
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Copy Prisma schema + generated client (needed for migrations at runtime) # Full prod-only node_modules so any CLI we run at startup (Prisma, etc.)
# resolves all its transitive deps. Standalone's bundled node_modules is
# layered on top; node's resolver finds whichever it needs.
COPY --from=prod-deps /app/node_modules ./node_modules
# Prisma artefacts (schema, migrations, generated client, CLI)
COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
# Copy i18n message files (required by next-intl at runtime) # i18n message files (required by next-intl at runtime)
COPY --from=builder /app/messages ./messages COPY --from=builder /app/messages ./messages
USER nextjs
EXPOSE 3000 EXPOSE 3000
ENV PORT=3000 ENV PORT=3000
ENV HOSTNAME="0.0.0.0" ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"] # Entrypoint runs briefly as root to chown mounted volumes (fixes EACCES
# on uploads when the host folder owner != container user), runs Prisma
# migrations, then drops to the nextjs user via su-exec.
COPY scripts/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
+72
View File
@@ -17,6 +17,12 @@ services:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
networks: networks:
- flux-net - flux-net
# Resource caps so no single container can starve the others (the Nginx
# outage earlier was a reminder). VPS has ~11 GB; these leave headroom.
deploy:
resources:
limits:
memory: 2g
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"] test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 5s interval: 5s
@@ -42,6 +48,12 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args:
# NEXT_PUBLIC_* are inlined into the client bundle at build time.
# Sourced from .env on the host; the fallback is the FLUX GA4 ID so
# analytics works out of the box even if .env doesn't override it.
NEXT_PUBLIC_GA_ID: ${NEXT_PUBLIC_GA_ID:-G-KQ1JRV3KN7}
NEXT_PUBLIC_GSC_VERIFICATION: ${NEXT_PUBLIC_GSC_VERIFICATION:-}
restart: always restart: always
depends_on: depends_on:
postgres: postgres:
@@ -58,6 +70,10 @@ services:
SMTP_FROM: ${SMTP_FROM} SMTP_FROM: ${SMTP_FROM}
SMTP_SECURE: ${SMTP_SECURE} SMTP_SECURE: ${SMTP_SECURE}
NODE_ENV: production NODE_ENV: production
# Optional: REDIS_URL enables multi-instance rate limiting. Leave unset
# for the current single-container deploy — the in-memory store is used.
REDIS_URL: ${REDIS_URL:-}
REDIS_TOKEN: ${REDIS_TOKEN:-}
volumes: volumes:
- ./public/footage:/app/public/footage - ./public/footage:/app/public/footage
- ./public/applications:/app/public/applications - ./public/applications:/app/public/applications
@@ -65,10 +81,24 @@ services:
- ./public/news:/app/public/news - ./public/news:/app/public/news
- ./public/parts:/app/public/parts - ./public/parts:/app/public/parts
- ./public/operations-inbox:/app/public/operations-inbox - ./public/operations-inbox:/app/public/operations-inbox
- ./public/branding:/app/public/branding
- ./public/team:/app/public/team
networks: networks:
- flux-net - flux-net
expose: expose:
- "3000" - "3000"
deploy:
resources:
limits:
memory: 1500m
healthcheck:
test:
- CMD-SHELL
- "node -e \"fetch('http://localhost:3000/api/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))\""
interval: 30s
timeout: 5s
retries: 3
start_period: 40s
# ── Nginx Reverse Proxy ── # ── Nginx Reverse Proxy ──
nginx: nginx:
@@ -88,10 +118,52 @@ services:
- ./public/parts:/srv/parts:ro - ./public/parts:/srv/parts:ro
- ./public/footage:/srv/footage:ro - ./public/footage:/srv/footage:ro
- ./public/operations-inbox:/srv/operations-inbox:ro - ./public/operations-inbox:/srv/operations-inbox:ro
- ./public/branding:/srv/branding:ro
- ./public/team:/srv/team:ro
depends_on: depends_on:
- app - app
networks: networks:
- flux-net - flux-net
deploy:
resources:
limits:
memory: 256m
healthcheck:
# Nginx self-health (served directly by the default_server, no upstream).
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/nginx-health || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
# ── Automated Postgres backups ──
# Nightly pg_dump -> gzip into ./backups on the host, 14-day rotation.
# NOTE: this is LOCAL to the VPS. Offsite copy (S3/rsync) is the recommended
# next step once the client provides storage credentials.
backup:
image: postgres:16-alpine
restart: always
depends_on:
postgres:
condition: service_healthy
environment:
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: ${DB_NAME}
BACKUP_DIR: /backups
RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-14}
BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400}
volumes:
- ./backups:/backups
- ./scripts/db-backup.sh:/usr/local/bin/db-backup.sh:ro
- ./scripts/backup-loop.sh:/usr/local/bin/backup-loop.sh:ro
entrypoint: ["/bin/sh", "/usr/local/bin/backup-loop.sh"]
networks:
- flux-net
deploy:
resources:
limits:
memory: 256m
volumes: volumes:
pgdata: pgdata:
+458
View File
@@ -0,0 +1,458 @@
# FLUX SRL — Audit Verification & Corrected Priorities
**Date:** 2026-06-09
**Method:** 10-dimension multi-agent audit (59 agents, adversarial verification) + manual re-verification of every critical/high finding against the running production system.
> **Why this file exists:** the automated audit (full report below) is thorough but produced one **false-positive CRITICAL** and overstated a few others. This top section is the corrected, ground-truth verdict. Trust this section over the raw report where they disagree.
---
## Manual verification results (the corrections)
### ❌ SEC-02 "Auth middleware never runs" — FALSE POSITIVE
The audit's #1 critical claimed `src/proxy.ts` is never executed because it exports `proxy` not `middleware`.
**Verified in production:** `curl https://www.rf-flux.com/hq-command/dashboard` (no cookie) → **307 redirect to `/hq-command/login`**. The middleware **is running**. Next.js 16 *does* recognize `src/proxy.ts` exporting `proxy` (the rename is real in Next 16; the code comment was correct). The empty `middleware-manifest.json` in a local build is misleading — runtime behavior disproves the claim.
**Consequence:** the HQ admin surface **is** protected by the middleware. SEC-03 (below) drops from critical to low.
### ⚠️ SEC-03 "HQ server actions lack auth" — OVERSTATED (low, not critical)
Because the middleware *does* run and its matcher covers `/hq-command/*`, POSTs to those routes (which is how server actions are invoked) are gated — no cookie → redirect before the action executes. Adding an in-action `getSession()` check is still good defense-in-depth, but it is **not** an open door.
### ✅ SEC-04 "/api/assets + /api/branding/favicon unauthenticated" — REAL (HIGH) — the actual top security issue
Verified: neither route checks a session, and the middleware matcher **excludes `/api`**, so the middleware does not protect them. Anyone can `GET/POST/PUT/DELETE` `/api/assets` — list CMS structure, upload files into public scopes, rename/delete content — and POST `/api/branding/favicon`. **This is the real #1.**
### ✅ DB-01 "ClientUser table never migrated" — REAL (HIGH), impact nuanced
Verified: the init migration creates 10 tables; **`ClientUser` is not among them**, and no later migration adds it. Code calls `prisma.clientUser` in `clientAuth.ts` (B2B register/login) and the dashboard.
**Real impact:** the **B2B client portal (register/login) is broken** at runtime. The HQ dashboard does *not* crash (its counts run inside a try/catch and silently show 0 clients), so the audit's "undeployable crash" framing was overstated — but the B2B portal genuinely doesn't work.
### ✅ SEC-05 "Operations email not HTML-escaped" — REAL (MEDIUM-HIGH)
Verified: `src/app/actions/operations.ts → generateRichEmailHtml()` interpolates `item.title`, `payload.clientName`, `clientCompany`, etc. straight into HTML with no `escapeHtml()`. Stored-XSS into the team's internal operations inbox from the CartDrawer form. Real.
### ✅ SEC-01 "Hardcoded secret fallback in proxy" — REAL but MITIGATED (MEDIUM)
`src/proxy.ts:13` still has `|| "FLUX_SUPER_SECRET_KEY_2026_ARCHITECTURE"`. Mitigated because signing (`session.ts`) throws without the env var, so tokens can't be forged in normal operation. Should still be removed (the verifier asymmetry is real).
### ✅ INFRA-03 "Secrets in git" — REAL (known)
OpenAI key + Gmail app password are in git history. Rotate; convert tracked `env``.env.example`.
---
## Corrected priority list — what actually matters
### 🔴 NOW (real, small, do first)
1. **Auth on `/api/assets` + `/api/branding/favicon`** (SEC-04) — add `getAdminSession()` guard at the top of each handler. *Effort: S*
2. **Create the `ClientUser` migration** (DB-01) — additive migration; unblocks the B2B portal. *Effort: S*
3. **Escape HTML in operations emails** (SEC-05) — reuse `escapeHtml` like consultation/route already does. *Effort: S*
4. **Remove the proxy secret fallback** (SEC-01) — throw if `SESSION_SECRET` missing, mirroring `session.ts`. *Effort: S*
5. **Whitelist chat context fields** (AI-01) — sanitize `context.section/activeTab` before injecting into the system prompt (prompt-injection hygiene). *Effort: S*
6. **Rotate OpenAI key + SMTP password; untrack `env`** (INFRA-03). *Effort: S — needs the client for the key*
### 🟡 NEXT (operational resilience — all real)
- Automated nightly **DB backups** (none today) + offsite. *M*
- **Container memory/CPU limits** + Nginx healthcheck in compose. *S*
- `buildSystemPrompt()` **try/catch + static fallback** so chat degrades instead of 500 if DB is down (TEST-02). *S*
- **Validate `OPENAI_API_KEY`** at startup + wrap `streamText` in try/catch (TEST-03). *S*
- **Idempotent consultation** submission (no orphan records on SMTP fail) (TEST-04). *M*
- **Monitoring/uptime** (UptimeRobot on `/api/health`) + log aggregation. *M*
- Index `GlobalNode.application`; cache the system prompt (PERF-01/03). *S*
### 🟢 LATER (polish/scale)
- Defense-in-depth `getSession()` inside HQ actions (SEC-03). a11y pass (focus trap, labels, skip link, reduced-motion). i18n: number formatting + translate error boundaries. SEO: image/video sitemaps, FAQ/VideoObject schema. Reduce `any`, split oversized files, adopt `log.*` everywhere. Jsonb migration for hot JSON fields. Lazy-load Three.js.
---
## What's genuinely strong (verified)
CSRF on public endpoints · magic-byte upload validation · JWT signing that throws on missing secret · **the middleware DOES protect HQ** · complete 5-locale parity · the AI translation glossary · hreflang/canonical/breadcrumb SEO · Nginx edge caching + the new canonical-host guard + scanner blocking · structured logger + safe-JSON helpers + escapeHtml exist (gap is adoption consistency) · clean idempotent migrations · bcrypt password hashing · well-designed 9-tool SPIN FluxAI.
---
## Dimension scorecard (audit, with my adjustments)
| Dimension | Audit | Adjusted | Note |
|---|---|---|---|
| Security | 3.5 | **6.5** | SEC-02 false positive removed; middleware protects HQ. Real gaps: SEC-04/05/01. |
| Performance | 6.0 | 6.0 | Solid baseline; prompt rebuilt per message. |
| Code quality | 5.5 | 5.5 | `any` usage, logging inconsistency. |
| Database | 4.5 | **5.5** | ClientUser migration missing (B2B), but not a dashboard crash. |
| FluxAI | 6.0 | 6.0 | Strong tools; add prompt-injection guard + history. |
| SEO | 6.5 | 6.5 | Good base; add media sitemaps + FAQ/Video schema. |
| i18n | 6.0 | 6.5 | Excellent infra; few hardcoded strings. |
| Infra | 4.0 | 4.5 | No backups/monitoring; secrets in git. The real weak spot. |
| Accessibility/UX | 4.5 | 5.0 | a11y gaps; dark mode now covered. |
| Testing/reliability | 3.0 | 3.5 | One suite; add API integration tests + degradation. |
**Adjusted overall: ~6.0/10** (audit said 4.8 — the false-positive critical dragged it down). The build is solid; the genuine priorities are a handful of small auth/data fixes plus operational resilience (backups + monitoring).
---
---
# Appendix — Full automated audit report (raw, uncorrected)
> The section below is the unedited 10-dimension report. Where it conflicts with the verification above (notably SEC-02), the verification above is authoritative.
# FLUX SRL — Consolidated Audit Report
**Project:** rf-flux.com (Flux SRL) — Next.js 16 · Prisma 7 · PostgreSQL · AI SDK 6 · 5 locales · Docker + Nginx on OVH
**Audit scope:** 10 dimensions, adversarially verified (each critical/high finding independently re-checked against source)
**Date:** 2026-06-09
**Auditor:** Lead consolidation pass
---
## 1. Executive Summary
Flux SRL is a capably-built marketing + B2B platform with genuinely good bones — multi-stage Docker, CSRF on public endpoints, magic-byte upload validation, a structured logger, a working AI translation glossary, complete 5-locale message parity, and a recently-hardened Nginx (canonical-host guard, scanner blocking). But the audit surfaced a **cluster of authorization failures that, taken together, mean the entire HQ admin surface is currently unprotected**, plus operational gaps (no backups, no monitoring, secrets in git) that make this risky to run in production as-is.
The single most important finding: the auth middleware **does not execute at all** because the file exports a function named `proxy` instead of `middleware`. Every downstream auth assumption collapses from that one fact — and because the HQ server actions and the `/api/assets` + `/api/branding` routes have *no* internal session checks, they are reachable by anyone.
### Overall health: **4.8 / 10** (weighted toward the security + infra blockers)
### Dimension scorecard
| Dimension | Score | One-line verdict |
|---|---|---|
| Security | 3.5 | Middleware never runs; HQ actions + asset APIs fully unauthenticated. |
| Performance | 6.0 | Solid caching baseline; chat rebuilds prompt + 4 DB queries every message. |
| Code quality | 5.5 | 187 `any` usages, inconsistent logging, empty catch blocks; good helpers exist but under-used. |
| Database | 4.5 | `ClientUser` table referenced in code but never migrated — runtime crash risk. |
| FluxAI | 6.0 | Strong tool architecture; prompt injection via context, no history, plaintext message storage. |
| SEO | 6.5 | Good metadata + JSON-LD; no image/video sitemaps for a visual industrial site. |
| i18n | 6.0 | Excellent infra and parity; a few components ship hardcoded English. |
| Infra | 4.0 | No resource limits, no backups, no monitoring, secrets in tracked `env` file. |
| Accessibility/UX | 4.5 | No skip link, no Escape-to-close, no reduced-motion, broken Tailwind class. |
| Testing/reliability | 3.0 | One unit-test file; zero integration tests; multiple silent-fail paths. |
### Top 5 things to fix first
1. **Make the middleware actually run** (SEC-02). Rename/export `proxy` as `middleware` in `src/middleware.ts`. Until this lands, *nothing else in the auth layer matters* — the page-level guards are dead code.
2. **Add session checks inside HQ server actions and the asset/branding APIs** (SEC-03, SEC-04). Defense-in-depth: even with middleware fixed, these must self-verify. They are directly POST-able today.
3. **Rotate the leaked credentials and stop tracking `env`** (INFRA-03). The OpenAI key and Gmail app password are in git history. Rotate now; they're compromised regardless of what you do next.
4. **Create the missing `ClientUser` migration** (DB-01). Code calls `prisma.clientUser` in the dashboard and B2B auth; the table was never created. This is an active crash, not a hypothetical.
5. **Stand up backups + container resource limits** (INFRA-05, INFRA-01). No automated DB backup and no memory caps on an OVH single-VPS is a "lose everything on one bad day" setup.
---
## 2. Critical & High Findings (confirmed / partially-confirmed only)
> Severities below use the **adjusted** verdict from verification. Refuted findings are excluded (see §6).
### Security
**SEC-02 — Auth middleware never executes (CRITICAL)**
`src/proxy.ts:17` — The file exports `async function proxy(...)` plus `config`, but Next.js 16 only invokes a file named `middleware.ts` exporting `middleware`. The `.next/server/middleware-manifest.json` is empty, confirming it was never compiled. A code comment even documents the rename intentionally. **Impact:** every `/hq-command/*` route has zero middleware protection; direct navigation and prefetch hit the dashboard with no auth gate. **Fix:** create `src/middleware.ts` with `export { proxy as middleware }` and re-export `config`; verify the matcher covers `/hq-command`. Then redeploy and confirm the manifest is populated.
**SEC-03 — HQ server actions lack session verification (CRITICAL)**
`src/app/hq-command/dashboard/inbox/actions.ts:11-188``getSignals()`, `getClients()`, `approveAccessRequest()`, `updateSignalStatus()`, `resolveAndCleanSignal()`, `deleteSignal()`, `resendSignalEmail()`, `deleteClient()` run raw Prisma mutations with no `getSession()` call. Marked `"use server"` but self-unauthenticated. **Impact:** these are invokable directly via POST (curl/fetch) — approve/delete B2B clients, mutate signals, trigger emails, all without a token. **Fix:** add a `requireAdminSession()` helper and call it at the top of every action: `const s = await getAdminSession(); if (!s) throw new Error('Unauthorized');`. Don't rely on the middleware alone.
**SEC-04 — `/api/assets` and `/api/branding/favicon` are unauthenticated (CRITICAL, raised from high)**
`src/app/api/assets/route.ts`, `src/app/api/branding/favicon/route.ts` — All methods (GET/POST/PUT/DELETE/PATCH) on `/api/assets` and POST on the favicon route have no session check. Path sanitization prevents traversal but not auth. **Impact:** anyone can enumerate CMS structure, upload SVG-with-JS into public scopes, delete branding/content, or regenerate favicons → defacement/XSS/downtime. **Fix:** at the top of each handler, `const s = await getAdminSession(); if (!s) return NextResponse.json({error:'Unauthorized'},{status:401});`.
**SEC-01 — Hardcoded session-secret fallback in proxy (HIGH, lowered from critical)**
`src/proxy.ts:13``process.env.SESSION_SECRET || "FLUX_SUPER_SECRET_KEY_2026_ARCHITECTURE"`, used for JWT *verification* at line 35. Verifier confirmed it's mitigated in practice: `session.ts`/`clientAuth.ts` *throw* if the secret is unset at signing time, so tokens can't be forged under normal operation. The danger is the asymmetry — if env loading ever fails at runtime, the verifier would accept tokens signed with the public string. **Fix:** remove the fallback; throw loudly if `SESSION_SECRET` is missing, mirroring `session.ts`.
**SEC-05 — Missing HTML escaping in operations email templates (HIGH)**
`src/app/actions/operations.ts:119-156``generateRichEmailHtml()` interpolates `item.title`, `item.sku`, `payload.clientName`, `clientCompany`, `clientEmail`, `clientPhone`, `message` straight into HTML. `escapeHtml()` exists in `src/lib/escapeHtml.ts` but isn't imported here. **Impact:** HTML/script injection into internal operations emails from the CartDrawer form. **Fix:** escape every user field before interpolation, matching the pattern already correct in `src/app/api/consultation/route.ts`.
### Database
**DB-01 — `ClientUser` table referenced but never migrated (CRITICAL)**
`prisma/schema.prisma:370-387` defines `ClientUser` and the `OperationsSignal.clientId` relation (lines 226-227), but **no migration creates the table** — not the init migration nor any of the 5 later ones. Code actively calls it: `src/app/hq-command/dashboard/page.tsx:67-68` (`prisma.clientUser.count()`), `src/app/actions/clientAuth.ts:25,30,50+` (findUnique/create/update). **Impact:** immediate runtime failure on any B2B auth or dashboard-count path; undeployable. **Fix:** generate a migration creating `ClientUser` (id, email unique, passwordHash, fullName, companyName, phone?, isApproved default false, lastLoginAt?, createdAt, updatedAt) and the FK from `OperationsSignal.clientId`. Run `prisma migrate dev` and verify against a fresh DB.
**DB-04 — JSON stored as `String` instead of `Jsonb` (HIGH)**
`prisma/schema.prisma` — 24+ JSON fields across 12 models (`translationsJson`, `galleryJson`, `sectionsJson`, `payloadJson`, etc.) are `String`. **Impact:** no DB-level JSON queries/indexing/validation; all parse/serialize is manual (and is the root cause of several CQ-03/CQ-04 parse-error findings). **Fix:** migrate query-hot fields first (`payloadJson` on AiEvent, `galleryJson`/`sectionsJson` on Application) to `Jsonb` via add-column → backfill → drop → rename.
**DB-05 — AI telemetry grows unbounded; no retention policy (HIGH)**
`prisma/schema.prisma:322-365``AiConversation`/`AiEvent` persist every chat indefinitely. No TTL, cleanup job, or purge anywhere in `src/`. Stores hashed IP + userAgent + full message text → GDPR Art. 5(1)(e) storage-limitation exposure. **Fix:** add a `retentionDays` setting (default 90), a daily cleanup deleting rows older than the window, and document it. Consider archival before delete.
**DB-03 — `GlobalNode.application` has no referential integrity (MEDIUM, lowered from high)**
`prisma/schema.prisma:40` — plain `String`, validated only as non-empty in `network/actions.ts:34`. Verifier downgraded: orphaned nodes persist and are simply excluded from filtered queries (deterministic, not data loss); UI dropdown prevents most bad input. **Fix:** validate `Application.findUnique({where:{slug}})` exists before create, or migrate to an `applicationId` FK.
**DB-02 — `PageContent` missing `createdAt` (MEDIUM, lowered from high)**
`prisma/schema.prisma:266` — has `updatedAt` only; every other model has both (except `SiteSetting`). **Impact:** no creation audit trail for page content. **Fix:** add `createdAt DateTime @default(now())`.
### Infrastructure
**INFRA-01 — No container resource limits (CRITICAL)**
`docker-compose.yml:41-116` — neither `app` (with `restart: always`) nor `nginx` nor postgres define memory/CPU limits. **Impact:** a runaway/leaking process consumes all VPS RAM and OOM-kills siblings → documented downtime. **Fix:** `app` mem_limit 1G (reservation 512M), nginx 256M/128M, cpus 1.0 each; load-test.
**INFRA-03 — Secrets committed in tracked `env` file (CRITICAL)**
`env:29-40` — real `OPENAI_API_KEY` (sk-proj-…), `SMTP_USER`, and a 16-char Gmail app password, tracked since first commit `fc24313`. `.gitignore` pattern `.env*` doesn't match the bare `env` filename. **Fix (in order):** (1) **rotate the OpenAI key and Gmail app password now** — they're already compromised; (2) `git rm --cached env`, add `env` to `.gitignore`; (3) commit `.env.example` with placeholders; (4) move to GitHub Secrets / a secrets manager. History scrub is optional but the rotation is not.
**INFRA-05 — No automated database backups (CRITICAL)**
`src/app/hq-command/dashboard/health/actions.ts:31-52` — backup is a manual HTTP export of unencrypted JSON covering only 10 of 16 tables (omits HeroSlide, SiteSetting, AiConversation, AiEvent, ClientUser, TeamMember). No cron, no offsite, no retention, no WAL/PITR. **Impact:** total loss if the PG volume corrupts. **Fix:** nightly `pg_dump | gzip` to offsite (S3, encrypted), 30-day retention; enable WAL archiving for PITR; document + test RTO/RPO monthly.
**INFRA-02 — Nginx has no healthcheck; unconditional `depends_on` (HIGH, lowered from critical)**
`docker-compose.yml:93-116` — nginx lacks a healthcheck and depends on `app` with no `condition`, while `app` correctly uses `condition: service_healthy` for postgres. Verifier softened the "deadlock" claim: it's a startup race, not a hang — nginx proxies to the unready app and returns 502/503 during the ~40s start window. **Fix:** add an nginx healthcheck (tcp:80) and `depends_on: app: condition: service_healthy`.
**INFRA-04 — Deploy script has no error handling or rollback (HIGH, lowered from critical)**
`.github/workflows/deploy.yml:28-51` — the health check at line 49 uses `curl -sf … || echo`, swallowing failure so `script_stop` is bypassed; migrations (line 42) run post-live with no error handling; no snapshot, no rollback. **Impact:** a failed migration or post-deploy crash leaves prod broken with manual-only recovery. **Fix:** make the health check fail the deploy (`curl -sf … || exit 1`, retried 3× with backoff); take a pre-deploy snapshot; restore on failure.
**INFRA-06 — No monitoring, alerting, or log aggregation (HIGH, lowered from critical)**
`docker-compose.yml` — structured logger + `/api/health` exist, but nothing ships metrics/logs to any platform (no Prometheus/Grafana/Sentry/Loki). **Impact:** blind operations; degradation invisible until users complain. **Fix:** expose `/metrics` (prom-client), ship structured logs to Loki/CloudWatch, alert on mem >80% / error-rate >1% / p99 >500ms.
**INFRA-07 — Missing OCSP stapling (HIGH)**
`nginx/conf.d/flux.conf:68-82` — no `ssl_stapling`. **Impact:** slower TLS handshakes; visitor IPs leak to Let's Encrypt. **Fix:** add `ssl_stapling on; ssl_stapling_verify on; ssl_trusted_certificate …/chain.pem; resolver 1.1.1.1 8.8.8.8 valid=300s;`.
**INFRA-11 — CSP allows `unsafe-inline` + `unsafe-eval` (MEDIUM, lowered from high)**
`nginx/conf.d/flux.conf:87` — present in `script-src` for Next.js hydration. Verifier noted strong compensating controls (Zod validation, `escapeHtml`, magic-byte checks, minimal `dangerouslySetInnerHTML`), so it's real technical debt rather than an active hole. **Fix:** move to nonce-based CSP; run `Report-Only` first.
**INFRA-08 — Nginx cache-poisoning surface via hidden `Set-Cookie` (MEDIUM, lowered from high)**
`nginx/conf.d/flux.conf:282-286``proxy_ignore_headers`/`proxy_hide_header Set-Cookie` on public pages. Verifier refuted the "session cookies lost" and "sensitive header disclosure" parts (authenticated requests bypass cache via `$cookie_flux_session`); the residual risk is poisoning public pages with a non-sensitive injected cookie. **Fix:** refine cache-bypass logic rather than blanket-hiding `Set-Cookie`.
**INFRA-09 — Large `client_max_body_size` without explicit body timeout (MEDIUM, lowered from high)**
`nginx/conf.d/flux.conf:71,135,148` — 500M limit; global block lacks `client_body_timeout`. Verifier softened: nginx's 60s default applies and upload endpoints set proxy timeouts. **Fix:** add `client_body_timeout 60s;` to the http block; reduce 500M where not genuinely needed.
**INFRA-10 — No logrotate (MEDIUM, lowered from high)**
`nginx/nginx.conf:39` — logs unbounded, but verifier noted `/var/log/nginx` isn't a mounted volume, so logs live in ephemeral container storage (purged on restart). Residual risk: log loss on restart and container-layer growth on long uptimes. **Fix:** add logrotate (daily, compress, keep 7, SIGHUP).
### Performance
**PERF-01 — System prompt rebuilt with 4 DB queries on every chat request (HIGH)**
`src/app/api/chat/route.ts:42-281``buildSystemPrompt()` runs `application.findMany` + 2× `globalNode.count` + `sparePart.count` (Promise.all) on every POST, with no caching layer (the `promptCacheKey` is a no-op). **Impact:** 4 round-trips per message; 400+/min at 100 concurrent users. **Fix:** cache the built prompt in-memory with a 3060s TTL, invalidated on CMS write.
**PERF-03 — Missing index on `GlobalNode.application` (HIGH)**
`prisma/schema.prisma` — queries filter on `(application, isActive)`, `(nodeType, isActive, application)`, and `(application, nodeType, isActive)` (applications page 136-142; chat 503, 621) but there's no `application` index. **Impact:** full table scans on the synchronous FluxAI search path. **Fix:** add `@@index([application])`, `@@index([application, isActive])`, `@@index([application, nodeType])`; migrate.
**PERF-02 — Synchronous `fs.readdirSync` in SSR paths (MEDIUM, lowered from high)**
`src/app/[locale]/page.tsx:102`, `src/app/[locale]/applications/[slug]/page.tsx:32` — real blocking calls, but verifier downgraded: they're fallback paths inside try/catch on small dirs, hit during 60s ISR regen, not per-request. **Fix:** switch to `fs.promises.readdir`, or remove the fallback once HeroSlide/CMS migration is complete.
### FluxAI
**AI-01 — Prompt injection via unvalidated context fields (HIGH)**
`src/app/api/chat/route.ts:215-216``context.section` and `context.activeTab` come from `req.json()` (TS types only, no runtime validation) and are interpolated into `contextNote`, concatenated to the system prompt at line 287. A manipulated frontend store can inject instructions overriding FluxAI's personality/tool limits. **Fix:** whitelist `context.section`/`activeTab` against the known section set; reject anything else (Zod).
**AI-03 — User message text stored unencrypted (HIGH)**
`src/app/api/chat/route.ts:268` — full user text (≤8000 chars) persisted as plaintext in `AiEvent.payloadJson` (`String`). Customer questions about volumes/processes sit in cleartext. **Impact:** breach exposes competitive intel; GDPR Art. 32 (encryption at rest). **Fix:** encrypt sensitive payloads (pgcrypto/KMS) or store only industry label + tool names; pair with the DB-05 retention policy.
**AI-02 — No conversation history / resume (HIGH)**
`src/components/ai/SilentObserver.tsx:76-78``useChat` initializes fresh with no `initialMessages`; backend persists everything but the UI never fetches it. **Impact:** multi-turn B2B sales context lost on refresh, undermining the funnel-tracking investment. **Fix:** add `/api/chat/history?sessionId`, hydrate `initialMessages` on mount, offer "continue previous conversation?".
**AI-06 — Telemetry write errors silently swallowed (HIGH)**
`src/app/api/chat/route.ts:272-274, 370-371` — telemetry writes are wrapped in try/catch with only `log.warn()`; the `onFinish` writes can fail after the response streamed, losing conversation records. **Impact:** undercounted funnel analytics. **Fix:** circuit-breaker + alert on >5 failures/hour; buffer-and-retry unsent events.
### SEO
**SEO-02 — No image or video sitemaps (HIGH)**
`src/app/sitemap.ts:1-106` — only page URLs. Rich media (news `coverImage`/`galleryJson`, application galleries, heritage `mediaUrl` videos) is invisible to Google Image/Video Search; `robots.ts` declares only `/sitemap.xml`. **Fix:** add `sitemap-images.xml` (`<image:image>` per article cover/gallery + app hero) and `sitemap-videos.xml` (heritage videos with title/description/duration); declare both in robots.
### i18n
**I18N-01 — Hardcoded English in EnergySavingsCalculator (HIGH)**
`src/components/ai/EnergySavingsCalculator.tsx:61,118,146-149,156,164-178` — 13+ literal strings ("Annual Savings", "CO2 Reduced", "Payback", "Request Detailed Engineering Study", …); component never imports `useTranslations`, and no `EnergySavingsCalculator` namespace exists in any of the 5 locale files. **Impact:** IT/ES/DE/VEC users see English in a customer-facing calculator. **Fix:** add the namespace to `en.json`, translate to all 4 locales, wire `useTranslations("EnergySavingsCalculator")`.
**I18N-02 — Hardcoded alert in CartDrawer (HIGH)**
`src/components/layout/CartDrawer.tsx:43``alert("You must accept the privacy policy.")` bypasses the otherwise-used translation system; a modal blocker shown in English to all locales. **Fix:** move to a translated key and prefer the Toast component over native `alert()`.
### Accessibility / UX
**A11Y-02 — Broken Tailwind class breaks nav styling (HIGH)**
`src/components/layout/NavBar.tsx:190``"text-[#86868B hover:text-[#1D1D1F]"` is missing a `]`. **Impact:** the color class doesn't apply to inactive nav links in light mode. **Fix:** `"text-[#86868B] hover:text-[#1D1D1F]"`.
**A11Y-03 — Icon-only buttons missing `aria-label` (HIGH)**
`src/components/layout/NavBar.tsx:229,278,294` — theme toggle, cart, mobile-menu announce only "button". **Fix:** add descriptive `aria-label`s (e.g., `aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}`).
**A11Y-04 — Modal can't be closed with Escape (HIGH)**
`src/components/ui/CaseStudyModal.tsx:232-237` — no keydown handler; only a click-able close button (WCAG 2.1.1). **Fix:** `useEffect` keydown listener → `if (e.key==='Escape') onClose()`.
**A11Y-05 — No skip-to-main-content link (HIGH)**
`src/app/[locale]/layout.tsx:145-204` — ~13 interactive elements before main; main div has no `id` and isn't a `<main>` (WCAG 2.4.1). **Fix:** add `<a href="#main-content" class="sr-only focus:not-sr-only">Skip to main content</a>` and an `id`/`<main>` target.
**A11Y-08 — No `prefers-reduced-motion` handling (HIGH)**
`src/app/globals.css` — 260+ framer-motion animations, zero reduced-motion checks. **Impact:** vestibular-disorder risk. **Fix:** add `@media (prefers-reduced-motion: reduce){*{animation:none!important;transition:none!important}}` and gate heavy motion via `useReducedMotion()`.
**A11Y-09 — Hero videos lack captions (MEDIUM, lowered from high)**
`src/components/sections/HeroReel.tsx:71-83` — no `<track>`; verifier noted videos are muted/decorative with overlaid text, softening impact (WCAG 1.2.2). **Fix:** add captions where videos carry meaning, or mark decorative explicitly.
**A11Y-01 — Empty `alt` on cart product images (MEDIUM, lowered from high)**
`src/components/layout/CartDrawer.tsx:148``alt=""`; verifier noted adjacent visible title+SKU text mitigates. **Fix:** `alt={item.title}` for defense-in-depth.
### Code quality
**CQ-01 — 187 `any` usages across 48 files (HIGH)**
Concentrated in `ApplicationClient.tsx` (1208 lines), `chat/route.ts` (920 lines), `SilentObserver.tsx` (22 instances), despite `strict: true`. **Fix:** use existing `cms.ts` types (`AppFull`, `NodeFull`); add a `model-viewer.d.ts` for the web-component casts; chip away by file.
**CQ-02 — Inconsistent logging: 50 `console.error` vs 9 `log.error` (HIGH)**
A structured JSON logger exists (`src/lib/logger.ts`) but is bypassed in `sitemap.ts:75,102`, `heritage/page.tsx:210`, `mailer.ts:96`, `imageOptimizer.ts:117`, etc. **Impact:** unstructured output breaks Loki/CloudWatch parsing. **Fix:** replace `console.*` with `log.*` in server code; add a lint rule / pre-commit hook.
**CQ-04 — Empty catch blocks suppress JSON parse errors (HIGH)**
`ApplicationClient.tsx:618,826-829`, `news/[slug]/page.tsx:259`, `parts/page.tsx:55` — silent `catch(e){}` on dimensions/media/specs parsing. **Fix:** at minimum `catch(e){ log.warn('parse_failed', e) }`.
**CQ-03 — Unguarded `JSON.parse` for sections/advantages (MEDIUM, lowered from high)**
`ApplicationClient.tsx:1026-1027``data.sectionsJson`/`advantagesJson` parsed with no try/catch (verifier confirmed *this* pair; refuted the claims about lines 600/609/CaseStudyModal which do have guards). **Fix:** use the existing `parseJsonField` helper from `cms.ts`.
**CQ-07 — `any`-typed Prisma access in chat (MEDIUM, lowered from high)**
`chat/route.ts:413-414``(app: any)` access with inconsistent null-handling; verifier refuted the line-450 "undefined .score" claim (it's always initialized). Real issue is type-safety, not a live crash. **Fix:** use Prisma `select` with explicit types instead of `any`.
### Testing / reliability
**TEST-01 — No integration/e2e tests for critical API routes (HIGH)**
Only `tests/ai/golden.test.mjs` (17 unit tests). Zero coverage of `/api/consultation`, `/api/chat`, `/api/health`, `/api/public-upload`. **Fix:** add integration tests (Node test runner) with mocked Prisma/nodemailer; aim >80% on `src/app/api`.
**TEST-02 — `buildSystemPrompt()` has no DB-failure handling (HIGH)**
`chat/route.ts:42-72` — 4 parallel Prisma queries, no try/catch; called at line 281 before `streamText()`. If Postgres is down, the whole chat 500s instead of degrading. Asymmetric with the telemetry try/catch. **Fix:** wrap in try/catch → fall back to a static `DEFAULT_SYSTEM_PROMPT` (keeps personality/tools, omits dynamic counts).
**TEST-03 — OpenAI key never validated at startup or in health check (HIGH)**
`chat/route.ts`, `health/route.ts``openai('gpt-4o')` is called with no key validation; `/api/health` only does `SELECT 1`. **Impact:** invalid/missing key surfaces mid-stream after headers are sent. **Fix:** validate `OPENAI_API_KEY` at startup; wrap `streamText()` in try/catch returning a structured error (with `retryAfterSec` on 429).
**TEST-04 — Consultation email isn't idempotent; orphaned records on SMTP failure (HIGH)**
`api/consultation/route.ts:80-155` — signal created (line 105) before email send (line 141); on SMTP failure the client gets a 500 with no `ticketId`, retries duplicate, and the fallback route isn't re-attempted. **Fix:** add an idempotency key, return 202 with `ticketId` + `emailError` on send failure, and add a background retry with backoff.
---
## 3. Medium / Low / Info
**Security**
- SEC-06 (med) — `operations.ts:129-132`: file links built from unvalidated `fileUrl` → validate it starts with `/`.
- SEC-07 (low, downgraded) — `parts/page.tsx`: client-side gate not SSR redirect; data isn't leaked → optional `redirect()` for cleanliness.
- SEC-08 (med) — HQ actions have no rate limiting → rate-limit by admin id + action.
- SEC-09 (low) — `rateLimit.ts:144`: trusts `X-Forwarded-For` unvalidated → validate single-IP / trust only Nginx.
- SEC-10 (low) — `chat/route.ts:225`: `SESSION_SECRET` reused as telemetry HMAC salt → use a separate `VISITOR_HASH_SECRET`.
**Database**
- DB-06 (med) — `schema.prisma:230`: `AiConversation→OperationsSignal` no `onDelete` → orphans; document or cascade.
- DB-07 (med) — add `@@index([type, status, createdAt(sort: Desc)])` on OperationsSignal.
- DB-08 (low) — Application.order non-unique → validate uniqueness per `isActive` group.
- DB-09 (low) — NewsArticle.publishedAt has no future-date guard → filter `publishedAt <= now()`.
**Performance**
- PERF-04 (med) — `chat/route.ts:519`: JS keyword filter post-query → use Prisma `contains`/`mode:'insensitive'`.
- PERF-05 (med) — lazy-load `GlobalOperations` (Three.js ~300KB) via `dynamic({ssr:false})`.
- PERF-06 (med) — 48 `use client` components; push pure-display ones back to server components.
- PERF-07 / INFRA-12 (med) — Prisma pool `max=10` hardcoded → make `DB_POOL_MAX` env-configurable (2030 prod).
- PERF-08/10 (low) — ISR `revalidate=60` × 40 renders/min → consider 300600s or on-demand revalidation.
- PERF-09 (med) — large Docker build context → audit with `--progress=plain`, exclude heavy media.
- INFRA-13 (med) — Nginx upstream `keepalive=32` → raise to 128256.
**FluxAI**
- AI-04 (med) — no golden eval for tool selection → build 2030 query eval set, run monthly.
- AI-05 (low) — `stepCountIs(5)` may truncate → bump to 78, monitor traces.
- AI-07 (low) — no per-tool cost tracking → log `(conversationId, toolName, tokensIn/Out)`.
- AI-08 (med) — `SilentObserver.tsx:232,237`: tool failures silently omitted → render "no results" card.
- AI-09 (low) — duplicate of PERF-01 (prompt caching).
- AI-10 (med) — `useChat` has no `onError` → add error state + Retry button.
**SEO**
- SEO-01 (med) — OG images lack width/height/type → add `{width:1200,height:630,type:'image/jpeg'}`.
- SEO-03 (med) — no FAQPage schema → add `faqPageSchema()`.
- SEO-04 (med) — no VideoObject schema on heritage videos → add `videoObjectSchema()`.
- SEO-05/06 (low) — Product schema lacks AggregateRating/Offer; Article lacks keywords/commentCount.
- SEO-07 (med) — markdown parser allows empty `alt` → fallback `alt || 'Article image'` + CMS validation.
- SEO-08 (low) — add breadcrumb schema to home/team/heritage/news-hub.
- SEO-09 (low) — LocalBusiness hours lack timezone → add `Europe/Rome`.
- SEO-10 (med) — GlobalNode case studies lack structured data → add `caseStudySchema()`.
- SEO-11 (low) — add Twitter `creator`/`site` handles.
- SEO-12 (info) — no Core Web Vitals monitoring → add `web-vitals` + dashboard.
**i18n**
- I18N-03 (med) — error boundaries hardcoded English → translate `[locale]/error.tsx` at minimum.
- I18N-04 (med) — `formatNumber()` calls `toLocaleString()` without locale → pass active locale / `Intl.NumberFormat`.
- I18N-05 (med) — register `EnergySavingsCalculator` namespace in all 5 locale files (pairs with I18N-01).
- I18N-08 (low) — `getLocalizedData` fallback is English-only → optional locale-chain fallback.
**Accessibility / UX**
- A11Y-06 (med) — `ConsultationScheduler.tsx:394`: errors lack `aria-live`/`role=alert`.
- A11Y-07 (med) — form inputs lack `<label htmlFor>` associations.
- A11Y-10 (med) — 71 buttons default to `type=submit` → add `type=button` to non-submit buttons.
- A11Y-11 (med) — AI chat modal has no focus trap.
- A11Y-12 (med) — language dropdown lacks arrow-key navigation.
- A11Y-13 (med) — opacity-reduced nav text may fail 4.5:1 contrast → test + use solid colors.
- A11Y-14 (med) — success message lacks `role=status`/`aria-live`.
- A11Y-15 (low) — verify dark-mode coverage on newer pages + HQ panel.
**Code quality**
- CQ-05 (med) — oversized files (ApplicationClient 1208, chat/route 920, AssetBucketBrowser 874) → extract sub-components/tools.
- CQ-06 (med) — model-viewer `as any` casts → proper `.d.ts` type.
- CQ-08 (med) — inconsistent error-handling pattern across layers → standardize log-then-return/throw.
- CQ-09 (low) — `privacy/page.tsx:12`: hardcoded `privacy@rf-flux.com` (TODO unconfirmed) → env/SiteSettings + confirm with FLUX legal.
- CQ-10 (low) — repeated `as unknown as` casts on AI SDK responses → define interfaces.
- CQ-11 (low) — `i18nHelper.ts:27`: `console.error``log.error`.
**Infra / testing**
- INFRA-14 (med) — backup/restore lacks HMAC integrity → sign exports, verify on restore.
- INFRA-15 (med) — deploy doesn't verify commit signatures → `git verify-commit HEAD`.
- TEST-05 (med) — no i18n parity/fallback tests.
- TEST-06 (med) — error boundaries log to console, not the structured logger.
- TEST-07 (med) — server-component DB errors bubble ungracefully → per-query try/catch + degraded UI.
- TEST-08 (med) — no retry/backoff for transient email/Prisma/OpenAI failures.
- TEST-09 (med) — in-memory rate limit multiplies across replicas → require Redis for multi-instance.
- TEST-10 (med) — health check doesn't verify migrations/SMTP/OpenAI/env.
- TEST-11/12/13 (low) — telemetry truncation unlogged; no polyglot-file upload tests; `restoreDatabase()` partial-failure handling.
---
## 4. What's Already Strong
Credit where due — several things are done well and should be preserved:
- **Public-endpoint security baseline.** CSRF is correctly implemented on public endpoints, rate limiting covers chat, file uploads use magic-byte validation, and the consultation route already escapes HTML correctly (the model SEC-05 should copy). Recent commits added a **canonical-host guard and scanner-probe blocking** in Nginx — good hardening.
- **JWT signing discipline.** `session.ts`/`clientAuth.ts` correctly *throw* when `SESSION_SECRET` is missing rather than falling back — this is what saved SEC-01 from being critical.
- **i18n infrastructure.** Complete top-level key parity across all 5 locales (18 sections), correct next-intl integration, preserved `{count}`/`{app}` placeholders, and a well-built **AI translation glossary** that masks/restores protected brand/technical terms (FLUX, Radio Frequency, solid-state, RF, MHz/kHz/GHz, kW/kWh/MW). Pluralization (`componentFound`/`componentsFound`) is handled correctly.
- **SEO foundations.** Correct hreflang alternates, canonical URLs, breadcrumb JSON-LD on detail pages, and core schemas (Organization, LocalBusiness, WebSite, Article, Product). robots.txt disallow rules are correct; sitemap covers all public routes.
- **Caching & DB pooling.** Nginx edge caching, ISR, image optimization, and a real Prisma connection pool are all in place — a solid performance baseline.
- **Architecture awareness.** A structured JSON logger, safe-JSON helpers (`parseJsonField`), `escapeHtml`, and proper `cms.ts` types all exist — the gaps are *adoption consistency*, not missing infrastructure. bcrypt is used for password hashing; migrations are clean and idempotent (`IF NOT EXISTS` guards). FluxAI's 9-tool SPIN-funnel architecture is well-designed. Semantic HTML (`main`/`nav`/`header`/`footer`) and dark-mode coverage are largely in place.
---
## 5. Prioritized Remediation Roadmap
### NOW — blockers; do before any further production traffic
| Item | Finding | Effort |
|---|---|---|
| Rename/export middleware so auth actually runs | SEC-02 | **S** |
| Add session checks inside HQ server actions | SEC-03 | **M** |
| Add auth to `/api/assets` + `/api/branding` | SEC-04 | **S** |
| Rotate leaked OpenAI key + Gmail password; untrack `env` | INFRA-03 | **S** |
| Create the missing `ClientUser` migration | DB-01 | **S** |
| Escape HTML in operations email templates | SEC-05 | **S** |
| Whitelist chat context fields (prompt injection) | AI-01 | **S** |
| Remove the proxy session-secret fallback | SEC-01 | **S** |
### NEXT — operational resilience + data integrity
| Item | Finding | Effort |
|---|---|---|
| Automated nightly offsite encrypted backups + WAL/PITR | INFRA-05 | **M** |
| Container memory/CPU limits | INFRA-01 | **S** |
| Nginx healthcheck + `condition: service_healthy` | INFRA-02 | **S** |
| Deploy: failing health check + snapshot/rollback | INFRA-04 | **M** |
| AI telemetry retention policy + cleanup job | DB-05 | **M** |
| Encrypt / minimize stored chat message text | AI-03 | **M** |
| `buildSystemPrompt()` try/catch + fallback prompt | TEST-02 | **S** |
| Validate OpenAI key at startup + wrap `streamText` | TEST-03 | **S** |
| Idempotent consultation submission | TEST-04 | **M** |
| Cache system prompt (TTL) | PERF-01 | **S** |
| Add `GlobalNode.application` indexes | PERF-03 | **S** |
| Integration tests for the 4 critical API routes | TEST-01 | **L** |
| Monitoring + log aggregation + alerts | INFRA-06 | **M** |
| Fix `aria-label`s, skip link, Escape-to-close, reduced-motion, broken nav class | A11Y-02/03/04/05/08 | **M** |
| i18n: EnergySavingsCalculator + CartDrawer alert | I18N-01/02/05 | **M** |
### LATER — hardening, polish, scale
| Item | Finding | Effort |
|---|---|---|
| Migrate hot JSON `String` fields → `Jsonb` | DB-04 | **L** |
| Nonce-based CSP (drop unsafe-inline/eval) | INFRA-11 | **M** |
| OCSP stapling, logrotate, body timeout, keepalive tuning | INFRA-07/09/10/13 | **S** |
| Image + video sitemaps; VideoObject/FAQ/CaseStudy schema | SEO-02/03/04/10 | **M** |
| Reduce `any` usage; extract oversized files; standardize logging | CQ-01/02/04/05 | **L** |
| Lazy-load Three.js; trim `use client`; configurable pool | PERF-05/06/07 | **M** |
| Conversation history/resume; tool eval set; useChat onError | AI-02/04/10 | **M** |
| Remaining a11y (focus trap, labels, contrast, button types) | A11Y-06/07/10/11/12/13/14 | **M** |
| Locale-aware number formatting; translate error boundaries | I18N-03/04 | **S** |
---
## 6. False Positives Considered (refuted / materially downgraded)
These were checked and either refuted or substantially softened during verification — listed so you know they were examined, not missed:
- **SEC-07 (B2B `/parts` not redirecting)** — *refuted as a security issue.* The page returns 200 but never exposes parts data (empty array), and shows a proper "Access Restricted" locked-state UI. A style/pattern preference, not a leak. Downgraded high → low.
- **CQ-07 line-450 "undefined `.score`"** — *refuted.* The `scored` array always initializes `score` (line 415, returned 443); `.score` can't be undefined there. The surrounding `any`-typing concern remains valid; the crash claim does not.
- **CQ-03 (lines 600/609 + CaseStudyModal "silent failure")** — *partially refuted.* Lines 600/609 are inside a try/catch with `.w/.h/.d` validation, and CaseStudyModal:189-197 has real try/catch. Only the `sectionsJson`/`advantagesJson` parse at lines 1026-1027 is genuinely unguarded.
- **INFRA-08 (cache poisoning)** — *partially refuted.* "Session cookies lost for users" and "sensitive header disclosure" are false (authenticated requests bypass cache via `$cookie_flux_session`). Residual risk is limited to poisoning public pages with non-sensitive cookies.
- **INFRA-02 / INFRA-04 (nginx + deploy)** — *softened, not refuted.* The "deadlock"/"no-recovery" framing was overstated (startup race + manual recovery, not hangs); both remain real high-severity gaps.
- **INFRA-09 / INFRA-10 (body timeout / logrotate)** — *softened.* nginx's 60s default timeout and ephemeral (unmounted) log storage reduce the blast radius; both downgraded high → medium.
- **DB-02 / DB-03 / PERF-02 / A11Y-01 / A11Y-09 / INFRA-11** — *confirmed but downgraded* (see §2) where verification found mitigating context (other models' parity, dropdown-constrained input, ISR-only timing, adjacent visible text, muted decorative video, compensating input-validation controls).
---
*Bottom line, David: the build quality is real, but the auth layer is currently a no-op and the platform has no backups, no monitoring, and live secrets in git. The "NOW" block is mostly small, surgical changes — SEC-02 alone is a one-file fix that re-activates a security layer you already wrote. Land that block first, then the operational resilience in "NEXT," and this moves from "risky to run" to "production-ready" quickly.*
@@ -0,0 +1,639 @@
# FLUX SRL — Website Engineering Report
**Project:** rf-flux.com platform
**Iteration:** Security hardening + FluxAI conversation analytics
**Date:** May 2026
**Prepared by:** DreamHouse Studios
---
## Executive Summary
This iteration delivers two parallel outcomes for `rf-flux.com`:
1. **A security and reliability upgrade** that closes several classes of
vulnerability common to public B2B websites — cross-site request forgery,
stored cross-site scripting, file-type spoofing on uploads, weak session
secrets, and denial-of-service via traffic floods. The site now meets the
baseline expected of an enterprise property.
2. **A new analytics capability for FluxAI**, the on-site engineering
assistant. Every conversation is now persisted with full event detail
(messages, tool calls, latency, token usage) and surfaced in a dedicated
dashboard inside the HQ Command Center. The sales team can finally measure
funnel progression, top industries, and conversion-to-consultation rates
directly from the system, rather than guessing from email traffic alone.
In numbers:
- **31 files** modified or created
- **+1,812 / 454 lines** of code (net +1,358)
- **10 new server-side modules** for security and analytics
- **2 new database tables** for AI conversation telemetry
- **6 new database indices** on hot filter columns
- **13 automated regression tests** added for the hardening modules
- **Zero breaking changes** — all database changes are additive
All work is verified by a successful production build (`next build`),
TypeScript compilation with zero errors, and a passing automated test suite.
---
## 1. Security Hardening
### 1.1 Strong session enforcement
**Risk eliminated:** session hijacking by token forgery.
The previous code allowed the server to start with a hard-coded fallback
secret (`"FLUX_SUPER_SECRET_KEY_2026_ARCHITECTURE"`) if the `SESSION_SECRET`
environment variable failed to load. Because that fallback string was visible
in the source tree, any attacker who read the public repository could mint
valid 7-day admin JWTs and walk into the HQ Command Center as any user.
The application now refuses to start without a `SESSION_SECRET` of at least
32 characters. A weak or missing value is a fatal error, surfaced at boot
time rather than silently accepted. The same protection is applied to the
B2B client portal authentication path (`clientAuth.ts`).
**Operational note:** the production VPS must have a strong secret in its
`.env` file before the next deploy. The recommended generator is
`openssl rand -base64 48`.
### 1.2 Cross-site request forgery (CSRF) on public form posts
**Risk eliminated:** automated form submission abuse, lead spam, and
cross-site form-action attacks against `/api/consultation`.
The consultation form endpoint was previously accepting any POST request
with a valid JSON body. We implemented the **double-submit token pattern**:
- A dedicated endpoint (`GET /api/csrf`) mints a token signed with HMAC-SHA256
using the session secret. The token is delivered both as a cookie and in
the JSON response body. It expires after one hour.
- The form's submission code copies the token into the `X-CSRF-Token` header.
- The consultation endpoint verifies that cookie and header match and that
the HMAC is valid before processing any data.
Stateless verification means no database lookup is required. Tokens cannot
be forged or replayed.
### 1.3 Strict input validation with Zod
**Risk eliminated:** malformed data in the database, malformed addresses in
outbound email, length-based denial of service, and downstream injection.
Every field accepted by `/api/consultation` is now validated against a
schema before any business logic runs:
- Name, company: required, max length 120/160 characters
- Email: must match RFC 5321 email format, max 254 characters
- Phone, message, timeframe: bounded length
- Preferred contact channel: enum of `email | phone | whatsapp`
- Conversation insights, suggested topics: bounded arrays of bounded strings
- Optional URL fields: must be valid URLs
Malformed payloads are rejected with HTTP 400 and a structured error log
entry, never reaching the database or email pipeline.
### 1.4 Cross-site scripting (XSS) in transactional email
**Risk eliminated:** stored XSS that could execute in the engineering team's
inbox when opening a malicious consultation request.
The consultation email template was concatenating client-supplied strings
(name, company, email, message, AI-detected industry labels) directly into
raw HTML. An attacker submitting a name like `<script>...</script>` would
have that markup rendered as live HTML when the email was opened in any
permissive client.
We introduced a small escape library (`src/lib/escapeHtml.ts`) and applied
it to every interpolated value in the template. Mail-to links are validated
with a strict regex and URL-encoded before reaching the `href` attribute.
### 1.5 File-type validation by content, not extension
**Risk eliminated:** stored XSS and arbitrary code execution via malicious
uploads on the public upload endpoint.
Previously, `/api/public-upload` trusted the file extension provided by the
client. A user could rename `payload.html` to `image.png` and the server
would save it as-is. Browsers reading the file later might still interpret
it as HTML, depending on response headers — a classic vector.
We added a magic-byte detector (`src/lib/fileType.ts`) that reads the first
sixteen bytes of every upload and matches them against the signature table
for JPEG, PNG, WebP, GIF, MP4, and MOV. Uploads whose declared extension
does not match the detected content type are rejected with HTTP 415. The
verification happens **before** the buffer is written to disk.
### 1.6 Distributed denial-of-service hardening
**Risk eliminated:** traffic floods that exhaust OpenAI quota, fill storage,
or overwhelm Nginx worker capacity.
The previous rate limit was tied to a per-process in-memory map. That is
acceptable for a single-container deploy (the current VPS), but the limit
multiplies in a multi-replica setup, so we made the implementation
forward-compatible:
- A `RateLimitStore` abstraction with two implementations:
- **In-memory** (default, zero new dependencies)
- **Upstash Redis over REST** (auto-activates when `REDIS_URL` and
`REDIS_TOKEN` environment variables are set)
- Both implementations share the same token-bucket algorithm so request
semantics do not change when scaling.
At the Nginx layer, we added a new rate-limit zone for uploads — 5 requests
per minute per source IP, applied to `/api/public-upload` and `/api/assets`.
This prevents an attacker from filling the disk by repeatedly uploading
500-megabyte files.
### 1.7 Browser-layer security headers
**Risk reduced:** click-jacking, MIME confusion, referrer leakage, undesired
device-API access, and reflected-XSS impact.
Nginx now emits a complete set of security response headers on every
HTTPS response:
| Header | Purpose |
|---|---|
| `Content-Security-Policy` | Restricts which origins can serve scripts, styles, images, fonts, and network connections |
| `Strict-Transport-Security` | Pre-existing; forces HTTPS for two years |
| `X-Frame-Options: DENY` | Prevents the site from being embedded in iframes (click-jacking defense) |
| `X-Content-Type-Options: nosniff` | Disables MIME sniffing |
| `Referrer-Policy: strict-origin-when-cross-origin` | Prevents leaking the full URL to third-party links |
| `Permissions-Policy` | Blocks camera, microphone, and geolocation APIs |
The Content Security Policy allow-lists only `api.openai.com` and the
Upstash REST endpoint for outbound connections. Inline scripts and styles
remain permitted for now because Next.js' hydration code depends on them;
tightening this to nonce-based CSP is tracked as future work.
---
## 2. Code Quality and Performance
### 2.1 Dead code removal
`GlobalOperations_old.tsx` (310 lines, no references) was removed. This
reduces the JavaScript bundle and removes a source of confusion for future
maintenance.
### 2.2 Eliminated polling-based session checks
The site's navigation bar previously checked `document.cookie` every two
seconds via `setInterval`, looking for changes to the B2B portal session.
Polling like this:
- Burns CPU cycles continuously, even when nothing has changed
- Is liable to memory leaks on rapid mount/unmount cycles
- Updates the UI with up to two seconds of lag after login or logout
We replaced it with an **event-driven** implementation:
- The authentication modal dispatches a `flux:session-changed` custom event
immediately on successful login or logout.
- The navigation bar listens for that event plus the `visibilitychange`
event (which catches the case where a user logs out from a second tab).
- No interval, no polling, no lag.
### 2.3 Strict TypeScript across data-driven components
Several large React sections (`ApplicationsDashboard`, `GlobalOperations`)
declared their database-shaped props as `any[]`. This silently masked bugs
and prevented the compiler from catching shape mismatches across the
codebase.
We introduced `src/types/cms.ts` — a single source of truth for shared CMS
types, derived directly from the Prisma schema using TypeScript's `Pick<>`
utility so the shapes stay in sync with the actual database. Component
props were updated to use these named types. JSON-string fields (`galleryJson`,
`dashboardMetricsJson`, etc.) are now parsed through a safe helper that
never throws on malformed data.
### 2.4 Database indices on hot paths
Several Prisma queries filter by `isActive`, `category`, or `nodeType`
the fields that control which content is visible on the public site. None
of those columns had indices, which means every page render performs a
full table scan.
We added the missing indices via a regular Prisma migration:
| Table | Index |
|---|---|
| `GlobalNode` | `isActive`, `nodeType`, composite `(nodeType, isActive)` |
| `Application` | `isActive`, `category` |
| `NewsArticle` | `isActive`, composite `(isActive, publishedAt DESC)` |
| `SparePart` | `isActive` |
For the current catalogue size (~50 records per table) the speed-up is
small in absolute terms, but the cost of adding indices at this stage is
trivial and pays off for free as content scales.
### 2.5 Structured JSON logging
The codebase had `console.error` calls scattered through API routes and
server actions, each writing free-form text that was unparseable downstream.
We introduced `src/lib/logger.ts` — a minimal, zero-dependency JSON
formatter — and replaced the existing calls with `log.info`, `log.warn`,
and `log.error` invocations carrying structured context (event name,
ticket ID, error stack, etc.).
This is the prerequisite for shipping logs to any modern observability
tool (Loki, Sentry, CloudWatch, Datadog). Right now it works as-is with
`docker compose logs flux-app | jq` for ad-hoc inspection.
---
## 3. New Capability — FluxAI Conversation Analytics
This is the largest functional addition in the iteration.
### 3.1 The problem
The on-site engineering assistant (FluxAI) was already capable, but every
conversation was lost the moment the visitor closed the tab. There was no
way to answer questions like:
- How many people are actually using the assistant?
- Which industries are they coming from?
- What fraction of conversations lead to a consultation request?
- Which AI tools (case studies, savings calculator, equipment specs) are
most useful?
- How long does a typical conversation last?
- Are visitors getting stuck at any particular point?
This iteration adds full persistence and a dedicated dashboard.
### 3.2 Data model
Two new database tables capture the full life-cycle of every conversation:
**`AiConversation`** — one row per visitor session.
| Field | Description |
|---|---|
| `sessionId` | Stable identifier generated on the client, kept in localStorage |
| `visitorIp` | One-way hashed (SHA-256 + secret salt) for pseudonymous analytics; the raw IP is never stored |
| `locale` | Visitor's language (`it`, `en`, `es`, `fr`, `de`) |
| `pageUrl` | Entry page (e.g. `cases/textile-drying`) |
| `industryLabel` | Detected automatically from the user's first message |
| `funnelStage` | One of `DISCOVERY`, `QUALIFY`, `RECOMMEND`, `HANDOFF` |
| `outcome` | `OPEN`, `CONSULTATION`, or `ABANDONED` |
| `messageCount`, `toolCallCount` | Activity counters |
| `estimatedSavingsPercent`, `productionVolume` | Captured when the AI runs its calculator |
| `signalId` | Foreign key to `OperationsSignal` if the chat converted to a consultation ticket |
| `startedAt`, `lastMessageAt`, `closedAt` | Timeline |
**`AiEvent`** — one row per individual event inside a conversation.
| Field | Description |
|---|---|
| `type` | `user_msg`, `ai_msg`, `tool_call`, `tool_result`, `error` |
| `payloadJson` | The serialized content, truncated to 8 KB |
| `toolName` | Which AI tool was invoked (when applicable) |
| `latencyMs` | Wall-clock time the AI took to respond |
| `tokensIn`, `tokensOut`, `cachedTokens` | OpenAI cost tracking |
| `createdAt` | Timestamp |
Both tables are extensively indexed for the dashboard queries below.
### 3.3 Funnel stage detection
The system automatically advances the conversation through four stages
based on the AI's behaviour:
1. **DISCOVERY** — initial state, before any industry is identified.
2. **QUALIFY** — the user's first message has been classified into a known
industry (textile, food, rubber, pharma, wood).
3. **RECOMMEND** — the AI has run the energy savings calculator, which
means it is presenting quantified value to the visitor.
4. **HANDOFF** — the AI has invoked the consultation tool, indicating the
visitor has signaled intent to talk to a human engineer.
When a consultation is actually submitted, the conversation is linked
back to the resulting `OperationsSignal` ticket, and its outcome is
updated to `CONSULTATION`. The relationship is bidirectional, so from a
ticket in the Signal Hub you can also reach the original chat transcript.
### 3.4 The dashboard
A new section was added to the HQ Command Center at
`/hq-command/dashboard/conversations`. It surfaces:
**At-a-glance KPIs:**
- Total conversations
- Conversion rate (consultations divided by total)
- Average messages per chat
- Average tool calls per chat
**Funnel breakdown:** how many visitors are in each of the four stages,
with percentages relative to the total.
**Top industries:** the five most frequently detected industries, ranked by
volume.
**Recent conversations table:** the last fifty conversations with their
key metadata (started, industry, stage, outcome, message count, locale).
**Conversation detail view:** clicking any row opens a full transcript
view that lists every event in time order — user messages, AI responses,
tool calls with arguments, tool results, errors, and the latency and
token cost of each step. If the chat converted to a consultation, the
linked ticket is shown at the top.
### 3.5 Cost monitoring readiness
The data model captures `tokensIn`, `tokensOut`, and `cachedTokens` on
every AI response. Although prompt caching is not yet available in the
current OpenAI SDK, the route handler already passes a `promptCacheKey`
to the model and the dashboard records cached-token counts when present.
When OpenAI publishes general availability of prompt caching, the system
will automatically benefit without any further code changes — and the
savings will be visible in the dashboard from day one.
### 3.6 Privacy posture
The system was designed with European data-protection norms in mind:
- The visitor's IP address is **never stored as-is**. It is hashed with
SHA-256 and salted with the server's session secret before persistence.
- Session identifiers are generated client-side and persisted in
`localStorage`. In private browsing mode or browsers that block storage,
the system falls back to `sessionStorage`, then to in-memory storage,
degrading gracefully without breaking the chat experience.
- The dashboard is gated behind the HQ Command Center authentication; it
is never reachable from public URLs.
---
## 4. Infrastructure Improvements
### 4.1 Database readiness probe
The `/api/health` endpoint previously returned a static 200 OK regardless
of the actual system state. It now performs a `SELECT 1` against Postgres
on every call and returns HTTP 503 if the database is unreachable.
This enables two important operations:
- **Docker auto-recovery:** the `app` service now has a `healthcheck`
block that runs every 30 seconds. Docker will restart the container if
the check fails repeatedly.
- **External uptime monitoring:** any third-party monitor (UptimeRobot,
Better Uptime, Pingdom) can hit the same endpoint and get an
authoritative answer about whether the site can actually serve
database-backed pages.
### 4.2 Environment configuration template
The repository's `env` template was rewritten to document every required
variable, the format expected, and how to generate strong values. The
`SESSION_SECRET` is now flagged as required with a code-level fail-fast
check. Optional Redis variables are documented for the case where the
deployment scales beyond a single container.
### 4.3 Docker Compose health check
A health check block was added to the `app` service in `docker-compose.yml`:
```yaml
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://localhost:3000/api/health')...\""]
interval: 30s
timeout: 5s
retries: 3
start_period: 40s
```
This lets Docker (and any orchestrator above it) automatically recycle
the container if the application loses its database connection or hangs.
---
## 5. Quality Assurance
### 5.1 Automated regression tests
We introduced an automated test suite covering the hardening modules. The
suite is run via `npm run test:ai` and uses Node.js' built-in test runner
— no new dependencies are added to the project. Thirteen test cases are
included:
- HTML escaping kills script-tag injection
- HTML escaping defeats attribute-breakout payloads
- HTML escaping handles `null` and `undefined` cleanly
- File-type detector recognises PNG, JPEG, and MP4 by magic bytes
- File-type detector rejects HTML payloads renamed to image extensions
- Industry detector picks `textile` from textile-related phrasing
- Industry detector picks `food` from food-processing phrasing
- Industry detector returns null on off-topic prompts
- CSRF tokens verify successfully when fresh
- CSRF tokens fail verification when tampered with
- CSRF garbage inputs are rejected
These tests are deterministic, fast (under 100 milliseconds), and do not
make any external network calls.
### 5.2 Production build verification
The full Next.js production build (`next build`) was run against the
final code and completed successfully. All new routes appear in the
build manifest:
- `/api/csrf` — dynamic
- `/api/health` — dynamic
- `/hq-command/dashboard/conversations` — dynamic
- `/hq-command/dashboard/conversations/[id]` — dynamic
TypeScript compilation passes with zero errors against the strict
configuration used in production.
---
## 6. Deployment and Operations
### 6.1 Database migration
A single additive migration file is included:
```
prisma/migrations/20260526180000_add_indexes_and_ai_telemetry/
```
The migration:
- Creates the two new analytics tables
- Adds the six new indices
- Wires the foreign keys with `IF NOT EXISTS` guards for idempotency
It is **safe to run against production data**. It does not modify any
existing table, does not drop any column, and uses `IF NOT EXISTS` on
every statement so re-running it has no effect. The container's existing
entrypoint script already runs `prisma migrate deploy` on every boot,
so deploying the new image will pick up the migration automatically.
### 6.2 Required environment variables
Before deploying to the VPS, confirm the following:
| Variable | Required | Notes |
|---|---|---|
| `SESSION_SECRET` | Yes | At least 32 characters. Generated via `openssl rand -base64 48`. The app will refuse to start without it. |
| `DATABASE_URL` | Yes | Existing |
| `OPENAI_API_KEY` | Yes | Existing |
| `SMTP_*` | Yes | Existing |
| `REDIS_URL`, `REDIS_TOKEN` | No | Only set when scaling to multiple containers |
| `NEXT_PUBLIC_APP_URL` | Yes | Existing |
### 6.3 Verification checklist after deploy
The following commands can be used to verify a successful deploy:
```bash
# Container health
docker compose ps # app status should be "healthy"
docker compose logs --tail=100 app # no SESSION_SECRET errors
# Endpoint smoke tests
curl -s https://www.rf-flux.com/api/health
# expected: {"ok":true,"db":"up","latencyMs":N,"ts":"..."}
curl -I https://www.rf-flux.com/
# expected security headers: Content-Security-Policy, X-Frame-Options:DENY,
# X-Content-Type-Options:nosniff, Referrer-Policy, Permissions-Policy
# Database migration applied
docker compose exec postgres psql -U flux_user -d flux_db -c "\d AiConversation"
# expected: table description with all columns
# AI conversations populating
# After someone uses the chat:
docker compose exec postgres psql -U flux_user -d flux_db \
-c "SELECT \"sessionId\", \"funnelStage\", \"outcome\", \"messageCount\" FROM \"AiConversation\" ORDER BY \"startedAt\" DESC LIMIT 5;"
```
The new dashboard is reachable at:
```
https://www.rf-flux.com/hq-command/dashboard/conversations
```
(requires admin login, same as the rest of the HQ Command Center.)
---
## 7. Known Limitations and Recommendations
### 7.1 Items intentionally deferred
- **Content Security Policy nonces.** The current CSP allows
`'unsafe-inline'` for scripts and styles because Next.js hydration
depends on them. Migrating to nonce-based CSP would require changes to
`next.config.ts` and the build pipeline. This is a known follow-up.
- **Prompt caching for the AI.** The OpenAI SDK does not yet expose
prompt caching to consumers. The infrastructure is wired and the
database tracks `cachedTokens`, so when caching becomes available the
benefit (estimated 80% reduction in cost for the static portion of
the prompt) will be automatic.
- **Email sequence automation, lead scoring, CRM integration.** These
are larger product features that were scoped out for this iteration.
### 7.2 Recommended next steps
1. **Rotate the OpenAI API key.** The current key is present in earlier
commits of the public repository. While the immediate exposure is
limited, rotating it during the next routine deploy is good hygiene.
2. **Rotate the SMTP password.** Same reasoning as above.
3. **Move the `env` file out of version control.** A follow-up commit
should convert `env` into `.env.example` (containing only placeholders)
and add `env` to the `.gitignore`. The real `.env` is already
gitignored, so this is the final step in eliminating secrets from
the repository.
4. **Consider Sentry or equivalent error aggregation.** The structured
logger introduced in this iteration is the prerequisite. Wiring it
to a hosted aggregation service is a half-day task and dramatically
improves time-to-detection for production errors.
5. **Schedule a 30-day review of the conversation dashboard data.** The
analytics will be most useful after a month of real traffic. At that
point we can identify the highest-impact funnel-stage improvements
based on actual visitor behaviour.
---
## Appendix A — Files Modified or Created
**New files (10):**
| File | Purpose |
|---|---|
| `src/lib/csrf.ts` | CSRF token issuance and verification |
| `src/lib/escapeHtml.ts` | HTML escaping helpers |
| `src/lib/fileType.ts` | Magic-byte file-type detection |
| `src/lib/logger.ts` | Structured JSON logger |
| `src/lib/aiSessionId.ts` | Client-side session ID with privacy fallbacks |
| `src/types/cms.ts` | Shared CMS type definitions |
| `src/app/api/csrf/route.ts` | CSRF token issuance endpoint |
| `src/app/api/health/route.ts` | Database readiness probe |
| `src/app/hq-command/dashboard/conversations/page.tsx` | Analytics dashboard |
| `src/app/hq-command/dashboard/conversations/[id]/page.tsx` | Conversation detail view |
| `prisma/migrations/20260526180000_add_indexes_and_ai_telemetry/migration.sql` | Additive database migration |
| `tests/ai/golden.test.mjs` | Regression test suite |
**Modified files (19):**
| File | Change |
|---|---|
| `src/lib/session.ts` | Fail-fast on missing or weak `SESSION_SECRET` |
| `src/lib/rateLimit.ts` | Pluggable backend (in-memory or Redis) |
| `src/app/actions/clientAuth.ts` | Same fail-fast as `session.ts` |
| `src/app/api/chat/route.ts` | AI telemetry persistence and prompt cache key |
| `src/app/api/consultation/route.ts` | CSRF + Zod + escapeHtml |
| `src/app/api/public-upload/route.ts` | Magic-byte validation |
| `src/components/layout/NavBar.tsx` | Event-driven session check |
| `src/components/ai/SilentObserver.tsx` | Sends sessionId in transport body |
| `src/components/ai/ConsultationScheduler.tsx` | Sends CSRF token in form post |
| `src/components/sections/ApplicationsDashboard.tsx` | Strict types replace `any[]` |
| `src/components/sections/GlobalOperations.tsx` | Strict types replace `any[]` |
| `src/app/[locale]/parts/_components/AuthModal.tsx` | Dispatches session-changed event |
| `src/app/hq-command/dashboard/page.tsx` | Tile for the new conversations dashboard |
| `prisma/schema.prisma` | New models, indices, back-reference on `OperationsSignal` |
| `nginx/conf.d/flux.conf` | Security headers, upload rate-limit zone |
| `docker-compose.yml` | Health check, optional Redis env vars |
| `package.json` | `npm run test:ai` script |
| `env` | Documented `SESSION_SECRET` requirement and Redis variables |
**Removed files (1):**
| File | Reason |
|---|---|
| `src/components/sections/GlobalOperations_old.tsx` | Unreferenced legacy code (310 lines) |
---
## Appendix B — Quick Reference for the Sales Team
For team members who want to use the new analytics without engineering help:
1. Log in to the HQ Command Center at `https://www.rf-flux.com/hq-command`.
2. From the main dashboard, click the **FluxAI Conversations** tile (cyan
sparkle icon, last position in the grid).
3. The top four cards show overall numbers: total conversations,
conversion rate, average messages, average tool calls.
4. The two panels below show the funnel breakdown and the most common
industries.
5. The table lists the last fifty conversations. Click **Open** on any
row to see the full transcript.
6. Conversations that converted to a consultation ticket display the
ticket ID in green at the top of the detail view.
The data updates in real time — no refresh needed between visits.
---
*End of report.*
+20 -2
View File
@@ -5,8 +5,26 @@
#:xDATABASE_URL="postgresql://flux_user:STRONG_PASSWORD@postgres:5432/flux_db?schema=public" #:xDATABASE_URL="postgresql://flux_user:STRONG_PASSWORD@postgres:5432/flux_db?schema=public"
DATABASE_URL="postgresql://flux_user:dev_password@localhost:5432/flux_db?schema=public" DATABASE_URL="postgresql://flux_user:dev_password@localhost:5432/flux_db?schema=public"
#FLUX SECRET Esto no se que hace # SESSION_SECRET (REQUIRED, min 32 chars).
SESSION_SECRET="FLUX_SUPER_SECRET_KEY_2026_ARCHITECTURE" # Used to sign 7-day admin JWTs in src/lib/session.ts and CSRF tokens in
# src/lib/csrf.ts. The app refuses to boot without it. Generate with:
# openssl rand -base64 48
SESSION_SECRET="CHANGE_ME_openssl_rand_base64_48_min_32_chars"
# Optional: multi-instance rate limiting via Upstash Redis REST API.
# Leave both unset to use the in-memory bucket store (fine for single VPS).
#REDIS_URL="https://xxx.upstash.io"
#REDIS_TOKEN="xxxxx"
# Google Analytics 4 Measurement ID (format: G-XXXXXXXXXX).
# Leave empty to disable analytics entirely — the site loads no Google
# scripts and the consent banner stays hidden until this is set.
# This is a PUBLIC value (it ships in the page HTML), safe to commit.
NEXT_PUBLIC_GA_ID="G-KQ1JRV3KN7"
# Google Search Console verification token (the content="" value from the
# HTML-tag verification method). Leave empty if you verify via DNS or GA.
NEXT_PUBLIC_GSC_VERIFICATION=""
# OPEN AI KEY # OPEN AI KEY
OPENAI_API_KEY=sk-proj-dsdsdsds-kaDAHhZXPYsK6-4uw0UWJZ0YuLnQfVoEA OPENAI_API_KEY=sk-proj-dsdsdsds-kaDAHhZXPYsK6-4uw0UWJZ0YuLnQfVoEA
+25 -2
View File
@@ -1,10 +1,25 @@
{ {
"Consent": {
"title": "Wir schätzen Ihre Privatsphäre",
"body": "Wir verwenden Analyse-Cookies, um zu verstehen, wie Besucher unsere Website nutzen, und um sie zu verbessern. Es werden keine Daten erfasst, bis Sie zustimmen.",
"learnMore": "Datenschutzerklärung",
"accept": "Akzeptieren",
"decline": "Ablehnen"
},
"Navigation": { "Navigation": {
"applications": "Anwendungen", "applications": "Anwendungen",
"globalMap": "Weltkarte", "globalMap": "Weltkarte",
"ourStory": "Unsere Geschichte", "ourStory": "Unsere Geschichte",
"parts": "Ersatzteile", "parts": "Ersatzteile",
"insideFlux": "Inside Flux" "insideFlux": "Inside Flux",
"team": "Team"
},
"TeamPage": {
"eyebrow": "Unser Team",
"title1": "Die Köpfe hinter",
"title2": "der Leistung.",
"description": "Vier Jahrzehnte RF-Ingenieurskompetenz, verkörpert von den Menschen, die jedes FLUX-System entwerfen, bauen und betreuen.",
"empty": "Die Profile unseres Teams sind in Kürze verfügbar."
}, },
"HeroReel": { "HeroReel": {
"title1": "Innovation,", "title1": "Innovation,",
@@ -144,7 +159,8 @@
"eventOverview": "Veranstaltungsübersicht", "eventOverview": "Veranstaltungsübersicht",
"projectChronicle": "Projektchronik", "projectChronicle": "Projektchronik",
"pendingData": "[ Chronikdaten für diesen Knoten ausstehend ]", "pendingData": "[ Chronikdaten für diesen Knoten ausstehend ]",
"mediaGallery": "Mediengalerie" "mediaGallery": "Mediengalerie",
"viewFullCase": "Vollständige Fallstudie ansehen"
}, },
"Footer": { "Footer": {
"madeInItaly": "Hergestellt in Italien", "madeInItaly": "Hergestellt in Italien",
@@ -184,6 +200,13 @@
"page": "Seite", "page": "Seite",
"of": "von" "of": "von"
}, },
"ArticlePage": {
"backToNewsHub": "Zurück zum News Hub",
"backToNews": "Zurück zu Nachrichten",
"mediaGallery": "Mediengalerie",
"joinLinkedIn": "Diskussion auf LinkedIn beitreten",
"internalRelease": "Interne Unternehmensmitteilung"
},
"AuthModal": { "AuthModal": {
"b2bPortal": "B2B-Portal", "b2bPortal": "B2B-Portal",
"signIn": "Anmelden", "signIn": "Anmelden",
+25 -2
View File
@@ -1,10 +1,25 @@
{ {
"Consent": {
"title": "We value your privacy",
"body": "We use analytics cookies to understand how visitors use our site and to improve it. No data is collected until you accept.",
"learnMore": "Privacy Policy",
"accept": "Accept",
"decline": "Decline"
},
"Navigation": { "Navigation": {
"applications": "Applications", "applications": "Applications",
"globalMap": "Global Map", "globalMap": "Global Map",
"ourStory": "Our Story", "ourStory": "Our Story",
"parts": "Spare Parts", "parts": "Spare Parts",
"insideFlux": "Inside Flux" "insideFlux": "Inside Flux",
"team": "Team"
},
"TeamPage": {
"eyebrow": "Our Team",
"title1": "The minds behind",
"title2": "the power.",
"description": "Four decades of RF engineering expertise, embodied by the people who design, build and support every FLUX system.",
"empty": "Our team profiles are coming soon."
}, },
"HeroReel": { "HeroReel": {
"title1": "Innovation,", "title1": "Innovation,",
@@ -144,7 +159,8 @@
"eventOverview": "Event Overview", "eventOverview": "Event Overview",
"projectChronicle": "Project Chronicle", "projectChronicle": "Project Chronicle",
"pendingData": "[ Chronicle data pending for this node ]", "pendingData": "[ Chronicle data pending for this node ]",
"mediaGallery": "Media Gallery" "mediaGallery": "Media Gallery",
"viewFullCase": "View full case study"
}, },
"Footer": { "Footer": {
"madeInItaly": "Made in Italy", "madeInItaly": "Made in Italy",
@@ -184,6 +200,13 @@
"page": "Page", "page": "Page",
"of": "of" "of": "of"
}, },
"ArticlePage": {
"backToNewsHub": "Back to News Hub",
"backToNews": "Back to News",
"mediaGallery": "Media Gallery",
"joinLinkedIn": "Join the conversation on LinkedIn",
"internalRelease": "Internal Corporate Release"
},
"AuthModal": { "AuthModal": {
"b2bPortal": "B2B Portal", "b2bPortal": "B2B Portal",
"signIn": "Sign In", "signIn": "Sign In",
+25 -2
View File
@@ -1,10 +1,25 @@
{ {
"Consent": {
"title": "Respetamos tu privacidad",
"body": "Usamos cookies analíticas para entender cómo los visitantes usan nuestro sitio y mejorarlo. No se recopila ningún dato hasta que aceptes.",
"learnMore": "Política de privacidad",
"accept": "Aceptar",
"decline": "Rechazar"
},
"Navigation": { "Navigation": {
"applications": "Aplicaciones", "applications": "Aplicaciones",
"globalMap": "Mapa Global", "globalMap": "Mapa Global",
"ourStory": "Nuestra Historia", "ourStory": "Nuestra Historia",
"parts": "Repuestos", "parts": "Repuestos",
"insideFlux": "Inside Flux" "insideFlux": "Inside Flux",
"team": "Equipo"
},
"TeamPage": {
"eyebrow": "Nuestro Equipo",
"title1": "Las mentes detrás",
"title2": "de la potencia.",
"description": "Cuatro décadas de experiencia en ingeniería de RF, encarnadas por las personas que diseñan, construyen y dan soporte a cada sistema FLUX.",
"empty": "Los perfiles de nuestro equipo estarán disponibles pronto."
}, },
"HeroReel": { "HeroReel": {
"title1": "Innovación,", "title1": "Innovación,",
@@ -144,7 +159,8 @@
"eventOverview": "Resumen del Evento", "eventOverview": "Resumen del Evento",
"projectChronicle": "Crónica del Proyecto", "projectChronicle": "Crónica del Proyecto",
"pendingData": "[ Datos de crónica pendientes para este nodo ]", "pendingData": "[ Datos de crónica pendientes para este nodo ]",
"mediaGallery": "Galería de Medios" "mediaGallery": "Galería de Medios",
"viewFullCase": "Ver el caso completo"
}, },
"Footer": { "Footer": {
"madeInItaly": "Hecho en Italia", "madeInItaly": "Hecho en Italia",
@@ -184,6 +200,13 @@
"page": "Página", "page": "Página",
"of": "de" "of": "de"
}, },
"ArticlePage": {
"backToNewsHub": "Volver al News Hub",
"backToNews": "Volver a Noticias",
"mediaGallery": "Galería de Medios",
"joinLinkedIn": "Únete a la conversación en LinkedIn",
"internalRelease": "Comunicado Corporativo Interno"
},
"AuthModal": { "AuthModal": {
"b2bPortal": "Portal B2B", "b2bPortal": "Portal B2B",
"signIn": "Iniciar Sesión", "signIn": "Iniciar Sesión",
+25 -2
View File
@@ -1,10 +1,25 @@
{ {
"Consent": {
"title": "Rispettiamo la tua privacy",
"body": "Utilizziamo cookie analitici per capire come i visitatori usano il nostro sito e per migliorarlo. Nessun dato viene raccolto finché non accetti.",
"learnMore": "Informativa sulla privacy",
"accept": "Accetta",
"decline": "Rifiuta"
},
"Navigation": { "Navigation": {
"applications": "Applicazioni", "applications": "Applicazioni",
"globalMap": "Mappa Globale", "globalMap": "Mappa Globale",
"ourStory": "La nostra Storia", "ourStory": "La nostra Storia",
"parts": "Ricambi", "parts": "Ricambi",
"insideFlux": "Inside Flux" "insideFlux": "Inside Flux",
"team": "Team"
},
"TeamPage": {
"eyebrow": "Il nostro Team",
"title1": "Le menti dietro",
"title2": "la potenza.",
"description": "Quattro decenni di competenza ingegneristica RF, incarnati dalle persone che progettano, costruiscono e supportano ogni sistema FLUX.",
"empty": "I profili del nostro team saranno disponibili a breve."
}, },
"HeroReel": { "HeroReel": {
"title1": "Innovazione,", "title1": "Innovazione,",
@@ -144,7 +159,8 @@
"eventOverview": "Panoramica Evento", "eventOverview": "Panoramica Evento",
"projectChronicle": "Cronaca del Progetto", "projectChronicle": "Cronaca del Progetto",
"pendingData": "[ Dati cronaca in attesa per questo nodo ]", "pendingData": "[ Dati cronaca in attesa per questo nodo ]",
"mediaGallery": "Galleria Media" "mediaGallery": "Galleria Media",
"viewFullCase": "Vedi il caso completo"
}, },
"Footer": { "Footer": {
"madeInItaly": "Made in Italy", "madeInItaly": "Made in Italy",
@@ -184,6 +200,13 @@
"page": "Pagina", "page": "Pagina",
"of": "di" "of": "di"
}, },
"ArticlePage": {
"backToNewsHub": "Torna al News Hub",
"backToNews": "Torna alle Notizie",
"mediaGallery": "Galleria Media",
"joinLinkedIn": "Partecipa alla conversazione su LinkedIn",
"internalRelease": "Comunicato Aziendale Interno"
},
"AuthModal": { "AuthModal": {
"b2bPortal": "Portale B2B", "b2bPortal": "Portale B2B",
"signIn": "Accedi", "signIn": "Accedi",
+25 -2
View File
@@ -1,10 +1,25 @@
{ {
"Consent": {
"title": "Tegnémo cara ła to privacy",
"body": "Doperémo cookie analitici par capir come che i visitadori i dòpara el nostro sito e par mejorarlo. Nissun dato vien racolto fin che no te aceti.",
"learnMore": "Informativa privacy",
"accept": "Aceta",
"decline": "Refuda"
},
"Navigation": { "Navigation": {
"applications": "Applicaçion", "applications": "Applicaçion",
"globalMap": "Mapa del Mondo", "globalMap": "Mapa del Mondo",
"ourStory": "La Nostra Storia", "ourStory": "La Nostra Storia",
"parts": "Pessi de Ricambio", "parts": "Pessi de Ricambio",
"insideFlux": "Drento FLUX" "insideFlux": "Drento FLUX",
"team": "Squadra"
},
"TeamPage": {
"eyebrow": "La nostra Squadra",
"title1": "Le menti drio",
"title2": "ła potensa.",
"description": "Quatro deceni de esperiensa inzegnierìstica RF, incarnài da łe persone che projeta, costruise e suporta ogni sistema FLUX.",
"empty": "I profiłi de ła nostra squadra i rivarà presto."
}, },
"HeroReel": { "HeroReel": {
"title1": "Inovaçion,", "title1": "Inovaçion,",
@@ -144,7 +159,8 @@
"eventOverview": "Detaji de l'evento", "eventOverview": "Detaji de l'evento",
"projectChronicle": "Storia del projeto", "projectChronicle": "Storia del projeto",
"pendingData": "[ Dati de la storia drio rivar par sto nodo ]", "pendingData": "[ Dati de la storia drio rivar par sto nodo ]",
"mediaGallery": "Gałeria de foto" "mediaGallery": "Gałeria de foto",
"viewFullCase": "Varda el caso completo"
}, },
"Footer": { "Footer": {
"madeInItaly": "Fato in Itaia", "madeInItaly": "Fato in Itaia",
@@ -184,6 +200,13 @@
"page": "Pagina", "page": "Pagina",
"of": "de" "of": "de"
}, },
"ArticlePage": {
"backToNewsHub": "Torna al News Hub",
"backToNews": "Torna a łe Notissie",
"mediaGallery": "Gałeria Media",
"joinLinkedIn": "Parteçipa a ła conversassion su LinkedIn",
"internalRelease": "Comunicato Aziendałe Interno"
},
"AuthModal": { "AuthModal": {
"b2bPortal": "Portal par ditte", "b2bPortal": "Portal par ditte",
"signIn": "Entra chive", "signIn": "Entra chive",
+126
View File
@@ -1,11 +1,62 @@
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/s; limit_req_zone $binary_remote_addr zone=login:10m rate=5r/s;
# Slow zone for media uploads: 5 requests per minute per IP.
limit_req_zone $binary_remote_addr zone=upload:10m rate=5r/m;
upstream nextjs { upstream nextjs {
server app:3000; server app:3000;
keepalive 32; keepalive 32;
} }
# ─────────────────────────────────────────────────────────────────────────
# CANONICAL-HOST GUARD (default_server for ports 80 + 443)
# Catches every request NOT addressed to rf-flux.com / www.rf-flux.com —
# raw-IP access (135.125.53.234), SSRF probes (Host: 169.254.169.254,
# localhost, metadata.google.internal) and the bulk of bot scans that hit
# the bare IP. Returns 444 (drop the connection, send nothing).
#
# Legitimate traffic is unaffected: the rf-flux.com server blocks below win
# because an exact server_name match always beats default_server.
# ─────────────────────────────────────────────────────────────────────────
server {
listen 80 default_server;
server_name _;
# Nginx self-health endpoint (served directly, no upstream) — used by the
# docker-compose healthcheck. Reachable on 127.0.0.1 inside the container
# (no Host match needed, so it lands here on the default_server).
location = /nginx-health { return 200 "ok\n"; access_log off; }
# Keep ACME HTTP-01 working so certbot can still renew on any host.
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 444; }
}
server {
listen 443 ssl default_server;
http2 on;
server_name _;
# A cert is required to complete the TLS handshake before the Host is
# known; reuse the rf-flux.com cert, then drop. Bots hitting the IP get
# a cert-name mismatch and a closed connection — nothing is proxied.
ssl_certificate /etc/letsencrypt/live/rf-flux.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/rf-flux.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
return 444;
}
# Legacy domain redirect — anyone landing on lethepowerflux.com lands on
# the canonical https://www.rf-flux.com host instead. SEO-safe 301.
server {
listen 80;
server_name lethepowerflux.com www.lethepowerflux.com;
return 301 https://www.rf-flux.com$request_uri;
}
server { server {
listen 80; listen 80;
server_name rf-flux.com www.rf-flux.com; server_name rf-flux.com www.rf-flux.com;
@@ -35,6 +86,25 @@ server {
ssl_session_tickets off; ssl_session_tickets off;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# ── Security headers ────────────────────────────────────────────────
# 'unsafe-inline' / 'unsafe-eval' on script-src are required by Next.js
# for hydration. Tightening to nonces is tracked as future work.
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; media-src 'self' blob: https:; font-src 'self' data:; connect-src 'self' https://api.openai.com https://*.upstash.io https://www.googletagmanager.com https://www.google-analytics.com https://*.google-analytics.com https://*.analytics.google.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# ── Scanner / exploit-probe blocking ────────────────────────────────
# This is a Next.js app — it has no PHP, no .env/.git served over HTTP,
# no wp-admin. Any request for those paths is a bot probing for secrets
# or exploits. Drop them cheaply with 444 before they touch the app.
# The patterns are scanner-specific and never match real assets
# (.jpg/.png/.webp/.mp4/.glb/.pdf/.svg) or app routes.
location ~* (?:\.(?:php|phtml|asp|aspx|jsp|cgi|env|sql|bak|ini|sh|yml|yaml|conf)$|/\.(?:git|env|aws|ssh|svn|hg|idea|vscode)|/(?:wp-admin|wp-login|wordpress|phpmyadmin|xmlrpc)) {
return 444;
access_log off;
}
# Next.js bundles use content hashing — safe to cache forever # Next.js bundles use content hashing — safe to cache forever
location /_next/static/ { location /_next/static/ {
@@ -66,6 +136,7 @@ server {
# Asset uploads (large files, long timeout) # Asset uploads (large files, long timeout)
location /api/assets { location /api/assets {
limit_req zone=upload burst=10 nodelay;
client_max_body_size 500M; client_max_body_size 500M;
proxy_pass http://nextjs; proxy_pass http://nextjs;
proxy_set_header Host $host; proxy_set_header Host $host;
@@ -78,6 +149,7 @@ server {
} }
location /api/public-upload { location /api/public-upload {
limit_req zone=upload burst=10 nodelay;
client_max_body_size 500M; client_max_body_size 500M;
proxy_pass http://nextjs; proxy_pass http://nextjs;
proxy_set_header Host $host; proxy_set_header Host $host;
@@ -163,14 +235,68 @@ server {
access_log off; access_log off;
} }
location /branding/ {
alias /srv/branding/;
add_header Cache-Control "public, max-age=300, must-revalidate" always;
access_log off;
}
location /team/ {
alias /srv/team/;
add_header Cache-Control "public, max-age=300, must-revalidate" always;
access_log off;
}
location / { location / {
proxy_pass http://nextjs; proxy_pass http://nextjs;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
# Public-facing host + port so Next.js builds correct absolute
# redirect URLs (without leaking the internal container port 3000).
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
# Strip any leaked container port from upstream redirects, just in
# case Next.js still builds Location headers with :3000.
proxy_redirect ~^https?://[^/:]+:3000(/.*)$ https://$host$1;
# ── Shared HTML cache ───────────────────────────────────────────
# Caches GET responses that come back with a Cache-Control header
# from Next.js (the proxy at src/proxy.ts sets s-maxage=60 on
# public marketing pages). Authenticated requests skip the cache
# entirely. While a cached entry is being refreshed, other
# visitors keep getting the stale copy — no thundering herd.
proxy_cache flux_html;
proxy_cache_revalidate on;
proxy_cache_min_uses 1;
proxy_cache_lock on;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_cache_methods GET HEAD;
proxy_cache_valid 200 60s;
# Tell Nginx to ignore upstream headers that would otherwise
# disable caching:
# - Set-Cookie: next-intl writes a NEXT_LOCALE cookie even on
# public pages. Without ignoring it, Nginx refuses to cache
# anything that contains Set-Cookie (default behaviour).
# - Cache-Control / Expires from upstream-internal mechanisms
# would compete with our s-maxage=60 logic.
proxy_ignore_headers Set-Cookie X-Accel-Expires Expires;
proxy_hide_header Set-Cookie;
# Bypass cache for authenticated sessions (admin CMS or B2B portal)
# so logged-in users always get a fresh per-account render.
proxy_cache_bypass $cookie_flux_session $cookie_flux_b2b_session $http_pragma;
proxy_no_cache $cookie_flux_session $cookie_flux_b2b_session $http_pragma;
# Surface cache status in response headers for debugging.
# X-Cache-Status: HIT | MISS | EXPIRED | STALE | UPDATING | BYPASS
add_header X-Cache-Status $upstream_cache_status always;
} }
} }
+10 -1
View File
@@ -35,8 +35,17 @@ http {
log_format main '$remote_addr - $remote_user [$time_local] "$request" ' log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" ' '$status $body_bytes_sent "$http_referer" '
'"$http_user_agent"'; '"$http_user_agent" cache=$upstream_cache_status';
access_log /var/log/nginx/access.log main; access_log /var/log/nginx/access.log main;
# Shared HTML cache for public marketing pages.
# Honors Cache-Control headers from upstream (Next.js sets s-maxage=60
# via middleware on cacheable routes). Sized at 1GB on disk, kept warm
# for 24h. proxy_cache_use_stale lets a stale copy serve while a fresh
# render happens in the background — perceived latency stays sub-10ms
# even during regeneration.
proxy_cache_path /var/cache/nginx/flux levels=1:2 keys_zone=flux_html:50m
max_size=1g inactive=24h use_temp_path=off;
include /etc/nginx/conf.d/*.conf; include /etc/nginx/conf.d/*.conf;
} }
+1202 -1051
View File
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -6,7 +6,8 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint",
"test:ai": "node --test tests/ai/golden.test.mjs"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/openai": "^3.0.41", "@ai-sdk/openai": "^3.0.41",
@@ -15,6 +16,7 @@
"@prisma/client": "^7.5.0", "@prisma/client": "^7.5.0",
"@react-three/drei": "^10.7.7", "@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0", "@react-three/fiber": "^9.5.0",
"@swc/helpers": "^0.5.17",
"@types/nodemailer": "^7.0.11", "@types/nodemailer": "^7.0.11",
"ai": "^6.0.116", "ai": "^6.0.116",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
@@ -30,6 +32,7 @@
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"resend": "^6.9.3", "resend": "^6.9.3",
"sharp": "^0.34.5",
"speakeasy": "^2.0.0", "speakeasy": "^2.0.0",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"three": "^0.183.2", "three": "^0.183.2",
@@ -50,5 +53,13 @@
"prisma": "^7.5.0", "prisma": "^7.5.0",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5"
},
"optionalDependencies": {
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5"
} }
} }
+9 -3
View File
@@ -1,7 +1,13 @@
// Local dev loads .env via dotenv. Production (Docker) injects env vars
// directly through docker-compose, so dotenv isn't installed in the runtime
// image — load it conditionally so `prisma migrate deploy` works there too.
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
require("dotenv/config");
} catch {
// dotenv not installed in this environment — env vars expected from process
}
// This file was generated by Prisma and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig, env } from "prisma/config"; import { defineConfig, env } from "prisma/config";
export default defineConfig({ export default defineConfig({
@@ -0,0 +1,86 @@
-- ─────────────────────────────────────────────────────────────────────────
-- ADDITIVE MIGRATION — adds analytics tables + indices on hot filter columns.
-- Nothing in this file modifies or drops existing data. Safe to `migrate
-- deploy` in production. Idempotent: every CREATE uses IF NOT EXISTS.
-- ─────────────────────────────────────────────────────────────────────────
-- ── Indices on existing tables (speed up isActive/category filters) ──────
CREATE INDEX IF NOT EXISTS "GlobalNode_isActive_idx" ON "GlobalNode" ("isActive");
CREATE INDEX IF NOT EXISTS "GlobalNode_nodeType_idx" ON "GlobalNode" ("nodeType");
CREATE INDEX IF NOT EXISTS "GlobalNode_nodeType_isActive_idx" ON "GlobalNode" ("nodeType", "isActive");
CREATE INDEX IF NOT EXISTS "Application_isActive_idx" ON "Application" ("isActive");
CREATE INDEX IF NOT EXISTS "Application_category_idx" ON "Application" ("category");
CREATE INDEX IF NOT EXISTS "NewsArticle_isActive_idx" ON "NewsArticle" ("isActive");
CREATE INDEX IF NOT EXISTS "NewsArticle_isActive_publishedAt_idx" ON "NewsArticle" ("isActive", "publishedAt" DESC);
CREATE INDEX IF NOT EXISTS "SparePart_isActive_idx" ON "SparePart" ("isActive");
-- ── FluxAI telemetry ──────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS "AiConversation" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"visitorIp" TEXT,
"userAgent" TEXT,
"locale" TEXT,
"pageUrl" TEXT,
"industryLabel" TEXT,
"funnelStage" TEXT NOT NULL DEFAULT 'DISCOVERY',
"outcome" TEXT NOT NULL DEFAULT 'OPEN',
"messageCount" INTEGER NOT NULL DEFAULT 0,
"toolCallCount" INTEGER NOT NULL DEFAULT 0,
"estimatedSavingsPercent" DOUBLE PRECISION,
"productionVolume" TEXT,
"signalId" TEXT,
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastMessageAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"closedAt" TIMESTAMP(3),
CONSTRAINT "AiConversation_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "AiConversation_sessionId_key" ON "AiConversation" ("sessionId");
CREATE INDEX IF NOT EXISTS "AiConversation_funnelStage_idx" ON "AiConversation" ("funnelStage");
CREATE INDEX IF NOT EXISTS "AiConversation_outcome_idx" ON "AiConversation" ("outcome");
CREATE INDEX IF NOT EXISTS "AiConversation_startedAt_idx" ON "AiConversation" ("startedAt" DESC);
CREATE INDEX IF NOT EXISTS "AiConversation_industryLabel_idx" ON "AiConversation" ("industryLabel");
CREATE TABLE IF NOT EXISTS "AiEvent" (
"id" TEXT NOT NULL,
"conversationId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"payloadJson" TEXT NOT NULL,
"toolName" TEXT,
"latencyMs" INTEGER,
"tokensIn" INTEGER,
"tokensOut" INTEGER,
"cachedTokens" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AiEvent_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "AiEvent_conversationId_createdAt_idx" ON "AiEvent" ("conversationId", "createdAt");
CREATE INDEX IF NOT EXISTS "AiEvent_type_idx" ON "AiEvent" ("type");
CREATE INDEX IF NOT EXISTS "AiEvent_toolName_idx" ON "AiEvent" ("toolName");
-- ── Foreign keys (added separately so missing references don't break load) ──
DO $$ BEGIN
ALTER TABLE "AiConversation"
ADD CONSTRAINT "AiConversation_signalId_fkey"
FOREIGN KEY ("signalId") REFERENCES "OperationsSignal"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "AiEvent"
ADD CONSTRAINT "AiEvent_conversationId_fkey"
FOREIGN KEY ("conversationId") REFERENCES "AiConversation"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
@@ -0,0 +1,25 @@
-- ─────────────────────────────────────────────────────────────────────────
-- ADDITIVE MIGRATION — adds the TeamMember table for the public team page.
-- Nothing here modifies or drops existing data. Idempotent via IF NOT EXISTS.
-- ─────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS "TeamMember" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" TEXT NOT NULL,
"bio" TEXT,
"photoUrl" TEXT,
"email" TEXT,
"linkedinUrl" TEXT,
"xUrl" TEXT,
"websiteUrl" TEXT,
"order" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"translationsJson" TEXT DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "TeamMember_isActive_order_idx" ON "TeamMember" ("isActive", "order");
@@ -0,0 +1,10 @@
-- ─────────────────────────────────────────────────────────────────────────
-- ADDITIVE MIGRATION — adds a manual `order` column to Application so the
-- editor can drag-to-reorder applications on the public site (same pattern
-- as HeroSlide). Existing rows default to 0 and keep their creation order
-- as a tiebreaker. Safe for `migrate deploy`. Idempotent.
-- ─────────────────────────────────────────────────────────────────────────
ALTER TABLE "Application" ADD COLUMN IF NOT EXISTS "order" INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS "Application_isActive_order_idx" ON "Application" ("isActive", "order");
@@ -0,0 +1,40 @@
-- ─────────────────────────────────────────────────────────────────────────
-- ADDITIVE MIGRATION — creates the ClientUser table (B2B client portal) and
-- wires OperationsSignal.clientId to it. The Prisma schema has defined this
-- model + relation for a while, but no migration ever created the table, so
-- the B2B register/login flow (src/app/actions/clientAuth.ts) and the
-- dashboard client counts were failing at runtime. This backfills it.
--
-- Idempotent: IF NOT EXISTS / duplicate-object guards make it safe to re-run
-- and safe for `migrate deploy` against the existing production DB.
-- ─────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS "ClientUser" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"fullName" TEXT NOT NULL,
"companyName" TEXT NOT NULL,
"phone" TEXT,
"isApproved" BOOLEAN NOT NULL DEFAULT false,
"lastLoginAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ClientUser_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "ClientUser_email_key" ON "ClientUser" ("email");
-- OperationsSignal.clientId — the FK column the schema references. Add it if
-- a prior schema state never created it (nullable, so existing rows are fine).
ALTER TABLE "OperationsSignal" ADD COLUMN IF NOT EXISTS "clientId" TEXT;
-- Foreign key OperationsSignal.clientId -> ClientUser.id
DO $$ BEGIN
ALTER TABLE "OperationsSignal"
ADD CONSTRAINT "OperationsSignal_clientId_fkey"
FOREIGN KEY ("clientId") REFERENCES "ClientUser"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
@@ -0,0 +1,10 @@
-- ─────────────────────────────────────────────────────────────────────────
-- ADDITIVE MIGRATION — index GlobalNode(application, isActive).
-- The application detail page queries case studies by application slug +
-- isActive (the GlobalNode.application -> Application.slug join). Without an
-- index this is a full table scan on every application page render.
-- Idempotent. Safe for `migrate deploy`.
-- ─────────────────────────────────────────────────────────────────────────
CREATE INDEX IF NOT EXISTS "GlobalNode_application_isActive_idx"
ON "GlobalNode" ("application", "isActive");
+110 -4
View File
@@ -55,11 +55,18 @@ model GlobalNode {
rendersJson String? @default("[]") // Renders 3D fotorrealistas rendersJson String? @default("[]") // Renders 3D fotorrealistas
model3DDimsJson String? // Dimensiones físicas AR: { w, h, d, unit, weight } model3DDimsJson String? // Dimensiones físicas AR: { w, h, d, unit, weight }
// 🌍 MOTOR DE TRADUCCIONES // 🌍 MOTOR DE TRADUCCIONES
translationsJson String? @default("{}") translationsJson String? @default("{}")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([isActive])
@@index([nodeType])
@@index([nodeType, isActive])
// Case studies on an application page filter by application slug + isActive
// (src/app/[locale]/applications/[slug]/page.tsx). Back this join with an index.
@@index([application, isActive])
} }
// ------------------------------------------------------ // ------------------------------------------------------
@@ -85,11 +92,18 @@ model Application {
dashboardMetricsJson String? @default("[]") dashboardMetricsJson String? @default("[]")
isActive Boolean @default(true) // 🔥 NUEVO: Para poder ocultarlas isActive Boolean @default(true) // 🔥 NUEVO: Para poder ocultarlas
// 🌍 MOTOR DE TRADUCCIONES // 🔥 NUEVO: Orden manual para drag-to-reorder en el frontend (como HeroSlide)
order Int @default(0)
// 🌍 MOTOR DE TRADUCCIONES
translationsJson String? @default("{}") translationsJson String? @default("{}")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([isActive])
@@index([category])
@@index([isActive, order])
} }
// ------------------------------------------------------ // ------------------------------------------------------
@@ -129,11 +143,14 @@ model NewsArticle {
galleryJson String? @default("[]") // Galería de imágenes extra galleryJson String? @default("[]") // Galería de imágenes extra
linkedinUrl String? // Enlace oficial para LinkedIn linkedinUrl String? // Enlace oficial para LinkedIn
// 🌍 MOTOR DE TRADUCCIONES // 🌍 MOTOR DE TRADUCCIONES
translationsJson String? @default("{}") translationsJson String? @default("{}")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([isActive])
@@index([isActive, publishedAt(sort: Desc)])
} }
// ------------------------------------------------------ // ------------------------------------------------------
@@ -177,6 +194,8 @@ model SparePart {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([isActive])
} }
// ------------------------------------------------------ // ------------------------------------------------------
@@ -209,7 +228,10 @@ model OperationsSignal {
// 🔥 NUEVO: Relación opcional con el Cliente Registrado (Para el futuro CRM) // 🔥 NUEVO: Relación opcional con el Cliente Registrado (Para el futuro CRM)
clientId String? clientId String?
client ClientUser? @relation(fields: [clientId], references: [id]) client ClientUser? @relation(fields: [clientId], references: [id])
// FluxAI telemetry back-ref: which AI conversations converted into this ticket.
conversations AiConversation[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -293,6 +315,58 @@ model SiteSetting {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
// ------------------------------------------------------
// 13b. FLUXAI TELEMETRY (Conversaciones + eventos del chat IA)
// ------------------------------------------------------
// Persiste cada conversación con FluxAI para análisis de funnel B2B.
// Una conversación se identifica por sessionId (UUID generado en cliente,
// persistido en localStorage). Los eventos individuales (mensajes,
// tool calls, errores) viven en AiEvent.
model AiConversation {
id String @id @default(cuid())
sessionId String @unique
visitorIp String? // sha256(ip + SESSION_SECRET) — pseudonymous
userAgent String?
locale String? // "it","en","es","fr","de"
pageUrl String? // entry page
industryLabel String? // SPIN-detected: "textile","food", etc.
funnelStage String @default("DISCOVERY") // DISCOVERY|QUALIFY|RECOMMEND|HANDOFF
outcome String @default("OPEN") // OPEN|CONSULTATION|ABANDONED
messageCount Int @default(0)
toolCallCount Int @default(0)
estimatedSavingsPercent Float?
productionVolume String?
signalId String?
signal OperationsSignal? @relation(fields: [signalId], references: [id])
startedAt DateTime @default(now())
lastMessageAt DateTime @default(now())
closedAt DateTime?
events AiEvent[]
@@index([funnelStage])
@@index([outcome])
@@index([startedAt(sort: Desc)])
@@index([industryLabel])
}
model AiEvent {
id String @id @default(cuid())
conversationId String
conversation AiConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
type String // "user_msg" | "ai_msg" | "tool_call" | "tool_result" | "error"
payloadJson String // truncated to 8KB at write time
toolName String?
latencyMs Int?
tokensIn Int?
tokensOut Int?
cachedTokens Int? // populated when OpenAI returns cached_tokens
createdAt DateTime @default(now())
@@index([conversationId, createdAt])
@@index([type])
@@index([toolName])
}
// ------------------------------------------------------ // ------------------------------------------------------
// 13. CLIENT PORTAL (Usuarios B2B Aprobados) // 13. CLIENT PORTAL (Usuarios B2B Aprobados)
// ------------------------------------------------------ // ------------------------------------------------------
@@ -313,4 +387,36 @@ model ClientUser {
lastLoginAt DateTime? lastLoginAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
}
// ------------------------------------------------------
// 14. THE TEAM (Equipo de FLUX — página pública + CMS)
// ------------------------------------------------------
// Minimal LinkedIn-style profiles. Editable in the HQ Command Center with
// drag-to-reorder (same pattern as HeroSlide). Name stays as written; role
// and bio are translatable through the AI translation engine. Social links
// are all optional — only the ones filled in render on the public card.
model TeamMember {
id String @id @default(cuid())
name String // Proper name — never translated
role String // Job title, e.g. "Founder & CEO" — translatable
bio String? // Short biography (Markdown allowed) — translatable
photoUrl String? // Portrait, served from /team/ bucket
// Optional social links — render only when present
email String?
linkedinUrl String?
xUrl String? // X / Twitter
websiteUrl String?
order Int @default(0) // Drag-to-reorder
isActive Boolean @default(true)
// 🌍 Translation engine — holds localized role + bio per locale
translationsJson String? @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([isActive, order])
} }
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
# ─────────────────────────────────────────────────────────────────────────────
# Backup service entrypoint. Runs one backup immediately on start, then loops
# every BACKUP_INTERVAL_SECONDS (default 24h). A loop (vs cron) inherits the
# container environment cleanly and survives restarts without lost schedules.
# ─────────────────────────────────────────────────────────────────────────────
set -eu
INTERVAL="${BACKUP_INTERVAL_SECONDS:-86400}"
echo "[backup] service started; interval=${INTERVAL}s, retention=${RETENTION_DAYS:-14}d"
while true; do
/usr/local/bin/db-backup.sh || echo "[backup] cycle failed; will retry next interval"
sleep "$INTERVAL"
done
+31
View File
@@ -0,0 +1,31 @@
#!/bin/sh
# ─────────────────────────────────────────────────────────────────────────────
# Single Postgres backup: pg_dump -> gzip -> N-day rotation.
# Run by scripts/backup-loop.sh inside the `backup` compose service.
# Env: DB_USER, DB_PASSWORD, DB_NAME, BACKUP_DIR, RETENTION_DAYS
# ─────────────────────────────────────────────────────────────────────────────
set -eu
BACKUP_DIR="${BACKUP_DIR:-/backups}"
RETENTION_DAYS="${RETENTION_DAYS:-14}"
TS=$(date -u +%Y%m%d_%H%M%S)
OUT="${BACKUP_DIR}/flux_db_${TS}.sql.gz"
mkdir -p "$BACKUP_DIR"
export PGPASSWORD="$DB_PASSWORD"
echo "[backup] $(date -u +%Y-%m-%dT%H:%M:%SZ) starting pg_dump -> ${OUT}"
# --no-owner/--no-privileges keep the dump portable across roles on restore.
if pg_dump -h postgres -U "$DB_USER" -d "$DB_NAME" --no-owner --no-privileges | gzip -9 > "$OUT"; then
SIZE=$(du -h "$OUT" | cut -f1)
echo "[backup] OK: ${OUT} (${SIZE})"
else
echo "[backup] FAILED: pg_dump returned non-zero; removing partial file"
rm -f "$OUT"
exit 1
fi
# Rotation — drop dumps older than RETENTION_DAYS.
DELETED=$(find "$BACKUP_DIR" -name 'flux_db_*.sql.gz' -mtime +"$RETENTION_DAYS" -print -delete 2>/dev/null | wc -l || echo 0)
echo "[backup] rotation: kept last ${RETENTION_DAYS} days, pruned ${DELETED} old dump(s)"
+38
View File
@@ -0,0 +1,38 @@
#!/bin/sh
# ─────────────────────────────────────────────────────────────────────────────
# FLUX container entrypoint.
#
# Runs as root briefly so we can:
# 1. Make sure every mounted upload dir AND every file inside is owned by
# uid 1001 / gid 1001 (nextjs:nodejs). Without this, files written
# previously when nextjs had nogroup (gid 65533) stay 1001:65533 and
# sysadmins on the host see a wrong group.
# 2. Apply pending Prisma migrations idempotently.
# 3. Hand off to the Next.js server, dropping privileges to nextjs.
# ─────────────────────────────────────────────────────────────────────────────
set -e
# Recursively normalise ownership on every mounted public/* folder. Recursive
# is fine because (a) Prisma and Next.js never read /app/public except from
# fs APIs that don't care about ownership, and (b) the chown is fast on local
# disk even with thousands of files — runs once per container start.
for dir in \
/app/public/branding \
/app/public/footage \
/app/public/applications \
/app/public/cases \
/app/public/news \
/app/public/parts \
/app/public/operations-inbox \
/app/public/heritage; do
if [ -d "$dir" ]; then
chown -R 1001:1001 "$dir" 2>/dev/null || true
fi
done
# Run pending migrations (idempotent).
su-exec nextjs node ./node_modules/prisma/build/index.js migrate deploy
# Boot the Next.js server as the unprivileged user.
exec su-exec nextjs node server.js
@@ -9,6 +9,8 @@ import Script from "next/script";
import { ArrowLeft, CheckCircle2, Zap, LayoutDashboard, Cpu, PencilRuler, Factory, MapPin, ChevronDown, Play, FileText, Box, Loader2, Maximize, X, ChevronLeft, ChevronRight } from "lucide-react"; import { ArrowLeft, CheckCircle2, Zap, LayoutDashboard, Cpu, PencilRuler, Factory, MapPin, ChevronDown, Play, FileText, Box, Loader2, Maximize, X, ChevronLeft, ChevronRight } from "lucide-react";
import BreathingField from "@/components/visuals/BreathingField"; import BreathingField from "@/components/visuals/BreathingField";
import AutoPlayVideo from "@/components/AutoPlayVideo"; import AutoPlayVideo from "@/components/AutoPlayVideo";
import Breadcrumbs from "@/components/seo/Breadcrumbs";
import type { BreadcrumbItem } from "@/components/seo/Breadcrumbs";
// 🔥 EL TRUCO DEFINITIVO PARA TYPESCRIPT Y WEB COMPONENTS 🔥 // 🔥 EL TRUCO DEFINITIVO PARA TYPESCRIPT Y WEB COMPONENTS 🔥
// Al asignar el string a una variable con 'as any', TypeScript deja de // Al asignar el string a una variable con 'as any', TypeScript deja de
@@ -904,7 +906,7 @@ function ExpandedCaseStudy({ node }: { node: any }) {
const fullImgSrc = `/cases/${nodeSlug}/${img}`; const fullImgSrc = `/cases/${nodeSlug}/${img}`;
return ( return (
<div key={`g-${idx}`} className="relative w-full aspect-video rounded-2xl md:rounded-3xl overflow-hidden border border-black/10 dark:border-white/10 shadow-lg group bg-white"> <div key={`g-${idx}`} className="relative w-full aspect-video rounded-2xl md:rounded-3xl overflow-hidden border border-black/10 dark:border-white/10 shadow-lg group bg-white">
<Image src={fullImgSrc} alt="Installation" fill className="object-cover group-hover:scale-105 transition-transform duration-700" /> <Image src={fullImgSrc} alt="Installation" fill sizes="(max-width: 768px) 100vw, 50vw" className="object-cover group-hover:scale-105 transition-transform duration-700" />
<button <button
onClick={() => openLightbox(fullImgSrc)} onClick={() => openLightbox(fullImgSrc)}
className="absolute inset-0 w-full h-full bg-black/0 hover:bg-black/30 active:bg-black/40 transition-all flex items-center justify-center opacity-0 group-hover:opacity-100 backdrop-blur-[0px] hover:backdrop-blur-sm touch-manipulation" className="absolute inset-0 w-full h-full bg-black/0 hover:bg-black/30 active:bg-black/40 transition-all flex items-center justify-center opacity-0 group-hover:opacity-100 backdrop-blur-[0px] hover:backdrop-blur-sm touch-manipulation"
@@ -999,9 +1001,24 @@ function ExpandedCaseStudy({ node }: { node: any }) {
} }
// --- COMPONENTE PRINCIPAL --- // --- COMPONENTE PRINCIPAL ---
export default function ApplicationClient({ data, realCases, images }: { data: any, realCases: any[], images: any }) { export default function ApplicationClient({ data, realCases, images, breadcrumbs }: { data: any, realCases: any[], images: any, breadcrumbs?: BreadcrumbItem[] }) {
const [expandedCase, setExpandedCase] = useState<string | null>(null); const [expandedCase, setExpandedCase] = useState<string | null>(null);
// Deep-link from the Global Map: a "#case-<id>" hash opens the matching
// case study, expands it, and scrolls to it. This is the bridge that
// connects a node's modal on the 3D globe to its full write-up here.
useEffect(() => {
if (typeof window === "undefined") return;
const hash = window.location.hash;
if (!hash.startsWith("#case-")) return;
const id = decodeURIComponent(hash.slice("#case-".length));
setExpandedCase(id);
const timer = setTimeout(() => {
document.getElementById(`case-${id}`)?.scrollIntoView({ behavior: "smooth", block: "start" });
}, 350);
return () => clearTimeout(timer);
}, []);
const [mainLightboxOpen, setMainLightboxOpen] = useState(false); const [mainLightboxOpen, setMainLightboxOpen] = useState(false);
const [mainLightboxImages, setMainLightboxImages] = useState<string[]>([]); const [mainLightboxImages, setMainLightboxImages] = useState<string[]>([]);
const [mainLightboxInitialIndex, setMainLightboxInitialIndex] = useState(0); const [mainLightboxInitialIndex, setMainLightboxInitialIndex] = useState(0);
@@ -1045,7 +1062,7 @@ export default function ApplicationClient({ data, realCases, images }: { data: a
<section className="relative w-full h-[50vh] md:h-[70vh] flex items-end justify-start overflow-hidden"> <section className="relative w-full h-[50vh] md:h-[70vh] flex items-end justify-start overflow-hidden">
{heroImage ? ( {heroImage ? (
<Image src={heroImage} alt={data.title} fill className="object-cover object-center scale-105 animate-slow-zoom" priority /> <Image src={heroImage} alt={data.title} fill sizes="100vw" className="object-cover object-center scale-105 animate-slow-zoom" priority />
) : ( ) : (
<div className="absolute inset-0 bg-gradient-to-br from-[#0066CC]/20 to-black/80" /> <div className="absolute inset-0 bg-gradient-to-br from-[#0066CC]/20 to-black/80" />
)} )}
@@ -1053,6 +1070,7 @@ export default function ApplicationClient({ data, realCases, images }: { data: a
<div className="relative z-10 max-w-4xl mx-auto px-6 pb-12 md:pb-16 w-full"> <div className="relative z-10 max-w-4xl mx-auto px-6 pb-12 md:pb-16 w-full">
<header> <header>
{breadcrumbs && <Breadcrumbs items={breadcrumbs} />}
<div className="inline-flex items-center gap-2 text-[#0066CC] dark:text-[#00F0FF] mb-3 bg-white/80 dark:bg-black/80 backdrop-blur-sm px-3 py-1.5 rounded-full border border-black/5 dark:border-white/10"> <div className="inline-flex items-center gap-2 text-[#0066CC] dark:text-[#00F0FF] mb-3 bg-white/80 dark:bg-black/80 backdrop-blur-sm px-3 py-1.5 rounded-full border border-black/5 dark:border-white/10">
<LayoutDashboard size={14} /> <LayoutDashboard size={14} />
<span className="text-[10px] md:text-xs font-semibold uppercase tracking-widest">{data.category}</span> <span className="text-[10px] md:text-xs font-semibold uppercase tracking-widest">{data.category}</span>
@@ -1144,12 +1162,12 @@ export default function ApplicationClient({ data, realCases, images }: { data: a
{realCases.map((node) => { {realCases.map((node) => {
const isExpanded = expandedCase === node.id; const isExpanded = expandedCase === node.id;
return ( return (
<div key={node.id} className="bg-white/80 dark:bg-[#111]/90 backdrop-blur-2xl border border-black/10 dark:border-white/10 rounded-2xl md:rounded-[2.5rem] overflow-hidden transition-all duration-500 shadow-xl"> <div key={node.id} id={`case-${node.id}`} className="scroll-mt-28 bg-white/80 dark:bg-[#111]/90 backdrop-blur-2xl border border-black/10 dark:border-white/10 rounded-2xl md:rounded-[2.5rem] overflow-hidden transition-all duration-500 shadow-xl target:ring-2 target:ring-[#0066CC] dark:target:ring-[#00F0FF]">
<div onClick={() => setExpandedCase(isExpanded ? null : node.id)} className="p-5 md:p-8 cursor-pointer flex flex-col md:flex-row items-start md:items-center justify-between gap-5 group hover:bg-black/5 dark:hover:bg-white/[0.02] transition-colors"> <div onClick={() => setExpandedCase(isExpanded ? null : node.id)} className="p-5 md:p-8 cursor-pointer flex flex-col md:flex-row items-start md:items-center justify-between gap-5 group hover:bg-black/5 dark:hover:bg-white/[0.02] transition-colors">
<div className="flex items-center gap-5 flex-1"> <div className="flex items-center gap-5 flex-1">
<div className="w-16 h-16 md:w-20 md:h-20 rounded-2xl md:rounded-3xl bg-black/5 dark:bg-black/50 border border-black/10 dark:border-white/5 flex items-center justify-center shrink-0 overflow-hidden relative shadow-inner"> <div className="w-16 h-16 md:w-20 md:h-20 rounded-2xl md:rounded-3xl bg-black/5 dark:bg-black/50 border border-black/10 dark:border-white/5 flex items-center justify-center shrink-0 overflow-hidden relative shadow-inner">
{node.mediaFileName ? ( {node.mediaFileName ? (
<Image src={`/cases/${nodeToSlug(node.title)}/${node.mediaFileName}`} alt={node.title} fill className="object-cover opacity-90 group-hover:scale-110 transition-transform duration-700" /> <Image src={`/cases/${nodeToSlug(node.title)}/${node.mediaFileName}`} alt={node.title} fill sizes="100px" className="object-cover opacity-90 group-hover:scale-110 transition-transform duration-700" />
) : ( ) : (
<Cpu size={28} className="text-[#0066CC]/50 dark:text-[#00F0FF]/50" /> <Cpu size={28} className="text-[#0066CC]/50 dark:text-[#00F0FF]/50" />
)} )}
+136 -32
View File
@@ -1,6 +1,7 @@
// ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads. // ISR: revalidate every 60s — see /[locale]/page.tsx for the full rationale.
export const revalidate = 60; export const revalidate = 60;
import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
@@ -8,40 +9,90 @@ import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import ApplicationClient from "./ApplicationClient"; import ApplicationClient from "./ApplicationClient";
// 🔥 IMPORTAMOS LA TUBERÍA MÁGICA import { setRequestLocale } from "next-intl/server";
import { getLocalizedData } from "@/lib/i18nHelper"; import { getLocalizedData } from "@/lib/i18nHelper";
import {
buildPageMetadata,
productSchema,
breadcrumbSchema,
baseUrl,
} from "@/lib/seo";
import JsonLd from "@/components/seo/JsonLd";
// --- FUNCIÓN ORIGINAL PARA LEER IMÁGENES LOCALES --- // --- FUNCIÓN ORIGINAL PARA LEER IMÁGENES LOCALES ---
function getApplicationImages(slug: string) { function getApplicationImages(slug: string) {
const imagesDir = path.join(process.cwd(), "public", "applications", slug);
let blueprints: string[] = []; let blueprints: string[] = [];
let machines: string[] = []; let machines: string[] = [];
let heroImage = ""; let heroImage = "";
if (fs.existsSync(imagesDir)) { try {
const files = fs.readdirSync(imagesDir).filter(file => /\.(png|jpe?g|webp)$/i.test(file)); const imagesDir = path.join(process.cwd(), "public", "applications", slug);
heroImage = files[0] ? `/applications/${slug}/${files[0]}` : ""; if (fs.existsSync(imagesDir)) {
blueprints = files.filter(f => f.includes("Screenshot") || f.startsWith("P10") || f.includes("blueprint")).slice(0, 3).map(f => `/applications/${slug}/${f}`); const files = fs.readdirSync(imagesDir).filter((file) => /\.(png|jpe?g|webp)$/i.test(file));
machines = files.filter(f => !f.includes("Screenshot") && !f.startsWith("P10") && !f.includes("blueprint")).slice(1, 4).map(f => `/applications/${slug}/${f}`);
heroImage = files[0] ? `/applications/${slug}/${files[0]}` : "";
blueprints = files
.filter((f) => f.includes("Screenshot") || f.startsWith("P10") || f.includes("blueprint"))
.slice(0, 3)
.map((f) => `/applications/${slug}/${f}`);
machines = files
.filter((f) => !f.includes("Screenshot") && !f.startsWith("P10") && !f.includes("blueprint"))
.slice(1, 4)
.map((f) => `/applications/${slug}/${f}`);
}
} catch (error) {
console.error(`[applications/${slug}] Image scan failed:`, error);
} }
return { heroImage, blueprints, machines }; return { heroImage, blueprints, machines };
} }
// GENERACIÓN DE RUTAS ESTÁTICAS DESDE LA BD // ── Per-page metadata (Open Graph, Twitter, hreflang, canonical) ───────────
export async function generateStaticParams() { export async function generateMetadata({
// In production Docker build, DB is not available. params,
// Pages are generated on-demand via SSR instead. }: {
if (process.env.NODE_ENV === 'production' && !process.env.VERCEL) { params: Promise<{ slug: string; locale: string }>;
return []; }): Promise<Metadata> {
try {
const { slug, locale } = await params;
const raw = await prisma.application.findUnique({ where: { slug } });
if (!raw) {
return {
title: "Application not found | FLUX",
robots: { index: false, follow: false },
};
}
const data: any = getLocalizedData(raw, locale);
const heroImage = getApplicationImages(slug).heroImage;
const title = data?.title || "Application";
const description = data?.shortDescription || data?.subtitle || "FLUX RF industrial solutions.";
return buildPageMetadata({
locale,
pathWithoutLocale: `applications/${slug}`,
title: `${title} — RF Industrial Solutions | FLUX`,
description,
ogImageUrl: heroImage || undefined,
type: "product",
});
} catch (error) {
console.error("[applications generateMetadata]", error);
return { title: "FLUX | Energy, Directed." };
} }
}
// Pre-render all known application slugs at build time. New slugs added
// after deploy render on-demand and get cached by ISR (revalidate=60).
// try/catch ensures the build never fails if the DB is unreachable
// during docker build — pages just render on first request instead.
export async function generateStaticParams() {
try { try {
const apps = await prisma.application.findMany({ const apps = await prisma.application.findMany({
select: { slug: true }, select: { slug: true },
}); });
return apps.map((app: any) => ({ slug: app.slug })); return apps.map((app) => ({ slug: app.slug }));
} catch { } catch {
return []; return [];
} }
@@ -51,11 +102,15 @@ export async function generateStaticParams() {
export default async function ApplicationPage({ params }: { params: Promise<{ slug: string, locale: string }> }) { export default async function ApplicationPage({ params }: { params: Promise<{ slug: string, locale: string }> }) {
const resolvedParams = await params; const resolvedParams = await params;
const { slug, locale } = resolvedParams; const { slug, locale } = resolvedParams;
setRequestLocale(locale);
// 1. Buscamos la Teoría General de la Aplicación // 1. Buscamos la Teoría General de la Aplicación
const rawData = await prisma.application.findUnique({ let rawData: any = null;
where: { slug } try {
}); rawData = await prisma.application.findUnique({ where: { slug } });
} catch (error) {
console.error(`[applications/${slug}] DB error fetching application:`, error);
}
if (!rawData) { if (!rawData) {
return ( return (
@@ -67,24 +122,73 @@ export default async function ApplicationPage({ params }: { params: Promise<{ sl
} }
// 🔥 TRADUCIMOS LA APLICACIÓN PRINCIPAL // 🔥 TRADUCIMOS LA APLICACIÓN PRINCIPAL
const data = getLocalizedData(rawData, locale); let data: any;
try {
data = getLocalizedData(rawData, locale);
} catch (error) {
console.error(`[applications/${slug}] Locale merge failed:`, error);
data = rawData;
}
// 2. Buscamos el "Muro de Soluciones" (Casos Reales específicos de esta app) // 2. Buscamos el "Muro de Soluciones" (Casos Reales específicos de esta app)
const rawRealCases = await prisma.globalNode.findMany({ let rawRealCases: any[] = [];
where: { try {
application: slug, rawRealCases = await prisma.globalNode.findMany({
isActive: true, where: {
projectOverview: { not: null } application: slug,
}, isActive: true,
orderBy: { createdAt: 'desc' } projectOverview: { not: null },
}); },
orderBy: { createdAt: "desc" },
});
} catch (error) {
console.error(`[applications/${slug}] DB error fetching cases:`, error);
}
// 🔥 TRADUCIMOS TODOS LOS CASOS DE ESTUDIO DEL MURO // 🔥 TRADUCIMOS TODOS LOS CASOS DE ESTUDIO DEL MURO
const realCases = rawRealCases.map((node: any) => getLocalizedData(node, locale)); let realCases: any[] = [];
try {
realCases = rawRealCases.map((node: any) => getLocalizedData(node, locale));
} catch (error) {
console.error(`[applications/${slug}] Cases locale merge failed:`, error);
realCases = rawRealCases;
}
// 3. Leemos las imágenes de la carpeta original // 3. Leemos las imágenes de la carpeta original
const images = getApplicationImages(slug); const images = getApplicationImages(slug);
// Pasamos TODO al componente cliente interactivo (que ya viene traducido) // 4. JSON-LD structured data — wrapped to never break the render.
return <ApplicationClient data={data} realCases={realCases} images={images} />; const appTitle = data?.title || "FLUX Application";
const appUrl = `${baseUrl()}/${locale}/applications/${slug}`;
const crumbs = [
{ name: "Home", url: `/${locale}` },
{ name: "Applications", url: `/${locale}#applications-deep` },
{ name: appTitle, url: `/${locale}/applications/${slug}` },
];
let jsonLd: object[] = [];
try {
const description = data?.shortDescription || data?.subtitle || "";
jsonLd = [
productSchema({
name: appTitle,
description,
imageUrl: images.heroImage || undefined,
category: data?.category || "RF Industrial",
url: appUrl,
}),
breadcrumbSchema(
crumbs.map((c) => ({ name: c.name, url: `${baseUrl()}${c.url}` }))
),
];
} catch (error) {
console.error(`[applications/${slug}] JSON-LD build failed:`, error);
}
return (
<>
{jsonLd.length > 0 && <JsonLd data={jsonLd} />}
<ApplicationClient data={data} realCases={realCases} images={images} breadcrumbs={crumbs} />
</>
);
} }
+47
View File
@@ -0,0 +1,47 @@
"use client";
// Locale-scoped error boundary — caught here so the root layout
// (NavBar, Footer, etc.) keeps rendering around the error UI.
// Useful for diagnosing per-page failures without losing site chrome.
import { useEffect } from "react";
export default function LocaleError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("[LocaleError]", error);
}, [error]);
return (
<div className="min-h-[60vh] flex items-center justify-center px-6">
<div className="max-w-2xl w-full">
<div className="text-[10px] uppercase tracking-widest text-[#FF6B6B] font-bold mb-3">
Page error
</div>
<h1 className="text-3xl md:text-4xl font-light text-[#1D1D1F] dark:text-white mb-4">
This page hit a problem.
</h1>
<p className="text-[#86868B] mb-6">
The site is up but this specific page failed to render.
</p>
<pre className="bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 rounded-2xl p-4 text-xs text-rose-500 dark:text-rose-400 overflow-auto mb-6">
{error.message || "Unknown error"}
{error.digest ? `\n\nDigest: ${error.digest}` : ""}
</pre>
<button
onClick={() => reset()}
className="px-5 py-3 bg-[#0066CC] dark:bg-[#00F0FF] text-white dark:text-black text-sm font-medium rounded-lg hover:opacity-80 transition-opacity"
>
Try again
</button>
</div>
</div>
);
}
+23 -5
View File
@@ -1,6 +1,8 @@
// ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads. // ISR: revalidate every 60s — see /[locale]/page.tsx for the full rationale.
// setRequestLocale caches the locale so next-intl doesn't read cookies/headers.
export const revalidate = 60; export const revalidate = 60;
import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -10,7 +12,23 @@ import AutoPlayVideo from "@/components/AutoPlayVideo";
// 🔥 IMPORTACIONES DE IDIOMAS // 🔥 IMPORTACIONES DE IDIOMAS
import { getLocalizedData } from "@/lib/i18nHelper"; import { getLocalizedData } from "@/lib/i18nHelper";
import { getTranslations } from "next-intl/server"; import { getTranslations, setRequestLocale } from "next-intl/server";
import { buildPageMetadata } from "@/lib/seo";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "HeritagePage" });
return buildPageMetadata({
locale,
pathWithoutLocale: "heritage",
title: `${t("subtitle")}${t("title1").trim()} ${t("title2").trim()} | FLUX`,
description: `${t("title1")} ${t("title2")} — Discover Patrizio Grando's 40-year legacy in Solid-State RF technology.`,
});
}
// ── SÚPER PARSER MARKDOWN (Con Tablas, Imágenes y Dark Mode puro) ── // ── SÚPER PARSER MARKDOWN (Con Tablas, Imágenes y Dark Mode puro) ──
const renderMarkdown = (text: string) => { const renderMarkdown = (text: string) => {
@@ -180,8 +198,8 @@ export default async function HeritagePage({ params }: { params: Promise<{ local
const resolvedParams = await params; const resolvedParams = await params;
const locale = resolvedParams.locale; const locale = resolvedParams.locale;
// 🔥 Cargamos el diccionario para los textos fijos setRequestLocale(locale);
const t = await getTranslations("HeritagePage"); const t = await getTranslations({ locale, namespace: "HeritagePage" });
let rawSections: any[] = []; let rawSections: any[] = [];
try { try {
@@ -238,7 +256,7 @@ export default async function HeritagePage({ params }: { params: Promise<{ local
{sec.type === 'image' && sec.mediaUrl && ( {sec.type === 'image' && sec.mediaUrl && (
<div className="relative w-full h-80 md:h-[500px] rounded-3xl overflow-hidden border border-black/10 dark:border-white/10 shadow-2xl"> <div className="relative w-full h-80 md:h-[500px] rounded-3xl overflow-hidden border border-black/10 dark:border-white/10 shadow-2xl">
<Image src={`/heritage/${sec.mediaUrl}`} alt={sec.title || "Heritage Image"} fill className="object-cover grayscale hover:grayscale-0 transition-all duration-1000" /> <Image src={`/heritage/${sec.mediaUrl}`} alt={sec.title || "Heritage Image"} fill sizes="100vw" className="object-cover grayscale hover:grayscale-0 transition-all duration-1000" />
</div> </div>
)} )}
+114 -16
View File
@@ -1,4 +1,5 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { Suspense } from "react";
import { Inter } from "next/font/google"; import { Inter } from "next/font/google";
import "../globals.css"; import "../globals.css";
@@ -7,27 +8,88 @@ 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 } from 'next-intl/server'; import { getMessages, setRequestLocale } from 'next-intl/server';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { routing } from '@/i18n/routing'; import { routing } from '@/i18n/routing';
import { getBranding } from '@/lib/siteSettings';
// Pre-render all 5 locale variants at build time so Next.js knows which
// [locale] segments are valid. Required for ISR to work with next-intl.
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
import { getBranding, getSocialLinks } from '@/lib/siteSettings';
import { organizationSchema, websiteSchema, localBusinessSchema } from '@/lib/seo';
import JsonLd from '@/components/seo/JsonLd';
const inter = Inter({ subsets: ["latin"] }); const inter = Inter({ subsets: ["latin"] });
// Dynamic metadata pulls favicon, logos, OG image and theme color from the // Dynamic metadata pulls favicon, logos, OG image and theme color from the
// SiteSetting CMS. Falls back to defaults when the table is empty. // SiteSetting CMS. Falls back to defaults when the table is empty.
//
// metadataBase is required so Next.js can resolve relative OG/Twitter image
// URLs to absolute ones — otherwise it warns and falls back to localhost:3000.
const APP_BASE_URL = (process.env.NEXT_PUBLIC_APP_URL || "https://rf-flux.com").replace(/\/$/, "");
// Multi-variant favicon detection: if the editor uploaded a master image
// through /api/branding/favicon, the multi-size set lives at /branding/
// and we reference each size explicitly. Falls back to the single
// branding.faviconUrl when the master hasn't been generated yet.
import fs from "fs";
import path from "path";
function detectFaviconVariants(): { hasVariants: boolean } {
try {
const dir = path.join(process.cwd(), "public", "branding");
if (!fs.existsSync(dir)) return { hasVariants: false };
const expected = ["favicon-32.png", "favicon-180.png", "favicon-192.png"];
return { hasVariants: expected.every((f) => fs.existsSync(path.join(dir, f))) };
} catch {
return { hasVariants: false };
}
}
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const branding = await getBranding(); const branding = await getBranding();
const { hasVariants } = detectFaviconVariants();
// When the multi-variant set exists, reference every size browsers and
// OSes look for. When it doesn't, use the single faviconUrl from settings
// so the legacy upload path keeps working.
const icons = hasVariants
? {
icon: [
{ url: "/branding/favicon-32.png", type: "image/png", sizes: "32x32" },
{ url: "/branding/favicon-16.png", type: "image/png", sizes: "16x16" },
{ url: "/branding/favicon-192.png", type: "image/png", sizes: "192x192" },
],
shortcut: "/branding/favicon-32.png",
apple: [{ url: "/branding/favicon-180.png", sizes: "180x180" }],
other: [
{ rel: "mask-icon", url: "/branding/favicon-512.png", color: branding.themeColor },
],
}
: {
icon: branding.faviconUrl,
shortcut: branding.faviconUrl,
apple: branding.appleTouchIconUrl,
};
// Google Search Console verification (HTML-tag method). Emits
// <meta name="google-site-verification" content="..."> when set.
const gscToken = process.env.NEXT_PUBLIC_GSC_VERIFICATION;
return { return {
metadataBase: new URL(APP_BASE_URL),
title: "FLUX | Energy, Directed.", title: "FLUX | Energy, Directed.",
description: "Advanced Radio Frequency Solutions by Patrizio Grando.", description: "Advanced Radio Frequency Solutions by Patrizio Grando.",
icons: { icons,
icon: branding.faviconUrl, manifest: "/manifest.webmanifest",
shortcut: branding.faviconUrl, ...(gscToken ? { verification: { google: gscToken } } : {}),
apple: branding.appleTouchIconUrl,
},
openGraph: { openGraph: {
title: "FLUX | Energy, Directed.", title: "FLUX | Energy, Directed.",
description: "Advanced Radio Frequency Solutions by Patrizio Grando.", description: "Advanced Radio Frequency Solutions by Patrizio Grando.",
@@ -69,7 +131,17 @@ export default async function RootLayout({
notFound(); notFound();
} }
const messages = await getMessages(); // Cache the locale so next-intl resolves it from memory instead of
// reading cookies/headers — this is what allows ISR on child pages.
setRequestLocale(locale);
const [messages, branding, social] = await Promise.all([
getMessages({ locale }),
getBranding(),
getSocialLinks(),
]);
const sameAs = [social.linkedin, social.instagram, social.youtube].filter(Boolean);
return ( return (
<html lang={locale} className="scroll-smooth bg-[#F5F5F7]"> <html lang={locale} className="scroll-smooth bg-[#F5F5F7]">
@@ -82,25 +154,51 @@ export default async function RootLayout({
position: "relative", position: "relative",
}} }}
> >
{/* Site-wide JSON-LD: Organization + WebSite — picked up by Google
knowledge panel and rich snippets. */}
<JsonLd
data={[
organizationSchema({
logoUrl: branding.logoUrl,
sameAs: sameAs.length ? sameAs : undefined,
}),
localBusinessSchema({
logoUrl: branding.logoUrl,
sameAs: sameAs.length ? sameAs : undefined,
}),
websiteSchema(),
]}
/>
<NextIntlClientProvider messages={messages}> <NextIntlClientProvider messages={messages}>
<NavBar /> <NavBar />
{/* 🔥 Panel del Carrito de Repuestos y Soporte Técnico 🔥 */}
<CartDrawer /> <CartDrawer />
{/* Inyectamos el manejador de transiciones aquí */} {/* Suspense boundary required for useSearchParams() under ISR */}
<NavigationManager /> <Suspense fallback={null}>
<NavigationManager />
</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 /> <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>
); );
+109 -29
View File
@@ -1,27 +1,64 @@
// ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads. // ISR: revalidate every 60s — see /[locale]/page.tsx for the full rationale.
export const revalidate = 60; export const revalidate = 60;
import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { ArrowLeft, Calendar, Tag, Linkedin } from "lucide-react"; import { ArrowLeft, Calendar, Tag, Linkedin } from "lucide-react";
import BreathingField from "@/components/visuals/BreathingField"; import BreathingField from "@/components/visuals/BreathingField";
// 🔥 IMPORTACIÓN DEL MOTOR DE TRADUCCIÓN 🔥 import { getTranslations, setRequestLocale } from "next-intl/server";
import { getLocalizedData } from "@/lib/i18nHelper"; import { getLocalizedData } from "@/lib/i18nHelper";
import Breadcrumbs from "@/components/seo/Breadcrumbs";
import {
buildPageMetadata,
articleSchema,
breadcrumbSchema,
baseUrl,
} from "@/lib/seo";
import JsonLd from "@/components/seo/JsonLd";
export async function generateStaticParams() { export async function generateMetadata({
// In production Docker build, DB is not available. params,
// Pages are generated on-demand via SSR instead. }: {
if (process.env.NODE_ENV === 'production' && !process.env.VERCEL) { params: Promise<{ slug: string; locale: string }>;
return []; }): Promise<Metadata> {
const { slug, locale } = await params;
try {
const raw = await prisma.newsArticle.findUnique({ where: { slug } });
if (!raw || !raw.isActive) {
return { title: "Article not found | FLUX", robots: { index: false, follow: false } };
}
const article = getLocalizedData(raw, locale);
const cover = article.coverImage ? `/news/${slug}/${article.coverImage}` : undefined;
return buildPageMetadata({
locale,
pathWithoutLocale: `news/${slug}`,
title: `${article.title} | FLUX Inside`,
description: article.excerpt,
ogImageUrl: cover,
type: "article",
publishedAt: article.publishedAt,
updatedAt: article.updatedAt,
});
} catch {
return { title: "FLUX | Energy, Directed." };
} }
}
// Pre-render all published news slugs at build time. New articles added
// after deploy render on-demand and get cached by ISR (revalidate=60).
// try/catch ensures the build never fails if the DB is unreachable.
export async function generateStaticParams() {
try { try {
const articles = await prisma.newsArticle.findMany({ const articles = await prisma.newsArticle.findMany({
where: { isActive: true },
select: { slug: true }, select: { slug: true },
}); });
return articles.map((a: any) => ({ slug: a.slug })); return articles.map((a) => ({ slug: a.slug }));
} catch { } catch {
return []; return [];
} }
@@ -190,10 +227,15 @@ const renderMarkdown = (text: string) => {
export default async function ArticlePage({ params }: { params: Promise<{ slug: string, locale: string }> }) { export default async function ArticlePage({ params }: { params: Promise<{ slug: string, locale: string }> }) {
const resolvedParams = await params; const resolvedParams = await params;
const { slug, locale } = resolvedParams; const { slug, locale } = resolvedParams;
setRequestLocale(locale);
const t = await getTranslations({ locale, namespace: "ArticlePage" });
const rawArticle = await prisma.newsArticle.findUnique({ let rawArticle: any = null;
where: { slug } try {
}); rawArticle = await prisma.newsArticle.findUnique({ where: { slug } });
} catch (error) {
console.error(`[news/${slug}] DB fetch failed:`, error);
}
if (!rawArticle) { if (!rawArticle) {
return ( return (
@@ -205,32 +247,70 @@ export default async function ArticlePage({ params }: { params: Promise<{ slug:
} }
// 🔥 TRADUCCIÓN MÁGICA ANTES DEL RENDER 🔥 // 🔥 TRADUCCIÓN MÁGICA ANTES DEL RENDER 🔥
const article = getLocalizedData(rawArticle, locale); let article: any;
try {
article = getLocalizedData(rawArticle, locale);
} catch (error) {
console.error(`[news/${slug}] Locale merge failed:`, error);
article = rawArticle;
}
let gallery: string[] = []; let gallery: string[] = [];
try { gallery = JSON.parse(article.galleryJson || "[]"); } catch (e) {} try { gallery = JSON.parse(article.galleryJson || "[]"); } catch (e) {}
const articleUrl = `${baseUrl()}/${locale}/news/${slug}`;
const cover = article.coverImage ? `/news/${slug}/${article.coverImage}` : undefined;
const crumbs = [
{ name: "Home", url: `/${locale}` },
{ name: "News", url: `/${locale}/news` },
{ name: article?.title || "Article", url: `/${locale}/news/${slug}` },
];
let jsonLd: object[] = [];
try {
jsonLd = [
articleSchema({
headline: article?.title || "FLUX Article",
description: article?.excerpt || "",
imageUrl: cover,
url: articleUrl,
publishedAt: article?.publishedAt || new Date(),
updatedAt: article?.updatedAt || new Date(),
}),
breadcrumbSchema(
crumbs.map((c) => ({ name: c.name, url: `${baseUrl()}${c.url}` }))
),
];
} catch (error) {
console.error(`[news/${slug}] JSON-LD build failed:`, error);
}
return ( return (
<main className="relative min-h-screen pb-24"> <main className="relative min-h-screen pb-24">
{jsonLd.length > 0 && <JsonLd data={jsonLd} />}
<BreathingField /> <BreathingField />
<div className="fixed top-24 left-6 z-50 hidden md:block"> <div className="fixed top-24 left-6 z-50 hidden md:block">
{/* EL BOTÓN DE VOLVER AHORA RESPETA EL IDIOMA */} {/* EL BOTÓN DE VOLVER AHORA RESPETA EL IDIOMA */}
<Link href={`/${locale}/news`} className="inline-flex items-center gap-2 text-sm font-medium text-[#86868B] hover:text-[#0066CC] dark:hover:text-[#4DA6FF] transition-colors py-2 px-4 bg-white/50 dark:bg-black/50 backdrop-blur-md rounded-full group shadow-sm border border-black/5 dark:border-white/10"> <Link href={`/${locale}/news`} className="inline-flex items-center gap-2 text-sm font-medium text-[#86868B] hover:text-[#0066CC] dark:hover:text-[#4DA6FF] transition-colors py-2 px-4 bg-white/50 dark:bg-black/50 backdrop-blur-md rounded-full group shadow-sm border border-black/5 dark:border-white/10">
<ArrowLeft size={16} className="group-hover:-translate-x-1 transition-transform" /> Back to News Hub <ArrowLeft size={16} className="group-hover:-translate-x-1 transition-transform" /> {t("backToNewsHub")}
</Link> </Link>
</div> </div>
<section className="relative w-full h-[50vh] md:h-[70vh] flex items-end justify-center overflow-hidden bg-[#1D1D1F]"> <section className="relative w-full h-[50vh] md:h-[70vh] flex items-end justify-center overflow-hidden bg-[#1D1D1F]">
{article.coverImage && ( {article.coverImage && (
<Image src={`/news/${slug}/${article.coverImage}`} alt={article.title} fill className="object-cover object-center opacity-60" priority /> <Image src={`/news/${slug}/${article.coverImage}`} alt={article.title} fill sizes="100vw" className="object-cover object-center opacity-60" priority />
)} )}
<div className="absolute inset-0 bg-gradient-to-t from-white via-white/80 dark:from-[#0A0A0C] dark:via-[#0A0A0C]/80 to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-white via-white/80 dark:from-[#0A0A0C] dark:via-[#0A0A0C]/80 to-transparent" />
<div className="relative z-10 max-w-4xl w-full mx-auto px-6 pb-12 md:pb-20 text-center"> <div className="relative z-10 max-w-4xl w-full mx-auto px-6 pb-12 md:pb-20 text-center">
<div className="flex justify-center mb-4">
<Breadcrumbs items={crumbs} />
</div>
<div className="flex flex-wrap items-center justify-center gap-4 text-xs font-semibold uppercase tracking-widest text-[#0066CC] dark:text-[#4DA6FF] mb-6"> <div className="flex flex-wrap items-center justify-center gap-4 text-xs font-semibold uppercase tracking-widest text-[#0066CC] dark:text-[#4DA6FF] mb-6">
<span className="flex items-center gap-1.5 bg-[#0066CC]/10 dark:bg-[#4DA6FF]/10 px-3 py-1.5 rounded-full"><Tag size={14}/> {article.category}</span> <span className="flex items-center gap-1.5 bg-[#0066CC]/10 dark:bg-[#4DA6FF]/10 px-3 py-1.5 rounded-full"><Tag size={14}/> {article.category}</span>
<span className="flex items-center gap-1.5 text-[#86868B]"><Calendar size={14}/> {new Date(article.publishedAt).toLocaleDateString(locale === 'en' ? 'en-US' : locale, { month: 'long', day: 'numeric', year: 'numeric' })}</span> <time dateTime={new Date(article.publishedAt).toISOString()} className="flex items-center gap-1.5 text-[#86868B]"><Calendar size={14}/> {new Date(article.publishedAt).toLocaleDateString(locale === 'en' ? 'en-US' : locale, { month: 'long', day: 'numeric', year: 'numeric' })}</time>
</div> </div>
<h1 className="text-4xl md:text-6xl font-medium text-[#1D1D1F] dark:text-[#F5F5F7] tracking-tight mb-6 leading-tight"> <h1 className="text-4xl md:text-6xl font-medium text-[#1D1D1F] dark:text-[#F5F5F7] tracking-tight mb-6 leading-tight">
{article.title} {article.title}
@@ -241,8 +321,8 @@ export default async function ArticlePage({ params }: { params: Promise<{ slug:
</div> </div>
</section> </section>
<div className="relative z-10 max-w-3xl mx-auto px-6 mt-12 md:mt-20"> <article className="relative z-10 max-w-3xl mx-auto px-6 mt-12 md:mt-20">
{/* 🔥 EL MARKDOWN TRADUCIDO AL VUELO 🔥 */} {/* 🔥 EL MARKDOWN TRADUCIDO AL VUELO 🔥 */}
<div className="max-w-none mb-16"> <div className="max-w-none mb-16">
{renderMarkdown(article.content)} {renderMarkdown(article.content)}
@@ -250,11 +330,11 @@ export default async function ArticlePage({ params }: { params: Promise<{ slug:
{gallery.length > 0 && ( {gallery.length > 0 && (
<div className="mt-16 pt-16 border-t border-black/5 dark:border-white/5"> <div className="mt-16 pt-16 border-t border-black/5 dark:border-white/5">
<h3 className="text-xl font-medium mb-8 text-[#1D1D1F] dark:text-white">Media Gallery</h3> <h3 className="text-xl font-medium mb-8 text-[#1D1D1F] dark:text-white">{t("mediaGallery")}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{gallery.map((imgSrc: string, idx: number) => ( {gallery.map((imgSrc: string, idx: number) => (
<div key={idx} className={`relative rounded-3xl overflow-hidden bg-[#1D1D1F] border border-black/10 dark:border-white/10 ${idx === 0 && gallery.length % 2 !== 0 ? 'sm:col-span-2 h-64 md:h-96' : 'h-48 md:h-64'}`}> <div key={idx} className={`relative rounded-3xl overflow-hidden bg-[#1D1D1F] border border-black/10 dark:border-white/10 ${idx === 0 && gallery.length % 2 !== 0 ? 'sm:col-span-2 h-64 md:h-96' : 'h-48 md:h-64'}`}>
<Image src={`/news/${slug}/gallery/${imgSrc}`} alt={`Gallery image ${idx + 1}`} fill className="object-cover hover:scale-105 transition-transform duration-700" /> <Image src={`/news/${slug}/gallery/${imgSrc}`} alt={`Gallery image ${idx + 1}`} fill sizes="(max-width: 640px) 100vw, 50vw" className="object-cover hover:scale-105 transition-transform duration-700" />
</div> </div>
))} ))}
</div> </div>
@@ -263,25 +343,25 @@ export default async function ArticlePage({ params }: { params: Promise<{ slug:
<div className="mt-16 pt-8 border-t border-black/10 dark:border-white/10 flex justify-between items-center"> <div className="mt-16 pt-8 border-t border-black/10 dark:border-white/10 flex justify-between items-center">
<Link href={`/${locale}/news`} className="text-[#0066CC] dark:text-[#4DA6FF] font-medium flex items-center gap-2 hover:underline md:hidden"> <Link href={`/${locale}/news`} className="text-[#0066CC] dark:text-[#4DA6FF] font-medium flex items-center gap-2 hover:underline md:hidden">
<ArrowLeft size={16} /> Back to News <ArrowLeft size={16} /> {t("backToNews")}
</Link> </Link>
{article.linkedinUrl ? ( {article.linkedinUrl ? (
<a <a
href={article.linkedinUrl} href={article.linkedinUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-2 text-sm font-semibold text-white bg-[#0A66C2] px-6 py-3 rounded-full hover:bg-[#004182] transition-all shadow-lg shadow-[#0A66C2]/20 ml-auto md:ml-0" className="flex items-center gap-2 text-sm font-semibold text-white bg-[#0A66C2] px-6 py-3 rounded-full hover:bg-[#004182] transition-all shadow-lg shadow-[#0A66C2]/20 ml-auto md:ml-0"
> >
<Linkedin size={16} /> Join the conversation on LinkedIn <Linkedin size={16} /> {t("joinLinkedIn")}
</a> </a>
) : ( ) : (
<div className="text-xs text-[#86868B] italic hidden md:block"> <div className="text-xs text-[#86868B] italic hidden md:block">
Internal Corporate Release {t("internalRelease")}
</div> </div>
)} )}
</div> </div>
</div> </article>
</main> </main>
); );
} }
+45 -9
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -5,17 +6,34 @@ import { Newspaper, ArrowRight, Calendar } from "lucide-react";
import BreathingField from "@/components/visuals/BreathingField"; import BreathingField from "@/components/visuals/BreathingField";
import { getLocalizedData } from "@/lib/i18nHelper"; import { getLocalizedData } from "@/lib/i18nHelper";
import { getTranslations } from "next-intl/server"; import { getTranslations, setRequestLocale } from "next-intl/server";
import { buildPageMetadata, collectionPageSchema, baseUrl } from "@/lib/seo";
import JsonLd from "@/components/seo/JsonLd";
// ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads. // ISR: revalidate every 60s — see /[locale]/page.tsx for the full rationale.
export const revalidate = 60; 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: "NewsHub" });
return buildPageMetadata({
locale,
pathWithoutLocale: "news",
title: `${t("subtitle")}${t("title1").trim()} ${t("title2").trim()} | FLUX`,
description: t("description"),
});
}
export default async function NewsHub({ params }: { params: Promise<{ locale: string }> }) { export default async function NewsHub({ params }: { params: Promise<{ locale: string }> }) {
const resolvedParams = await params; const resolvedParams = await params;
const locale = resolvedParams.locale; const locale = resolvedParams.locale;
// 🔥 LLAMAMOS AL DICCIONARIO setRequestLocale(locale);
const t = await getTranslations("NewsHub"); const t = await getTranslations({ locale, namespace: "NewsHub" });
let rawArticles: any[] = []; let rawArticles: any[] = [];
try { try {
@@ -32,8 +50,22 @@ export default async function NewsHub({ params }: { params: Promise<{ locale: st
const heroArticle = articles.length > 0 ? articles[0] : null; const heroArticle = articles.length > 0 ? articles[0] : null;
const gridArticles = articles.length > 1 ? articles.slice(1) : []; const gridArticles = articles.length > 1 ? articles.slice(1) : [];
const collectionSchema = articles.length > 0
? collectionPageSchema({
name: `${t("title1")} ${t("title2")} — FLUX`,
description: t("description"),
url: `${baseUrl()}/${locale}/news`,
items: articles.map((a: any, idx: number) => ({
name: a.title,
url: `${baseUrl()}/${locale}/news/${a.slug}`,
position: idx + 1,
})),
})
: null;
return ( return (
<main className="relative min-h-screen pt-32 pb-24"> <main className="relative min-h-screen pt-32 pb-24">
{collectionSchema && <JsonLd data={collectionSchema} />}
<BreathingField /> <BreathingField />
<div className="relative z-10 max-w-7xl mx-auto px-6"> <div className="relative z-10 max-w-7xl mx-auto px-6">
@@ -59,10 +91,11 @@ export default async function NewsHub({ params }: { params: Promise<{ locale: st
{/* HERO ARTICLE */} {/* HERO ARTICLE */}
{heroArticle && ( {heroArticle && (
<article>
<Link href={`/${locale}/news/${heroArticle.slug}`} className="group relative w-full rounded-[2rem] overflow-hidden bg-white/60 dark:bg-[#1D1D1F]/40 backdrop-blur-2xl border border-black/5 dark:border-white/10 flex flex-col md:flex-row shadow-lg hover:shadow-xl transition-all duration-500 hover:-translate-y-1"> <Link href={`/${locale}/news/${heroArticle.slug}`} className="group relative w-full rounded-[2rem] overflow-hidden bg-white/60 dark:bg-[#1D1D1F]/40 backdrop-blur-2xl border border-black/5 dark:border-white/10 flex flex-col md:flex-row shadow-lg hover:shadow-xl transition-all duration-500 hover:-translate-y-1">
<div className="w-full md:w-1/2 h-56 md:h-[400px] relative overflow-hidden bg-[#1D1D1F]"> <div className="w-full md:w-1/2 h-56 md:h-[400px] relative overflow-hidden bg-[#1D1D1F]">
{heroArticle.coverImage ? ( {heroArticle.coverImage ? (
<Image src={`/news/${heroArticle.slug}/${heroArticle.coverImage}`} alt={heroArticle.title} fill className="object-cover transition-transform duration-700 group-hover:scale-105" /> <Image src={`/news/${heroArticle.slug}/${heroArticle.coverImage}`} alt={heroArticle.title} fill sizes="(max-width: 768px) 100vw, 50vw" className="object-cover transition-transform duration-700 group-hover:scale-105" />
) : ( ) : (
<div className="absolute inset-0 bg-gradient-to-br from-[#0066CC]/20 to-[#0A0A0C] flex items-center justify-center"><Newspaper size={64} className="text-white/10" /></div> <div className="absolute inset-0 bg-gradient-to-br from-[#0066CC]/20 to-[#0A0A0C] flex items-center justify-center"><Newspaper size={64} className="text-white/10" /></div>
)} )}
@@ -70,7 +103,7 @@ export default async function NewsHub({ params }: { params: Promise<{ locale: st
<div className="w-full md:w-1/2 p-6 md:p-10 flex flex-col justify-center"> <div className="w-full md:w-1/2 p-6 md:p-10 flex flex-col justify-center">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<span className="text-[9px] font-bold uppercase tracking-widest text-[#0066CC] dark:text-[#4DA6FF] bg-[#0066CC]/10 dark:bg-[#4DA6FF]/10 px-2.5 py-1 rounded-full">{heroArticle.category}</span> <span className="text-[9px] font-bold uppercase tracking-widest text-[#0066CC] dark:text-[#4DA6FF] bg-[#0066CC]/10 dark:bg-[#4DA6FF]/10 px-2.5 py-1 rounded-full">{heroArticle.category}</span>
<span className="text-[10px] font-medium text-[#86868B] flex items-center gap-1"><Calendar size={12}/> {new Date(heroArticle.publishedAt).toLocaleDateString(locale === 'en' ? 'en-US' : locale, { month: 'short', day: 'numeric', year: 'numeric' })}</span> <time dateTime={new Date(heroArticle.publishedAt).toISOString()} className="text-[10px] font-medium text-[#86868B] flex items-center gap-1"><Calendar size={12}/> {new Date(heroArticle.publishedAt).toLocaleDateString(locale === 'en' ? 'en-US' : locale, { month: 'short', day: 'numeric', year: 'numeric' })}</time>
</div> </div>
<h2 className="text-2xl md:text-3xl font-medium text-[#1D1D1F] dark:text-white mb-3 leading-tight group-hover:text-[#0066CC] dark:group-hover:text-[#4DA6FF] transition-colors">{heroArticle.title}</h2> <h2 className="text-2xl md:text-3xl font-medium text-[#1D1D1F] dark:text-white mb-3 leading-tight group-hover:text-[#0066CC] dark:group-hover:text-[#4DA6FF] transition-colors">{heroArticle.title}</h2>
<p className="text-sm text-[#86868B] dark:text-[#A1A1A6] leading-relaxed mb-6 line-clamp-3">{heroArticle.excerpt}</p> <p className="text-sm text-[#86868B] dark:text-[#A1A1A6] leading-relaxed mb-6 line-clamp-3">{heroArticle.excerpt}</p>
@@ -79,16 +112,18 @@ export default async function NewsHub({ params }: { params: Promise<{ locale: st
</span> </span>
</div> </div>
</Link> </Link>
</article>
)} )}
{/* GRID COLUMNAS */} {/* GRID COLUMNAS */}
{gridArticles.length > 0 && ( {gridArticles.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6 mt-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6 mt-4">
{gridArticles.map((article) => ( {gridArticles.map((article) => (
<Link key={article.id} href={`/${locale}/news/${article.slug}`} className="group flex flex-col bg-white/60 dark:bg-[#1D1D1F]/40 backdrop-blur-md border border-black/5 dark:border-white/10 rounded-3xl overflow-hidden shadow-sm hover:shadow-md transition-all duration-500 hover:-translate-y-1"> <article key={article.id} className="flex flex-col">
<Link href={`/${locale}/news/${article.slug}`} className="group flex flex-col flex-1 bg-white/60 dark:bg-[#1D1D1F]/40 backdrop-blur-md border border-black/5 dark:border-white/10 rounded-3xl overflow-hidden shadow-sm hover:shadow-md transition-all duration-500 hover:-translate-y-1">
<div className="w-full h-40 relative overflow-hidden bg-[#1D1D1F]"> <div className="w-full h-40 relative overflow-hidden bg-[#1D1D1F]">
{article.coverImage ? ( {article.coverImage ? (
<Image src={`/news/${article.slug}/${article.coverImage}`} alt={article.title} fill className="object-cover transition-transform duration-700 group-hover:scale-105" /> <Image src={`/news/${article.slug}/${article.coverImage}`} alt={article.title} fill sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw" className="object-cover transition-transform duration-700 group-hover:scale-105" />
) : ( ) : (
<div className="absolute inset-0 bg-gradient-to-br from-[#0066CC]/10 to-[#0A0A0C]" /> <div className="absolute inset-0 bg-gradient-to-br from-[#0066CC]/10 to-[#0A0A0C]" />
)} )}
@@ -96,7 +131,7 @@ export default async function NewsHub({ params }: { params: Promise<{ locale: st
<div className="p-5 flex flex-col flex-1"> <div className="p-5 flex flex-col flex-1">
<div className="flex justify-between items-center mb-3"> <div className="flex justify-between items-center mb-3">
<span className="text-[8px] font-bold uppercase tracking-widest text-[#0066CC] dark:text-[#4DA6FF]">{article.category}</span> <span className="text-[8px] font-bold uppercase tracking-widest text-[#0066CC] dark:text-[#4DA6FF]">{article.category}</span>
<span className="text-[9px] text-[#86868B]">{new Date(article.publishedAt).toLocaleDateString(locale === 'en' ? 'en-US' : locale, { month: 'short', day: 'numeric' })}</span> <time dateTime={new Date(article.publishedAt).toISOString()} className="text-[9px] text-[#86868B]">{new Date(article.publishedAt).toLocaleDateString(locale === 'en' ? 'en-US' : locale, { month: 'short', day: 'numeric' })}</time>
</div> </div>
<h3 className="text-base font-medium text-[#1D1D1F] dark:text-white mb-2 leading-snug group-hover:text-[#0066CC] dark:group-hover:text-[#4DA6FF] transition-colors line-clamp-2">{article.title}</h3> <h3 className="text-base font-medium text-[#1D1D1F] dark:text-white mb-2 leading-snug group-hover:text-[#0066CC] dark:group-hover:text-[#4DA6FF] transition-colors line-clamp-2">{article.title}</h3>
<p className="text-xs text-[#86868B] dark:text-[#A1A1A6] line-clamp-2 mb-4">{article.excerpt}</p> <p className="text-xs text-[#86868B] dark:text-[#A1A1A6] line-clamp-2 mb-4">{article.excerpt}</p>
@@ -105,6 +140,7 @@ export default async function NewsHub({ params }: { params: Promise<{ locale: st
</span> </span>
</div> </div>
</Link> </Link>
</article>
))} ))}
</div> </div>
)} )}
+46 -5
View File
@@ -1,6 +1,6 @@
// src/app/[locale]/page.tsx // src/app/[locale]/page.tsx
// ✅ CORRECCIÓN: dynamic ya estaba, pero reforzamos el patrón de params
import type { Metadata } from "next";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -15,13 +15,54 @@ import ApplicationsDeep from "@/components/sections/ApplicationsDeep";
import HeroReel from "@/components/sections/HeroReel"; import HeroReel from "@/components/sections/HeroReel";
import WhatWeDo from "@/components/sections/WhatWeDo"; import WhatWeDo from "@/components/sections/WhatWeDo";
// ISR: page is statically generated, but revalidates on demand via import { setRequestLocale } from "next-intl/server";
// revalidatePath() after CMS uploads, plus a 60s safety window. import { buildPageMetadata } from "@/lib/seo";
import { getBranding } from "@/lib/siteSettings";
// ISR: revalidate every 60s. The DYNAMIC_SERVER_USAGE issue was caused by
// next-intl internally reading cookies/headers to resolve the locale.
// Fixed by calling setRequestLocale(locale) which caches the locale in
// React cache, preventing the dynamic API read. Nginx also caches with
// s-maxage=60 + stale-while-revalidate=300 as a safety net.
export const revalidate = 60; export const revalidate = 60;
const TITLES: Record<string, string> = {
en: "FLUX | Solid-State RF Industrial Solutions",
it: "FLUX | Soluzioni Industriali Solid-State RF",
vec: "FLUX | Solusion Industriali Solid-State RF",
es: "FLUX | Soluciones Industriales Solid-State RF",
de: "FLUX | Solid-State RF Industrielle Lösungen",
};
const DESCRIPTIONS: Record<string, string> = {
en: "World-leading Solid-State RF, Microwave and Infrared industrial equipment. Drying, vulcanization, defrosting and more — 95% efficiency, 40+ years of legacy by Patrizio Grando.",
it: "Leader mondiale in apparecchiature industriali Solid-State RF, Microwave e Infrarossi. Essiccazione, vulcanizzazione, scongelamento — 95% di efficienza, 40+ anni di eredità di Patrizio Grando.",
vec: "Lìder nel mondo par machinari industriali Solid-State RF, Microwave e Infrarossi. Sugar, vulcanizar, descongelar — 95% de eficiensa, 40+ ani de eredità de Patrizio Grando.",
es: "Líder mundial en equipos industriales Solid-State RF, Microondas e Infrarrojos. Secado, vulcanización, descongelación — 95% de eficiencia, 40+ años de legado de Patrizio Grando.",
de: "Weltweit führend bei industriellen Solid-State RF-, Mikrowellen- und Infrarot-Anlagen. Trocknung, Vulkanisation, Auftauen — 95% Effizienz, 40+ Jahre Erbe von Patrizio Grando.",
};
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const branding = await getBranding();
return buildPageMetadata({
locale,
pathWithoutLocale: "",
title: TITLES[locale] || TITLES.en,
description: DESCRIPTIONS[locale] || DESCRIPTIONS.en,
ogImageUrl: branding.ogImageUrl,
type: "website",
});
}
// ✅ Next.js 16: params es Promise y DEBE ser awaiteado // ✅ Next.js 16: params es Promise y DEBE ser awaiteado
export default async function Home({ params }: { params: Promise<{ locale: string }> }) { export default async function Home({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params; const { locale } = await params;
setRequestLocale(locale);
// --- 1. SLIDES DEL HERO (CMS-managed via HeroSlide model, fallback to filesystem) --- // --- 1. SLIDES DEL HERO (CMS-managed via HeroSlide model, fallback to filesystem) ---
let heroSlides: Array<{ let heroSlides: Array<{
@@ -109,7 +150,7 @@ export default async function Home({ params }: { params: Promise<{ locale: strin
shortDescription: true, heroDescription: true, shortDescription: true, heroDescription: true,
dashboardMetricsJson: true, isActive: true, translationsJson: true dashboardMetricsJson: true, isActive: true, translationsJson: true
}, },
orderBy: { createdAt: "asc" } orderBy: [{ order: "asc" }, { createdAt: "asc" }]
}); });
dbApps = rawApps.map((app: any) => getLocalizedData(app as any, locale)); dbApps = rawApps.map((app: any) => getLocalizedData(app as any, locale));
} catch (error) { } catch (error) {
@@ -140,7 +181,7 @@ export default async function Home({ params }: { params: Promise<{ locale: strin
<ApplicationsDashboard dbApps={dbApps} /> <ApplicationsDashboard dbApps={dbApps} />
<GlobalOperations dbNodes={mapNodes} dbApps={dbApps} /> <GlobalOperations dbNodes={mapNodes} dbApps={dbApps} />
<OurStory dbTimeline={dbTimeline} /> <OurStory dbTimeline={dbTimeline} />
<PatrizioLegacy /> <PatrizioLegacy locale={locale} />
<div className="h-64 w-full"></div> <div className="h-64 w-full"></div>
</div> </div>
</main> </main>
@@ -43,6 +43,8 @@ export default function AuthModal({ session }: { session: any }) {
setError(res.error); setError(res.error);
} else { } else {
setIsOpen(false); setIsOpen(false);
// NavBar listens to this event to refresh its session badge without polling.
if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent("flux:session-changed"));
router.refresh(); router.refresh();
} }
setIsLoading(false); setIsLoading(false);
@@ -84,9 +86,10 @@ export default function AuthModal({ session }: { session: any }) {
}; };
const handleLogout = async () => { const handleLogout = async () => {
setIsLoading(true); setIsLoading(true);
await logoutClient(); await logoutClient();
setIsOpen(false); setIsOpen(false);
if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent("flux:session-changed"));
router.refresh(); router.refresh();
}; };
+198
View File
@@ -0,0 +1,198 @@
import type { Metadata } from "next";
import { setRequestLocale } from "next-intl/server";
import { buildPageMetadata } from "@/lib/seo";
import Breadcrumbs from "@/components/seo/Breadcrumbs";
// Static legal page. Revalidate rarely.
export const revalidate = 86400;
const LAST_UPDATED = "June 2026";
const COMPANY = "FLUX Srl";
const ADDRESS = "Romano d'Ezzelino, Vicenza, Italy";
const CONTACT_EMAIL = "privacy@rf-flux.com"; // TODO: confirm with FLUX legal
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
return buildPageMetadata({
locale,
pathWithoutLocale: "privacy",
title: "Privacy & Cookie Policy | FLUX",
description:
"How FLUX Srl collects, uses and protects personal data on rf-flux.com, in compliance with the EU GDPR.",
});
}
export default async function PrivacyPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const crumbs = [
{ name: "Home", url: `/${locale}` },
{ name: "Privacy & Cookie Policy", url: `/${locale}/privacy` },
];
return (
<main className="relative w-full min-h-screen bg-[#F5F5F7] dark:bg-[#050505]">
<div className="max-w-3xl mx-auto px-6 pt-28 md:pt-36 pb-24">
<Breadcrumbs items={crumbs} />
<header className="mt-6 mb-10">
<h1 className="text-3xl md:text-5xl font-light text-[#1D1D1F] dark:text-white tracking-tight">
Privacy &amp; Cookie <span className="font-medium">Policy</span>
</h1>
<p className="mt-3 text-sm text-[#86868B] dark:text-[#A1A1A6]">Last updated: {LAST_UPDATED}</p>
</header>
{/* Template disclaimer — remove once reviewed by legal counsel */}
<div className="mb-10 rounded-2xl border border-amber-300/50 dark:border-amber-400/30 bg-amber-50 dark:bg-amber-400/10 p-4 text-sm text-amber-900 dark:text-amber-200">
<strong>Template notice:</strong> this is a standard GDPR-compliant
template provided as a starting point. Please have it reviewed and
adapted by your legal counsel before relying on it, and confirm the
contact details below.
</div>
<div className="space-y-8 text-[#1D1D1F] dark:text-[#F5F5F7]">
<Section title="1. Who we are">
<P>
{COMPANY} (&ldquo;we&rdquo;, &ldquo;us&rdquo;, &ldquo;our&rdquo;)
is the data controller responsible for your personal data
collected through this website, {SITE}. Our registered office is
in {ADDRESS}.
</P>
<P>
For any privacy-related request you can contact us at{" "}
<a href={`mailto:${CONTACT_EMAIL}`} className="text-[#0066CC] dark:text-[#00F0FF] underline underline-offset-2">
{CONTACT_EMAIL}
</a>
.
</P>
</Section>
<Section title="2. What data we collect">
<P>We collect personal data only when you actively provide it, or through privacy-respecting analytics:</P>
<ul className="list-disc pl-5 space-y-1.5 text-[#3A3A3C]">
<li>
<strong>Contact &amp; 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&rsquo; configured retention period.
</P>
</Section>
<Section title="7. Your rights">
<P>Under the GDPR you have the right to:</P>
<ul className="list-disc pl-5 space-y-1.5 text-[#3A3A3C]">
<li>access, rectify or erase your personal data;</li>
<li>restrict or object to processing;</li>
<li>data portability;</li>
<li>withdraw consent at any time;</li>
<li>lodge a complaint with your data protection authority (in Italy, the Garante per la protezione dei dati personali).</li>
</ul>
<P>
To exercise any of these rights, contact us at{" "}
<a href={`mailto:${CONTACT_EMAIL}`} className="text-[#0066CC] dark:text-[#00F0FF] underline underline-offset-2">
{CONTACT_EMAIL}
</a>
.
</P>
</Section>
<Section title="8. Data security">
<P>
We apply appropriate technical and organisational measures
(encryption in transit, access controls, pseudonymisation) to
protect your data against unauthorised access, loss or misuse.
</P>
</Section>
<Section title="9. Changes to this policy">
<P>
We may update this policy from time to time. The &ldquo;last
updated&rdquo; date at the top reflects the latest revision.
</P>
</Section>
</div>
</div>
</main>
);
}
const SITE = "rf-flux.com";
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section>
<h2 className="text-lg md:text-xl font-semibold text-[#1D1D1F] dark:text-white mb-3">{title}</h2>
<div className="space-y-3 text-[15px] leading-relaxed">{children}</div>
</section>
);
}
function P({ children }: { children: React.ReactNode }) {
return <p className="text-[#3A3A3C] dark:text-[#A1A1A6]">{children}</p>;
}
+114
View File
@@ -0,0 +1,114 @@
"use client";
import Image from "next/image";
import { motion } from "framer-motion";
import { Linkedin, Mail, Globe, Twitter, User } from "lucide-react";
import { trackEvent } from "@/lib/analytics/gtag";
export interface TeamCard {
id: string;
name: string;
role: string;
bio: string | null;
photoUrl: string | null;
email: string | null;
linkedinUrl: string | null;
xUrl: string | null;
websiteUrl: string | null;
}
export default function TeamGrid({ members }: { members: TeamCard[] }) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
{members.map((m, i) => (
<motion.article
key={m.id}
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-60px" }}
transition={{ duration: 0.5, delay: Math.min(i * 0.06, 0.4), ease: [0.16, 1, 0.3, 1] }}
className="group relative flex flex-col rounded-3xl bg-white dark:bg-[#111] border border-black/[0.06] dark:border-white/10 shadow-[0_2px_20px_rgba(0,0,0,0.04)] hover:shadow-[0_12px_40px_rgba(0,0,0,0.10)] dark:shadow-[0_2px_20px_rgba(0,0,0,0.4)] transition-all duration-500 overflow-hidden"
>
{/* Portrait */}
<div className="relative aspect-[4/5] w-full overflow-hidden bg-gradient-to-br from-[#EEF2F5] to-[#E3E9ED] dark:from-[#1A1A1C] dark:to-[#0E0E10]">
{m.photoUrl ? (
<Image
src={m.photoUrl}
alt={m.name}
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
className="object-cover transition-transform duration-700 group-hover:scale-[1.03]"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-[#B0B8BF]">
<User size={64} strokeWidth={1} />
</div>
)}
{/* Subtle gradient for text legibility if needed later */}
<div className="absolute inset-x-0 bottom-0 h-1/3 bg-gradient-to-t from-black/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
{/* Body */}
<div className="flex flex-col flex-1 p-6">
<h3 className="text-lg font-semibold text-[#1D1D1F] dark:text-white tracking-tight">{m.name}</h3>
<p className="text-[#0066CC] dark:text-[#00F0FF] text-xs font-medium uppercase tracking-[0.12em] mt-1">
{m.role}
</p>
{m.bio && (
<p className="mt-4 text-sm leading-relaxed text-[#6E6E73] dark:text-[#A1A1A6] line-clamp-5">{m.bio}</p>
)}
{/* Social links — only the ones that exist */}
<div className="mt-auto pt-5 flex items-center gap-2">
{m.linkedinUrl && (
<SocialLink href={m.linkedinUrl} label={`${m.name} on LinkedIn`} name={m.name} network="linkedin">
<Linkedin size={16} />
</SocialLink>
)}
{m.xUrl && (
<SocialLink href={m.xUrl} label={`${m.name} on X`} name={m.name} network="x">
<Twitter size={16} />
</SocialLink>
)}
{m.websiteUrl && (
<SocialLink href={m.websiteUrl} label={`${m.name} website`} name={m.name} network="web">
<Globe size={16} />
</SocialLink>
)}
{m.email && (
<SocialLink href={`mailto:${m.email}`} label={`Email ${m.name}`} name={m.name} network="email" external={false}>
<Mail size={16} />
</SocialLink>
)}
</div>
</div>
</motion.article>
))}
</div>
);
}
function SocialLink({
href, label, children, name, network, external = true,
}: {
href: string;
label: string;
children: React.ReactNode;
name: string;
network: string;
external?: boolean;
}) {
return (
<a
href={href}
aria-label={label}
title={label}
{...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
onClick={() => trackEvent({ name: "contact_cta_clicked", params: { location: `team:${network}` } })}
className="inline-flex items-center justify-center w-9 h-9 rounded-full border border-black/[0.08] dark:border-white/15 text-[#6E6E73] dark:text-[#A1A1A6] hover:text-white hover:bg-[#1D1D1F] hover:border-[#1D1D1F] dark:hover:bg-[#00F0FF] dark:hover:text-black dark:hover:border-[#00F0FF] transition-colors"
>
{children}
</a>
);
}
+112
View File
@@ -0,0 +1,112 @@
import type { Metadata } from "next";
import { prisma } from "@/lib/prisma";
import { getLocalizedData } from "@/lib/i18nHelper";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { buildPageMetadata, baseUrl } from "@/lib/seo";
import JsonLd from "@/components/seo/JsonLd";
import Breadcrumbs from "@/components/seo/Breadcrumbs";
import BreathingField from "@/components/visuals/BreathingField";
import TeamGrid, { type TeamCard } from "./TeamGrid";
// ISR: revalidate every 60s, like the other public pages.
export const revalidate = 60;
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "TeamPage" });
return buildPageMetadata({
locale,
pathWithoutLocale: "team",
title: `${t("eyebrow")} | FLUX`,
description: t("description"),
});
}
export default async function TeamPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations({ locale, namespace: "TeamPage" });
let members: TeamCard[] = [];
try {
const rows = await prisma.teamMember.findMany({
where: { isActive: true },
orderBy: [{ order: "asc" }, { createdAt: "asc" }],
});
members = rows.map((row) => {
const localized = getLocalizedData(row, locale);
return {
id: localized.id,
name: localized.name,
role: localized.role,
bio: localized.bio,
photoUrl: localized.photoUrl,
email: localized.email,
linkedinUrl: localized.linkedinUrl,
xUrl: localized.xUrl,
websiteUrl: localized.websiteUrl,
};
});
} catch (error) {
console.error("[team] DB error fetching members:", error);
}
// JSON-LD: a Person entity per member, plus a breadcrumb trail.
const orgUrl = baseUrl();
const personSchemas = members.map((m) => ({
"@context": "https://schema.org",
"@type": "Person",
name: m.name,
jobTitle: m.role,
worksFor: { "@type": "Organization", name: "FLUX Srl", url: orgUrl },
...(m.photoUrl ? { image: `${orgUrl}${m.photoUrl}` } : {}),
...(m.linkedinUrl ? { sameAs: [m.linkedinUrl] } : {}),
}));
const crumbs = [
{ name: "Home", url: `/${locale}` },
{ name: t("eyebrow"), url: `/${locale}/team` },
];
return (
<>
{personSchemas.length > 0 && <JsonLd data={personSchemas} />}
<main className="relative w-full min-h-screen bg-[#F5F5F7] dark:bg-[#050505] overflow-hidden">
{/* Ambient visual, consistent with the News / Heritage hubs */}
<div className="absolute inset-0 opacity-60 pointer-events-none">
<BreathingField />
</div>
<div className="relative z-10 max-w-7xl mx-auto px-6 pt-28 md:pt-36 pb-24">
<Breadcrumbs items={crumbs} />
<header className="max-w-3xl mt-6 mb-16 md:mb-24">
<p className="text-[#0066CC] dark:text-[#00F0FF] text-xs md:text-sm font-semibold uppercase tracking-[0.2em] mb-4">
{t("eyebrow")}
</p>
<h1 className="text-4xl md:text-6xl font-light text-[#1D1D1F] dark:text-white tracking-tight leading-[1.05]">
{t("title1")}{" "}
<span className="font-medium">{t("title2")}</span>
</h1>
<p className="mt-6 text-base md:text-lg text-[#6E6E73] dark:text-[#A1A1A6] leading-relaxed max-w-2xl">
{t("description")}
</p>
</header>
{members.length === 0 ? (
<div className="text-center py-24 text-[#86868B] dark:text-[#A1A1A6]">
<p>{t("empty")}</p>
</div>
) : (
<TeamGrid members={members} />
)}
</div>
</main>
</>
);
}
+7 -1
View File
@@ -5,7 +5,13 @@ import bcrypt from "bcryptjs";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { SignJWT, jwtVerify } from "jose"; import { SignJWT, jwtVerify } from "jose";
const getSecretKey = () => new TextEncoder().encode(process.env.SESSION_SECRET || "flux-super-secret-key-2026"); const getSecretKey = () => {
const s = process.env.SESSION_SECRET;
if (!s || s.length < 32) {
throw new Error("SESSION_SECRET environment variable is required (min 32 chars).");
}
return new TextEncoder().encode(s);
};
export async function registerClientRequest(formData: FormData) { export async function registerClientRequest(formData: FormData) {
const fullName = formData.get("fullName") as string; const fullName = formData.get("fullName") as string;
+34 -23
View File
@@ -3,6 +3,7 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { sendEmail } from "@/lib/mailer"; import { sendEmail } from "@/lib/mailer";
import { escapeHtml, escapeAttr, safeMailto } from "@/lib/escapeHtml";
// ── Helper: Generate sequential ticket ID ── // ── Helper: Generate sequential ticket ID ──
async function generateTicketId(type: string): Promise<string> { async function generateTicketId(type: string): Promise<string> {
@@ -91,17 +92,23 @@ export async function submitOperationsSignal(payload: {
replyTo: payload.clientEmail, replyTo: payload.clientEmail,
}); });
// Track email delivery in DB // Track email delivery — best-effort. The signal (lead) is already saved,
await prisma.operationsSignal.update({ // so a telemetry-update hiccup must NOT fail the request and make the
where: { id: signal.id }, // client retry into a duplicate.
data: { try {
emailSentTo: emailResult.sentTo.join(", "), await prisma.operationsSignal.update({
emailSentAt: emailResult.sentAt, where: { id: signal.id },
emailError: emailResult.error, data: {
}, emailSentTo: emailResult.sentTo.join(", "),
}); emailSentAt: emailResult.sentAt,
emailError: emailResult.error,
},
});
} catch (trackErr) {
console.warn("[operations] email tracking update failed (lead already saved):", trackErr);
}
return { success: true, ticketId, emailSent: emailResult.success }; return { success: true, ticketId, emailSent: emailResult.success, emailError: emailResult.error };
} catch (error) { } catch (error) {
console.error("Error submitting signal:", error); console.error("Error submitting signal:", error);
return { error: "Failed to submit request. Please try again." }; return { error: "Failed to submit request. Please try again." };
@@ -119,17 +126,21 @@ function generateRichEmailHtml(payload: any, ticketId: string, aiAnalysis: strin
const cartRows = cartItems.map((item: any) => ` const cartRows = cartItems.map((item: any) => `
<tr> <tr>
<td style="padding: 16px 12px; border-bottom: 1px solid #E5E5EA;"> <td style="padding: 16px 12px; border-bottom: 1px solid #E5E5EA;">
<strong style="color: #1D1D1F; font-size: 14px;">${item.title}</strong><br/> <strong style="color: #1D1D1F; font-size: 14px;">${escapeHtml(item.title)}</strong><br/>
<span style="color: #86868B; font-size: 11px; font-family: monospace;">SKU: ${item.sku}</span> <span style="color: #86868B; font-size: 11px; font-family: monospace;">SKU: ${escapeHtml(item.sku)}</span>
</td> </td>
<td style="padding: 16px 12px; border-bottom: 1px solid #E5E5EA; text-align: center; font-family: monospace; font-weight: 600; color: #1D1D1F;">${item.quantity}</td> <td style="padding: 16px 12px; border-bottom: 1px solid #E5E5EA; text-align: center; font-family: monospace; font-weight: 600; color: #1D1D1F;">${escapeHtml(item.quantity)}</td>
</tr> </tr>
`).join(''); `).join('');
const fileLinks = files.map((fileUrl: string) => { const fileLinks = files
const isVideo = fileUrl.endsWith('.mp4') || fileUrl.endsWith('.mov'); // Only accept internal paths (start with a single "/"); ignore anything
return `<a href="${appUrl}${fileUrl}" style="display: inline-block; padding: 10px 16px; background: #0066CC; color: white; text-decoration: none; border-radius: 8px; margin: 4px 8px 4px 0; font-size: 13px; font-weight: 600; text-align: center;">View ${isVideo ? 'Video' : 'Image'}</a>`; // that could point off-site or break out of the href attribute.
}).join(''); .filter((fileUrl: string) => typeof fileUrl === 'string' && /^\/[^/]/.test(fileUrl))
.map((fileUrl: string) => {
const isVideo = fileUrl.endsWith('.mp4') || fileUrl.endsWith('.mov');
return `<a href="${escapeAttr(appUrl + fileUrl)}" style="display: inline-block; padding: 10px 16px; background: #0066CC; color: white; text-decoration: none; border-radius: 8px; margin: 4px 8px 4px 0; font-size: 13px; font-weight: 600; text-align: center;">View ${isVideo ? 'Video' : 'Image'}</a>`;
}).join('');
return ` return `
<div style="background-color: #F5F5F7; padding: 40px 20px; width: 100%; box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;"> <div style="background-color: #F5F5F7; padding: 40px 20px; width: 100%; box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
@@ -138,21 +149,21 @@ function generateRichEmailHtml(payload: any, ticketId: string, aiAnalysis: strin
<div style="padding: 40px 32px 32px 32px; text-align: center; border-bottom: 1px solid #E5E5EA;"> <div style="padding: 40px 32px 32px 32px; text-align: center; border-bottom: 1px solid #E5E5EA;">
<img src="${appUrl}/logoEmail.png" alt="FLUX SRL" style="height: 50px; width: auto; object-fit: contain; margin-bottom: 24px;" /> <img src="${appUrl}/logoEmail.png" alt="FLUX SRL" style="height: 50px; width: auto; object-fit: contain; margin-bottom: 24px;" />
<p style="color: #0066CC; font-size: 11px; letter-spacing: 2px; text-transform: uppercase; margin: 0; font-weight: 700;">Operations Command</p> <p style="color: #0066CC; font-size: 11px; letter-spacing: 2px; text-transform: uppercase; margin: 0; font-weight: 700;">Operations Command</p>
<h1 style="margin: 12px 0 16px 0; font-size: 26px; font-weight: 300; color: #1D1D1F; letter-spacing: -0.5px;">Incoming ${payload.type} Signal</h1> <h1 style="margin: 12px 0 16px 0; font-size: 26px; font-weight: 300; color: #1D1D1F; letter-spacing: -0.5px;">Incoming ${escapeHtml(payload.type)} Signal</h1>
<span style="display: inline-block; padding: 6px 16px; background-color: #F5F5F7; border-radius: 20px; font-family: monospace; color: #1D1D1F; font-size: 13px; font-weight: 600; border: 1px solid #E5E5EA;">Ticket: ${ticketId}</span> <span style="display: inline-block; padding: 6px 16px; background-color: #F5F5F7; border-radius: 20px; font-family: monospace; color: #1D1D1F; font-size: 13px; font-weight: 600; border: 1px solid #E5E5EA;">Ticket: ${escapeHtml(ticketId)}</span>
</div> </div>
<div style="padding: 32px; color: #1D1D1F;"> <div style="padding: 32px; color: #1D1D1F;">
<div style="background-color: #F5F5F7; padding: 24px; border-radius: 12px; margin-bottom: 32px; border: 1px solid #E5E5EA;"> <div style="background-color: #F5F5F7; padding: 24px; border-radius: 12px; margin-bottom: 32px; border: 1px solid #E5E5EA;">
<p style="margin: 0 0 8px 0; font-size: 15px;"><strong>${payload.clientName}</strong> · ${payload.clientCompany}</p> <p style="margin: 0 0 8px 0; font-size: 15px;"><strong>${escapeHtml(payload.clientName)}</strong> · ${escapeHtml(payload.clientCompany)}</p>
<p style="margin: 0 0 8px 0; font-size: 14px; color: #86868B;">Email: <a href="mailto:${payload.clientEmail}" style="color: #0066CC; text-decoration: none;">${payload.clientEmail}</a></p> <p style="margin: 0 0 8px 0; font-size: 14px; color: #86868B;">Email: <a href="${escapeAttr(safeMailto(payload.clientEmail))}" style="color: #0066CC; text-decoration: none;">${escapeHtml(payload.clientEmail)}</a></p>
<p style="margin: 0; font-size: 14px; color: #86868B;">Phone: ${payload.clientPhone || 'N/A'}</p> <p style="margin: 0; font-size: 14px; color: #86868B;">Phone: ${payload.clientPhone ? escapeHtml(payload.clientPhone) : 'N/A'}</p>
</div> </div>
${payload.message ? ` ${payload.message ? `
<div style="margin-bottom: 32px;"> <div style="margin-bottom: 32px;">
<h3 style="font-size: 12px; text-transform: uppercase; color: #86868B; letter-spacing: 1px; margin-bottom: 12px;">Client Notes</h3> <h3 style="font-size: 12px; text-transform: uppercase; color: #86868B; letter-spacing: 1px; margin-bottom: 12px;">Client Notes</h3>
<div style="padding: 20px; border-left: 4px solid #1D1D1F; background: #FAFAFA; border-radius: 0 8px 8px 0; font-size: 14px; line-height: 1.6;">${payload.message}</div> <div style="padding: 20px; border-left: 4px solid #1D1D1F; background: #FAFAFA; border-radius: 0 8px 8px 0; font-size: 14px; line-height: 1.6;">${escapeHtml(payload.message)}</div>
</div> </div>
` : ''} ` : ''}
+137 -17
View File
@@ -31,6 +31,16 @@ import { NextRequest, NextResponse } from "next/server";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { revalidateContent, type RevalidateScope } from "@/lib/revalidate"; import { revalidateContent, type RevalidateScope } from "@/lib/revalidate";
import { optimizeImage, isOptimizable } from "@/lib/imageOptimizer";
import { getAdminSession } from "@/lib/session";
// All asset operations are admin-only. The middleware (src/proxy.ts) does NOT
// cover /api, so each handler must verify the admin session itself.
async function requireAdmin(): Promise<NextResponse | null> {
const session = await getAdminSession();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
return null;
}
const SCOPE_ROOTS: Record<string, string> = { const SCOPE_ROOTS: Record<string, string> = {
applications: path.join(process.cwd(), "public", "applications"), applications: path.join(process.cwd(), "public", "applications"),
@@ -42,10 +52,12 @@ const SCOPE_ROOTS: Record<string, string> = {
footage: path.join(process.cwd(), "public", "footage", "main"), footage: path.join(process.cwd(), "public", "footage", "main"),
// 🔥 NUEVO: Site-wide brand assets (favicon, logo, OG image) // 🔥 NUEVO: Site-wide brand assets (favicon, logo, OG image)
branding: path.join(process.cwd(), "public", "branding"), branding: path.join(process.cwd(), "public", "branding"),
// 🔥 NUEVO: Team member portraits (flat folder, slug ignored)
team: path.join(process.cwd(), "public", "team"),
}; };
// Scopes that ignore the `slug` parameter and write directly under their root. // Scopes that ignore the `slug` parameter and write directly under their root.
const FLAT_SCOPES = new Set(["footage", "branding"]); const FLAT_SCOPES = new Set(["footage", "branding", "team"]);
const MEDIA_TYPES: Record<string, string[]> = { const MEDIA_TYPES: Record<string, string[]> = {
image: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg", ".avif"], image: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg", ".avif"],
@@ -103,6 +115,7 @@ function buildSafePath(scope: string, slug: string, subPath?: string): string |
function buildPublicUrl(scope: string, slug: string, rel: string): string { function buildPublicUrl(scope: string, slug: string, rel: string): string {
if (scope === "footage") return `/footage/main/${rel}`; if (scope === "footage") return `/footage/main/${rel}`;
if (scope === "branding") return `/branding/${rel}`; if (scope === "branding") return `/branding/${rel}`;
if (scope === "team") return `/team/${rel}`;
return `/${scope}/${slug}/${rel}`; return `/${scope}/${slug}/${rel}`;
} }
@@ -119,6 +132,7 @@ function buildBreadcrumbs(subPath: string) {
// GET — List files and folders // GET — List files and folders
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const unauth = await requireAdmin(); if (unauth) return unauth;
try { try {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const scope = searchParams.get("scope") || "applications"; const scope = searchParams.get("scope") || "applications";
@@ -186,7 +200,15 @@ export async function GET(request: NextRequest) {
} }
// POST — Upload a file // POST — Upload a file
//
// Optional query / form param `optimize=true` (or `optimize=1`) routes the
// upload through the sharp pipeline: auto-orient, cap at 2560px, encode to
// WebP, and save under a content-hashed filename. The same image always
// produces the same hash, so re-uploading is idempotent. Different content
// produces a different hash, so the browser cache invalidates instantly
// without any header trickery.
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const unauth = await requireAdmin(); if (unauth) return unauth;
try { try {
const formData = await request.formData(); const formData = await request.formData();
const scope = (formData.get("scope") as string) || "applications"; const scope = (formData.get("scope") as string) || "applications";
@@ -194,6 +216,12 @@ export async function POST(request: NextRequest) {
const subPath = formData.get("path") as string || ""; const subPath = formData.get("path") as string || "";
const file = formData.get("file") as File; const file = formData.get("file") as File;
// Two ways to opt into optimization: ?optimize=1 query or form field "optimize".
const optFlag =
formData.get("optimize") ??
new URL(request.url).searchParams.get("optimize");
const shouldOptimize = optFlag === "true" || optFlag === "1" || optFlag === "on";
if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 }); if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 });
if (!SCOPE_ROOTS[scope]) return NextResponse.json({ error: "Invalid scope" }, { status: 400 }); if (!SCOPE_ROOTS[scope]) return NextResponse.json({ error: "Invalid scope" }, { status: 400 });
if (!FLAT_SCOPES.has(scope) && !slug) return NextResponse.json({ error: "Missing slug" }, { status: 400 }); if (!FLAT_SCOPES.has(scope) && !slug) return NextResponse.json({ error: "Missing slug" }, { status: 400 });
@@ -211,13 +239,26 @@ export async function POST(request: NextRequest) {
fs.mkdirSync(dirPath, { recursive: true }); fs.mkdirSync(dirPath, { recursive: true });
const safeName = file.name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9._-]/g, ""); const inputBuffer: Buffer = Buffer.from(await file.arrayBuffer());
const filePath = path.join(dirPath, safeName);
// Optimization branch: replace filename with a content-hashed WebP one.
let saveName = file.name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9._-]/g, "");
let outputBuffer: Buffer | Uint8Array = inputBuffer;
let optimizedMeta: { width: number | null; height: number | null; bytes: number } | null = null;
if (shouldOptimize && isOptimizable(file.name)) {
const opt = await optimizeImage(inputBuffer, file.name);
saveName = opt.filename;
outputBuffer = opt.buffer;
optimizedMeta = { width: opt.width, height: opt.height, bytes: opt.bytes };
}
const filePath = path.join(dirPath, saveName);
const existed = fs.existsSync(filePath); const existed = fs.existsSync(filePath);
fs.writeFileSync(filePath, Buffer.from(await file.arrayBuffer())); fs.writeFileSync(filePath, outputBuffer);
const rel = subPath ? `${subPath}/${safeName}` : safeName; const rel = subPath ? `${subPath}/${saveName}` : saveName;
// 🔥 Invalida caché para que la imagen aparezca sin recompilar // 🔥 Invalida caché para que la imagen aparezca sin recompilar
revalidateContent({ scope: scope as RevalidateScope, slug }); revalidateContent({ scope: scope as RevalidateScope, slug });
@@ -225,12 +266,21 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
file: { file: {
name: safeName, name: saveName,
publicUrl: buildPublicUrl(scope, slug, rel), publicUrl: buildPublicUrl(scope, slug, rel),
path: rel, path: rel,
mediaType: getFileType(safeName), mediaType: getFileType(saveName),
size: getFileSize(file.size), size: getFileSize(outputBuffer.byteLength),
overwritten: existed, overwritten: existed,
optimized: optimizedMeta !== null,
...(optimizedMeta
? {
width: optimizedMeta.width,
height: optimizedMeta.height,
originalBytes: file.size,
savedBytes: file.size - optimizedMeta.bytes,
}
: {}),
} }
}); });
} catch (error) { } catch (error) {
@@ -241,6 +291,7 @@ export async function POST(request: NextRequest) {
// PUT — Create a new folder // PUT — Create a new folder
export async function PUT(request: NextRequest) { export async function PUT(request: NextRequest) {
const unauth = await requireAdmin(); if (unauth) return unauth;
try { try {
const body = await request.json(); const body = await request.json();
const { scope = "applications", slug = "", folderName, parentPath = "" } = body; const { scope = "applications", slug = "", folderName, parentPath = "" } = body;
@@ -271,29 +322,98 @@ export async function PUT(request: NextRequest) {
} }
} }
// DELETE — Remove a file // DELETE — Remove a file (or many in one call).
// Body shape:
// { scope, slug, filePath: "..." } single delete
// { scope, slug, filePaths: ["a", "b", ...] } bulk delete
export async function DELETE(request: NextRequest) { export async function DELETE(request: NextRequest) {
const unauth = await requireAdmin(); if (unauth) return unauth;
try { try {
const body = await request.json(); const body = await request.json();
const { scope = "applications", slug = "", filePath } = body; const { scope = "applications", slug = "", filePath, filePaths } = body;
if (!filePath) return NextResponse.json({ error: "Missing filePath" }, { status: 400 });
if (!SCOPE_ROOTS[scope]) return NextResponse.json({ error: "Invalid scope" }, { status: 400 }); if (!SCOPE_ROOTS[scope]) return NextResponse.json({ error: "Invalid scope" }, { status: 400 });
if (!FLAT_SCOPES.has(scope) && !slug) return NextResponse.json({ error: "Missing slug" }, { status: 400 }); if (!FLAT_SCOPES.has(scope) && !slug) return NextResponse.json({ error: "Missing slug" }, { status: 400 });
const targetPath = buildSafePath(scope, slug, filePath); const targets: string[] = Array.isArray(filePaths) ? filePaths : (filePath ? [filePath] : []);
if (!targetPath) return NextResponse.json({ error: "Invalid path" }, { status: 400 }); if (targets.length === 0) return NextResponse.json({ error: "Missing filePath(s)" }, { status: 400 });
if (!fs.existsSync(targetPath)) return NextResponse.json({ error: "File not found" }, { status: 404 }); const deleted: string[] = [];
if (fs.statSync(targetPath).isDirectory()) return NextResponse.json({ error: "Cannot delete folders via API" }, { status: 400 }); const failed: { path: string; reason: string }[] = [];
fs.unlinkSync(targetPath); for (const rel of targets) {
const targetPath = buildSafePath(scope, slug, rel);
if (!targetPath) {
failed.push({ path: rel, reason: "Invalid path" });
continue;
}
if (!fs.existsSync(targetPath)) {
failed.push({ path: rel, reason: "Not found" });
continue;
}
if (fs.statSync(targetPath).isDirectory()) {
failed.push({ path: rel, reason: "Refusing to delete folder via API" });
continue;
}
try {
fs.unlinkSync(targetPath);
deleted.push(rel);
} catch (err: any) {
failed.push({ path: rel, reason: err.message || "unlink failed" });
}
}
revalidateContent({ scope: scope as RevalidateScope, slug }); revalidateContent({ scope: scope as RevalidateScope, slug });
return NextResponse.json({ success: true, deleted: filePath }); return NextResponse.json({
success: deleted.length > 0,
deleted,
failed,
});
} catch (error) { } catch (error) {
console.error("Asset DELETE error:", error); console.error("Asset DELETE error:", error);
return NextResponse.json({ error: "Delete failed" }, { status: 500 }); return NextResponse.json({ error: "Delete failed" }, { status: 500 });
} }
}
// PATCH — Move or rename a file.
// Body shape: { scope, slug, fromPath, toPath }
// - rename in same bucket: fromPath="videos/a.mp4", toPath="videos/intro.mp4"
// - move between buckets: fromPath="videos/a.mp4", toPath="renders/a.mp4"
// - move to root: fromPath="videos/a.mp4", toPath="a.mp4"
// Cannot overwrite an existing file (returns 409). Sanitises target name
// the same way upload does, and creates intermediate folders if needed.
export async function PATCH(request: NextRequest) {
const unauth = await requireAdmin(); if (unauth) return unauth;
try {
const body = await request.json();
const { scope = "applications", slug = "", fromPath, toPath } = body;
if (!SCOPE_ROOTS[scope]) return NextResponse.json({ error: "Invalid scope" }, { status: 400 });
if (!FLAT_SCOPES.has(scope) && !slug) return NextResponse.json({ error: "Missing slug" }, { status: 400 });
if (!fromPath || !toPath) return NextResponse.json({ error: "Missing fromPath or toPath" }, { status: 400 });
const sourceAbs = buildSafePath(scope, slug, fromPath);
const destAbs = buildSafePath(scope, slug, toPath);
if (!sourceAbs || !destAbs) return NextResponse.json({ error: "Invalid path" }, { status: 400 });
if (!fs.existsSync(sourceAbs)) return NextResponse.json({ error: "Source file not found" }, { status: 404 });
if (fs.statSync(sourceAbs).isDirectory()) return NextResponse.json({ error: "Source is a folder" }, { status: 400 });
if (fs.existsSync(destAbs)) return NextResponse.json({ error: "A file with that name already exists at the destination" }, { status: 409 });
fs.mkdirSync(path.dirname(destAbs), { recursive: true });
fs.renameSync(sourceAbs, destAbs);
revalidateContent({ scope: scope as RevalidateScope, slug });
return NextResponse.json({
success: true,
from: fromPath,
to: toPath,
publicUrl: buildPublicUrl(scope, slug, toPath),
});
} catch (error: any) {
console.error("Asset PATCH error:", error);
return NextResponse.json({ error: error.message || "Move/rename failed" }, { status: 500 });
}
} }
+152
View File
@@ -0,0 +1,152 @@
// src/app/api/branding/favicon/route.ts
// ─────────────────────────────────────────────────────────────────────────────
// Brand master → multi-variant favicon generator.
//
// Editor uploads ONE square PNG/JPG (recommended ≥ 512×512) and the server
// produces every size the modern web actually needs:
//
// /branding/favicon-16.png 16×16 — browser tab favicon (legacy)
// /branding/favicon-32.png 32×32 — browser tab favicon (HiDPI)
// /branding/favicon-48.png 48×48 — Windows site icon
// /branding/favicon-180.png 180×180 — Apple touch icon (iPhone)
// /branding/favicon-192.png 192×192 — Android Chrome / PWA
// /branding/favicon-512.png 512×512 — PWA splash screen
// /branding/favicon-master.png original — kept for re-generation later
//
// Plus the manifest.webmanifest is regenerated to point at these.
//
// Idempotent: re-uploading just overwrites the same filenames so the
// browser/CDN cache stays consistent (we use cache-busting via the
// SiteSetting's updatedAt timestamp surfaced in the layout).
// ─────────────────────────────────────────────────────────────────────────────
import { NextRequest, NextResponse } from "next/server";
import sharp from "sharp";
import fs from "fs";
import path from "path";
import { revalidateContent } from "@/lib/revalidate";
import { getAdminSession } from "@/lib/session";
interface VariantSpec {
size: number;
filename: string;
description: string;
}
const VARIANTS: VariantSpec[] = [
{ size: 16, filename: "favicon-16.png", description: "Browser tab (legacy)" },
{ size: 32, filename: "favicon-32.png", description: "Browser tab (HiDPI)" },
{ size: 48, filename: "favicon-48.png", description: "Windows site icon" },
{ size: 180, filename: "favicon-180.png", description: "Apple touch icon" },
{ size: 192, filename: "favicon-192.png", description: "Android / PWA" },
{ size: 512, filename: "favicon-512.png", description: "PWA splash screen" },
];
const BRANDING_DIR = path.join(process.cwd(), "public", "branding");
const MASTER_FILENAME = "favicon-master.png";
const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB cap
const ALLOWED_EXT = new Set([".png", ".jpg", ".jpeg", ".webp"]);
export async function POST(request: NextRequest) {
// Admin-only: the middleware does not cover /api, so guard here.
const session = await getAdminSession();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
try {
const formData = await request.formData();
const file = formData.get("file") as File | null;
if (!file) {
return NextResponse.json({ error: "Missing file" }, { status: 400 });
}
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json({ error: `File exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit` }, { status: 400 });
}
const ext = path.extname(file.name).toLowerCase();
if (!ALLOWED_EXT.has(ext)) {
return NextResponse.json(
{ error: `File type "${ext}" not allowed. Use PNG, JPG or WebP.` },
{ status: 400 }
);
}
fs.mkdirSync(BRANDING_DIR, { recursive: true });
// Read the master once.
const inputBuffer = Buffer.from(await file.arrayBuffer());
// Validate it's actually a decodable image and roughly square.
const meta = await sharp(inputBuffer).metadata();
if (!meta.width || !meta.height) {
return NextResponse.json({ error: "Could not read image dimensions." }, { status: 400 });
}
const aspectDelta = Math.abs(meta.width - meta.height) / Math.max(meta.width, meta.height);
const isSquareEnough = aspectDelta < 0.1;
if (meta.width < 192 || meta.height < 192) {
return NextResponse.json(
{ error: `Image is too small (${meta.width}×${meta.height}). Use at least 512×512.` },
{ status: 400 }
);
}
const warnings: string[] = [];
if (!isSquareEnough) {
warnings.push(`Source image is not square (${meta.width}×${meta.height}). It will be center-cropped to a square.`);
}
if (meta.width < 512 || meta.height < 512) {
warnings.push("For best quality on retina displays, upload at least 512×512.");
}
// Save the master (kept for "regenerate" workflows later).
const masterPath = path.join(BRANDING_DIR, MASTER_FILENAME);
await sharp(inputBuffer)
.rotate() // honour EXIF
.resize({ width: 512, height: 512, fit: "cover", position: "center" })
.png({ compressionLevel: 9 })
.toFile(masterPath);
// Generate every variant in parallel.
const generated: { url: string; size: number; description: string; bytes: number }[] = [];
await Promise.all(
VARIANTS.map(async (v) => {
const target = path.join(BRANDING_DIR, v.filename);
const out = await sharp(inputBuffer)
.rotate()
.resize({ width: v.size, height: v.size, fit: "cover", position: "center" })
.png({ compressionLevel: 9 })
.toBuffer();
fs.writeFileSync(target, out);
generated.push({
url: `/branding/${v.filename}`,
size: v.size,
description: v.description,
bytes: out.byteLength,
});
})
);
// The classic favicon.ico — a 32×32 PNG inside an ICO container would
// be ideal, but PNG-as-ICO is widely supported by modern browsers when
// referenced from a <link rel="icon"> tag. Sharp doesn't ship an ICO
// encoder by default; we publish favicon-32.png as the canonical
// /favicon.ico via the layout's icon list (browsers fall back gracefully).
revalidateContent({ scope: "branding" });
revalidateContent({ scope: "settings" });
return NextResponse.json({
success: true,
master: `/branding/${MASTER_FILENAME}`,
variants: generated,
warnings,
});
} catch (error: any) {
console.error("[favicon] generation failed:", error);
return NextResponse.json(
{ error: error.message || "Favicon generation failed." },
{ status: 500 }
);
}
}
+433 -50
View File
@@ -1,9 +1,12 @@
import { openai } from '@ai-sdk/openai'; import { openai } from '@ai-sdk/openai';
import { streamText, UIMessage, convertToModelMessages, tool } from 'ai'; import { streamText, stepCountIs, UIMessage, convertToModelMessages, tool } from 'ai';
import { z } from 'zod'; import { z } from 'zod';
import { createHash } from 'crypto';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { checkChatRateLimit, getClientIp } from '@/lib/rateLimit';
import { log } from '@/lib/logger';
export const maxDuration = 30; export const maxDuration = 60;
// ─── PHYSICS CONSTANTS (NOT from DB — these are engineering benchmarks) ────── // ─── PHYSICS CONSTANTS (NOT from DB — these are engineering benchmarks) ──────
// These stay hardcoded because they are physical/scientific constants, // These stay hardcoded because they are physical/scientific constants,
@@ -36,22 +39,43 @@ const COMPARISON_DATA: Record<string, { rf: number; traditional: number; unit: s
// ─── DYNAMIC SYSTEM PROMPT BUILDER ────────────────────────────── // ─── DYNAMIC SYSTEM PROMPT BUILDER ──────────────────────────────
// Injects real-time database context so the AI knows what exists // Injects real-time database context so the AI knows what exists
// Cache the built prompt briefly so we don't run 4 DB queries on every single
// chat message. CMS changes appear within the TTL. Only healthy builds are
// cached, so a transient DB outage retries on the next message.
let _promptCache: { value: string; at: number } | null = null;
const SYSTEM_PROMPT_TTL_MS = 60_000;
async function buildSystemPrompt(): Promise<string> { async function buildSystemPrompt(): Promise<string> {
// Query real data from Prisma if (_promptCache && Date.now() - _promptCache.at < SYSTEM_PROMPT_TTL_MS) {
const [activeApps, installationCount, eventCount, partsCount] = await Promise.all([ return _promptCache.value;
prisma.application.findMany({ }
where: { isActive: true },
select: { slug: true, title: true, shortDescription: true, category: true },
orderBy: { title: 'asc' },
}),
prisma.globalNode.count({ where: { nodeType: 'installation', isActive: true } }),
prisma.globalNode.count({ where: { nodeType: 'event', isActive: true } }),
prisma.sparePart.count({ where: { isActive: true } }),
]);
const appList = activeApps.map((a: any) => ` - ${a.title} (slug: "${a.slug}", category: ${a.category})`).join('\n'); // Live DB context. If Postgres is unreachable, fall back to safe defaults so
// the assistant still answers (degraded) instead of 500-ing the whole chat.
let activeApps: Array<{ slug: string; title: string; shortDescription: string; category: string }> = [];
let installationCount = 0, eventCount = 0, partsCount = 0;
let dbOk = true;
try {
[activeApps, installationCount, eventCount, partsCount] = await Promise.all([
prisma.application.findMany({
where: { isActive: true },
select: { slug: true, title: true, shortDescription: true, category: true },
orderBy: { title: 'asc' },
}),
prisma.globalNode.count({ where: { nodeType: 'installation', isActive: true } }),
prisma.globalNode.count({ where: { nodeType: 'event', isActive: true } }),
prisma.sparePart.count({ where: { isActive: true } }),
]);
} catch (e) {
dbOk = false;
log.warn('chat.system_prompt_db_unavailable', { err: String(e) });
}
return `You are "FluxAI", the intelligent engineering advisor and sales specialist for FLUX Srl — a world leader in solid-state Radio Frequency (RF), Microwave, and Infrared industrial equipment. Founded by Patrizio Grando with 40+ years of legacy. Headquarters: Romano d'Ezzelino, Vicenza, Italy. const appList = activeApps.length
? activeApps.map((a) => ` - ${a.title} (slug: "${a.slug}", category: ${a.category})`).join('\n')
: ' (live catalog temporarily unavailable — describe FLUX applications from general RF knowledge)';
const prompt = `You are "FluxAI", the intelligent engineering advisor and sales specialist for FLUX Srl — a world leader in solid-state Radio Frequency (RF), Microwave, and Infrared industrial equipment. Founded by Patrizio Grando with 40+ years of legacy. Headquarters: Romano d'Ezzelino, Vicenza, Italy.
PERSONALITY: PERSONALITY:
- Senior RF engineer who also understands business ROI. - Senior RF engineer who also understands business ROI.
@@ -88,45 +112,62 @@ Example of a perfect autonomous flow:
6. You output your final text referencing real data, the case study card, and gently offer a consultation. 6. You output your final text referencing real data, the case study card, and gently offer a consultation.
═══════════════════════════════════════════ ═══════════════════════════════════════════
SALES METHODOLOGY — SPIN FRAMEWORK: SALES METHODOLOGY — FUNNEL-AWARE SPIN:
═══════════════════════════════════════════ ═══════════════════════════════════════════
Before deploying tools, qualify the prospect through natural conversation:
S (Situación): What's their current process? What method? What volume? STAGE 1 — QUALIFY (S+P from SPIN):
P (Problema): What's not working? Energy costs? Quality issues? Speed? Trigger: User mentions an industry or problem WITHOUT specifics.
I (Implicación): What does the problem cost them? Rejected batches? Downtime? Action: Ask 1-2 qualifying questions. DO NOT fire tools yet.
N (Necesidad): Confirm the need before recommending. Example: "Estoy en textiles" → "What specific process — post-dye drying, finishing, moisture leveling? And what method do you use currently?"
Example: "I need to reduce costs" → "Which industry and production process? What throughput per hour?"
STAGE 2 — RECOMMEND + EDUCATE:
Trigger: User provides industry + process OR industry + problem.
Action: Call 'recommend_application' first to match their needs to FLUX products. Then chain 'rf_technology_explainer' or 'get_application_knowledge' for the top match.
Example: User says "I dry textiles after dyeing, about 800 kg/h" → recommend_application → navigate to the recommended app page → get_application_knowledge.
STAGE 3 — QUANTIFY + PROVE:
Trigger: User understands the application and wants numbers.
Action: Chain 'energy_savings_calculator' → 'search_installations' → 'show_case_study' with the most relevant real installation.
STAGE 4 — SPECIFY + CONVERT:
Trigger: User asks about equipment, pricing, or next steps.
Action: 'show_equipment_specs' → 'schedule_consultation'. This is the PRIMARY goal.
RULES: RULES:
- If the user mentions an industry WITHOUT specifics → ask 1-2 qualifying questions BEFORE firing tools. - Progress through stages naturally. Do not skip Stage 1 unless the user provides enough context.
Example: "Estoy en textiles" → "What specific process are you evaluating — post-dye drying, finishing, moisture leveling? And what method do you currently use?" - If the user provides DETAILED context (industry + process + volume + problem), jump to Stage 2 or 3.
- If the user provides DETAILED context (industry + process + volume OR problem) → proceed directly to tools. - EXCEPTION: If the user explicitly asks for something specific ("show me case studies", "calculate savings for 500 kg/h"), deliver it immediately regardless of stage.
- Never fire more than 2 tools in a single autonomous sequence without including meaningful analysis text. - Never fire more than 3 tools in a single autonomous sequence without including analysis text between them.
- EXCEPTION: If the user explicitly asks for something specific ("show me case studies", "calculate savings for 500 kg/h"), deliver it immediately. - After EVERY tool result, include a brief human-readable insight before the next tool or suggestion.
IDEAL CONVERSION FLOW:
Qualify → Educate (explainer/comparison) → Quantify (calculator) → Prove (case study) → Recommend (equipment specs) → Convert (consultation)
═══════════════════════════════════════════ ═══════════════════════════════════════════
TOOL USAGE RULES: TOOL USAGE RULES:
═══════════════════════════════════════════ ═══════════════════════════════════════════
1. SEARCH INSTALLATIONS: Use 'search_installations' to find real installations from our database. This is a DATA tool — you receive the results and reason about them before responding. 1. RECOMMEND APPLICATION (NEW — Stage 2): Use 'recommend_application' when the user describes their industry or problem and you need to identify which FLUX product fits. This is your FIRST tool when entering Stage 2. It returns ranked matches from the database.
2. SHOW CASE STUDY: Use 'show_case_study' to display a rich case study card for a specific installation. Requires a nodeId (get it from search_installations first) or an application slug for auto-match. 2. SEARCH INSTALLATIONS: Use 'search_installations' to find real installations. DATA tool — read results and reference them in your response.
3. SAVINGS/ROI: Use 'energy_savings_calculator' when discussing costs, energy, ROI. If volume is missing, assume 500 kg/h and 16h/day. 3. SHOW CASE STUDY: Use 'show_case_study' for a rich case study card. Get nodeId from search_installations first.
4. NAVIGATION: Use 'navigate_to_section' to move the user around the site. 4. SAVINGS/ROI: Use 'energy_savings_calculator' for cost/energy/ROI discussions. Default: 500 kg/h, 16h/day if volume unknown.
5. COMPARISONS: Use 'process_comparison_table' for RF vs conventional tech. 5. NAVIGATION: Use 'navigate_to_section'. Mode A "section" for homepage scroll (valid: "technology", "applications-dashboard", "applications-deep", "global", "our-story", "legacy"). Mode B "url" for cross-page (url="/applications/{slug}", url="/news", url="/heritage", url="/parts").
6. EXPLAIN TECH: Use 'rf_technology_explainer' for physics/mechanism questions. 6. COMPARISONS: Use 'process_comparison_table' for RF vs conventional tech.
7. APPLICATION KNOWLEDGE: Use 'get_application_knowledge' to retrieve deep technical theory, advantages, and datasheets from our knowledge base. 7. EXPLAIN TECH: Use 'rf_technology_explainer' for physics/mechanism questions.
8. EQUIPMENT SPECS: Use 'show_equipment_specs' to display real machine specifications from an actual installation. 8. APPLICATION KNOWLEDGE: Use 'get_application_knowledge' for deep technical theory after identifying the right application.
9. CONSULTATION (PRIMARY GOAL): Use 'schedule_consultation' when the user shows buying intent. 9. EQUIPMENT SPECS: Use 'show_equipment_specs' for real machine specifications.
10. CONSULTATION (PRIMARY GOAL): Use 'schedule_consultation' when the user shows buying intent.
PROACTIVE NEXT STEPS: PROACTIVE NEXT STEPS (always suggest the next logical action):
After showing results, gently suggest the logical next action: recommend → "Let me show you the details of this application..." → navigate to app page or get_application_knowledge
savings → case study ("We have real installations proving these numbers...") knowledge/explainer → "Want to see what this means for your energy costs?" → energy_savings_calculator
case study → equipment specs ("Want to see the technical specs of the system used?") savings → "We have real installations proving these numbers..." → search_installations + show_case_study
equipment → consultation ("Shall I arrange a conversation with our engineering team?") case study → "Want to see the technical specs of the system used?" → show_equipment_specs
equipment → "Shall I arrange a conversation with our engineering team?" → schedule_consultation
comparison → "Let me quantify the difference for your specific operation..." → energy_savings_calculator
LANGUAGE: Respond in the exact same language the user writes in.`; LANGUAGE: Respond in the exact same language the user writes in.`;
// Only cache a healthy build so a transient DB outage retries next message.
if (dbOk) _promptCache = { value: prompt, at: Date.now() };
return prompt;
} }
// ─── HELPER: Parse JSON safely ────────────────────────────────── // ─── HELPER: Parse JSON safely ──────────────────────────────────
@@ -147,19 +188,132 @@ function industryFromSlug(slug: string): string {
return 'other'; return 'other';
} }
// Lightweight industry sniffer used for AiConversation.industryLabel telemetry.
// Order matters — more specific terms first.
function detectIndustryFromText(text: string): string | null {
const t = text.toLowerCase();
if (/text|fabric|dye|stenter|finishing|yarn/.test(t)) return 'textile';
if (/food|defrost|bak|pasteuriz|tempering|cook/.test(t)) return 'food';
if (/rubber|latex|vulcaniz|foam|tyre|tire/.test(t)) return 'rubber';
if (/pharma|cannabis|drug|api\b|lab/.test(t)) return 'pharma';
if (/wood|timber|lumber|kiln/.test(t)) return 'wood';
if (/ceramic|kiln|clay/.test(t)) return 'other';
return null;
}
// ─── ROUTE HANDLER ────────────────────────────────────────────── // ─── ROUTE HANDLER ──────────────────────────────────────────────
export async function POST(req: Request) { export async function POST(req: Request) {
const { messages, context }: { // ─── Rate limit (per-IP token bucket, 30 req/min) ──────────────
const rate = await checkChatRateLimit(req);
if (!rate.ok) {
return new Response(
JSON.stringify({
error: "Too many requests. Please slow down.",
retryAfterSec: rate.retryAfterSec,
}),
{
status: 429,
headers: {
"Content-Type": "application/json",
"Retry-After": String(rate.retryAfterSec),
"X-RateLimit-Remaining": String(rate.remaining),
},
}
);
}
// ─── Fail fast if the AI provider isn't configured ─────────────
// Without this, a missing/invalid key surfaces mid-stream after headers
// are already sent, producing a confusing broken response.
if (!process.env.OPENAI_API_KEY) {
log.error("chat.openai_key_missing", new Error("OPENAI_API_KEY is not set"));
return new Response(
JSON.stringify({ error: "The AI assistant is temporarily unavailable. Please try again later." }),
{ status: 503, headers: { "Content-Type": "application/json" } },
);
}
const {
messages,
context,
sessionId,
locale,
pageUrl,
}: {
messages: UIMessage[]; messages: UIMessage[];
context?: { section?: string; activeTab?: string }; context?: { section?: string; activeTab?: string };
sessionId?: string;
locale?: string;
pageUrl?: string | null;
} = await req.json(); } = await req.json();
const contextNote = context?.section const contextNote = context?.section
? `\n\n[CONTEXT: User is currently viewing the "${context.section}" section${context.activeTab ? `, tab: "${context.activeTab}"` : ''}.` ? `\n\n[CONTEXT: User is currently viewing the "${context.section}" section${context.activeTab ? `, tab: "${context.activeTab}"` : ''}.`
: ''; : '';
// Build system prompt with live database context // ─── FluxAI telemetry: upsert conversation + record user message ────────
// Wrapped in try/catch — telemetry never blocks the chat response.
let conversationId: string | null = null;
const startedAt = Date.now();
if (sessionId) {
try {
const ipHash = createHash('sha256')
.update(`${getClientIp(req)}|${process.env.SESSION_SECRET ?? ''}`)
.digest('hex')
.slice(0, 32);
const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user');
const lastUserText = lastUserMsg
? (lastUserMsg as unknown as { parts?: { type: string; text?: string }[] }).parts
?.filter((p) => p.type === 'text')
.map((p) => p.text || '')
.join(' ')
.slice(0, 8000)
: '';
const detectedIndustry = lastUserText ? detectIndustryFromText(lastUserText) : null;
const conv = await prisma.aiConversation.upsert({
where: { sessionId },
update: {
lastMessageAt: new Date(),
messageCount: { increment: 1 },
...(detectedIndustry ? { industryLabel: detectedIndustry } : {}),
// Once we have an industry, advance to QUALIFY.
...(detectedIndustry ? { funnelStage: 'QUALIFY' } : {}),
},
create: {
sessionId,
visitorIp: ipHash,
userAgent: req.headers.get('user-agent')?.slice(0, 240) ?? null,
locale: locale ?? null,
pageUrl: pageUrl ?? null,
industryLabel: detectedIndustry,
funnelStage: detectedIndustry ? 'QUALIFY' : 'DISCOVERY',
messageCount: 1,
},
});
conversationId = conv.id;
if (lastUserText) {
await prisma.aiEvent.create({
data: {
conversationId: conv.id,
type: 'user_msg',
payloadJson: JSON.stringify({ text: lastUserText }).slice(0, 8000),
},
});
}
} catch (e) {
log.warn('chat.telemetry_upsert_failed', { err: String(e) });
}
}
// Build system prompt with live database context.
// The static section (personality, knowledge, rules) is identical across
// requests, so we tag it with `providerOptions.openai.promptCacheKey` —
// a no-op today, but ready for prompt caching when the SDK lands it.
const systemPrompt = await buildSystemPrompt(); const systemPrompt = await buildSystemPrompt();
const coreMessages = await convertToModelMessages(messages); const coreMessages = await convertToModelMessages(messages);
@@ -168,13 +322,216 @@ export async function POST(req: Request) {
model: openai('gpt-4o'), model: openai('gpt-4o'),
system: systemPrompt + contextNote, system: systemPrompt + contextNote,
messages: coreMessages, messages: coreMessages,
// maxSteps has been temporarily removed to ensure compatibility with the installed AI SDK version providerOptions: { openai: { promptCacheKey: 'fluxai-v1' } },
// Surface streaming/provider errors (OpenAI 429/500, bad key) in the logs
// and, when possible, persist them to the conversation timeline.
onError: ({ error }) => {
log.error("chat.stream_error", error, { conversationId: conversationId ?? undefined });
if (conversationId) {
prisma.aiEvent.create({
data: {
conversationId,
type: "error",
payloadJson: JSON.stringify({ message: error instanceof Error ? error.message : String(error) }).slice(0, 2000),
},
}).catch(() => {});
}
},
onFinish: async ({ usage, toolCalls, toolResults }) => {
if (!conversationId) return;
try {
const latencyMs = Date.now() - startedAt;
// 1. Persist the assistant message (compact)
await prisma.aiEvent.create({
data: {
conversationId,
type: 'ai_msg',
payloadJson: JSON.stringify({
toolCalls: toolCalls?.map((tc) => ({ name: tc.toolName })) ?? [],
}).slice(0, 8000),
latencyMs,
tokensIn:
(usage as unknown as { inputTokens?: number; promptTokens?: number })?.inputTokens ??
(usage as unknown as { promptTokens?: number })?.promptTokens ??
null,
tokensOut:
(usage as unknown as { outputTokens?: number; completionTokens?: number })?.outputTokens ??
(usage as unknown as { completionTokens?: number })?.completionTokens ??
null,
cachedTokens:
(usage as unknown as { cachedTokens?: number })?.cachedTokens ?? null,
},
});
// 2. Persist each tool call/result
const tcArr = toolCalls ?? [];
const trArr = toolResults ?? [];
let advanceStage: string | null = null;
let savings: number | null = null;
let volume: string | null = null;
for (const tc of tcArr) {
await prisma.aiEvent.create({
data: {
conversationId,
type: 'tool_call',
toolName: tc.toolName,
payloadJson: JSON.stringify(
(tc as unknown as { args?: unknown; input?: unknown }).args ??
(tc as unknown as { input?: unknown }).input ??
{},
).slice(0, 8000),
},
});
if (tc.toolName === 'energy_savings_calculator') advanceStage = 'RECOMMEND';
if (tc.toolName === 'schedule_consultation') {
advanceStage = 'HANDOFF';
const args = ((tc as unknown as { args?: unknown; input?: unknown }).args ??
(tc as unknown as { input?: unknown }).input ??
{}) as {
estimatedSavingsPercent?: number | null;
productionVolume?: string | null;
};
if (typeof args.estimatedSavingsPercent === 'number') savings = args.estimatedSavingsPercent;
if (typeof args.productionVolume === 'string') volume = args.productionVolume;
}
}
for (const tr of trArr) {
await prisma.aiEvent.create({
data: {
conversationId,
type: 'tool_result',
toolName: (tr as unknown as { toolName?: string }).toolName ?? null,
payloadJson: JSON.stringify((tr as unknown as { result?: unknown }).result ?? {}).slice(0, 8000),
},
});
}
// 3. Update conversation funnel + counters
await prisma.aiConversation.update({
where: { id: conversationId },
data: {
toolCallCount: { increment: tcArr.length },
...(advanceStage ? { funnelStage: advanceStage } : {}),
...(savings != null ? { estimatedSavingsPercent: savings } : {}),
...(volume ? { productionVolume: volume } : {}),
},
});
} catch (e) {
log.warn('chat.telemetry_finish_failed', { err: String(e) });
}
},
// 🔥 RESTORED: AI SDK 6 multi-step autonomy. The agent can now chain
// search → calculator → case-study → consultation in a single turn,
// exactly as the SPIN methodology in the system prompt was designed for.
// Cap at 5 steps to bound LLM cost and latency.
stopWhen: stepCountIs(5),
tools: { tools: {
// ══════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════
// DATA TOOLS (have execute, return data for AI to reason about) // DATA TOOLS (have execute, return data for AI to reason about)
// ══════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════
// ── TOOL 0: Recommend Application (DATA — smart product matching) ──
recommend_application: tool({
description: `Analyze the user's needs and recommend the best FLUX application(s) from the database. Use this FIRST when a prospect describes their industry, problem, or process without specifying a particular FLUX product. Returns ranked matches with confidence scores and reasoning. After getting results, chain into 'navigate_to_section' (url) to show them the application page, or 'get_application_knowledge' for deep technical detail.`,
inputSchema: z.object({
industryKeywords: z.array(z.string())
.describe('Keywords about their industry, e.g. ["textile", "fabric", "drying", "moisture"]'),
problemDescription: z.string()
.describe('What the user is trying to solve, e.g. "too much moisture after dyeing, high energy costs"'),
processType: z.string().optional()
.describe('Specific process if mentioned, e.g. "post-dye drying", "defrosting meat blocks"'),
currentMethod: z.string().optional()
.describe('Their current equipment/method if mentioned, e.g. "stenter", "steam autoclave"'),
}),
execute: async ({ industryKeywords, problemDescription, processType, currentMethod }) => {
const apps = await prisma.application.findMany({
where: { isActive: true },
select: {
slug: true,
title: true,
subtitle: true,
category: true,
shortDescription: true,
heroDescription: true,
dashboardMetricsJson: true,
},
});
// Score each application against the user's needs
const scored = apps.map((app: any) => {
const searchText = `${app.title} ${app.subtitle || ''} ${app.shortDescription} ${app.category} ${(app.heroDescription || '').slice(0, 800)}`.toLowerCase();
let score = 0;
const matchedKeywords: string[] = [];
for (const kw of industryKeywords) {
if (searchText.includes(kw.toLowerCase())) {
score += 10;
matchedKeywords.push(kw);
}
}
// Check problem description words
const problemWords = problemDescription.toLowerCase().split(/\s+/).filter((w: string) => w.length > 3);
for (const pw of problemWords) {
if (searchText.includes(pw)) score += 3;
}
// Bonus for process type match
if (processType && searchText.includes(processType.toLowerCase())) score += 15;
// Bonus for current method match (they're looking to replace it)
if (currentMethod && searchText.includes(currentMethod.toLowerCase())) score += 8;
return {
slug: app.slug,
title: app.title,
subtitle: app.subtitle,
category: app.category,
shortDescription: app.shortDescription,
score,
matchedKeywords,
metrics: safeParseJson(app.dashboardMetricsJson, []),
};
});
const ranked = scored
.filter((a: any) => a.score > 0)
.sort((a: any, b: any) => b.score - a.score)
.slice(0, 3);
if (ranked.length === 0) {
return {
found: 0,
message: 'No direct match found. Ask the user for more details about their specific process and materials.',
allApplications: apps.map((a: any) => ({ slug: a.slug, title: a.title, category: a.category })),
};
}
return {
found: ranked.length,
recommendations: ranked.map((r: any, idx: number) => ({
rank: idx + 1,
slug: r.slug,
title: r.title,
subtitle: r.subtitle,
category: r.category,
shortDescription: r.shortDescription,
matchedKeywords: r.matchedKeywords,
confidenceScore: Math.min(100, r.score),
topMetrics: r.metrics.slice(0, 3),
})),
userContext: {
industryKeywords,
problemDescription,
processType: processType || null,
currentMethod: currentMethod || null,
},
};
},
}),
// ── TOOL 1: Search Installations (DATA — queries Prisma) ── // ── TOOL 1: Search Installations (DATA — queries Prisma) ──
search_installations: tool({ search_installations: tool({
description: `Search the FLUX global installation database for real case studies and projects. Returns summaries for you to analyze and reference in your response. Use when you need to find relevant installations to back up your claims, or when the user asks about references, case studies, or proven results. You can then call 'show_case_study' with a specific nodeId to display the full card to the user.`, description: `Search the FLUX global installation database for real case studies and projects. Returns summaries for you to analyze and reference in your response. Use when you need to find relevant installations to back up your claims, or when the user asks about references, case studies, or proven results. You can then call 'show_case_study' with a specific nodeId to display the full card to the user.`,
@@ -435,13 +792,39 @@ export async function POST(req: Request) {
}, },
}), }),
// ── TOOL 6: Navigate to Section (client-side — NO execute) ── // ── TOOL 6: Navigate (client-side — NO execute) ──────────────
// Handles BOTH same-page scrolling (section) and cross-page
// routing (url). The client inspects which field is set.
navigate_to_section: tool({ navigate_to_section: tool({
description: `Maps the user to a specific section of the FLUX website. Use when the user says "show me", "take me to", "where is", or asks about a specific page section. Available sections: "hero", "applications-dashboard", "applications-deep", "global" (globe), "timeline", "heritage", "news", "parts-catalog", "contact".`, description: `Navigate the user to any part of the FLUX website.
TWO MODES:
A) SAME-PAGE SCROLL — set "section" to scroll to a homepage element by its DOM id:
"technology" (hero/intro), "applications-dashboard", "applications-deep", "global" (interactive globe), "our-story" (timeline), "legacy" (Patrizio legacy)
ONLY use these exact IDs. They only work when the user is on the homepage.
B) CROSS-PAGE NAVIGATION — set "url" to a route path (WITHOUT locale prefix). The client adds the locale automatically:
"/news" — news hub listing
"/news/{slug}" — specific article (use a real slug from context)
"/heritage" — company heritage deep-dive
"/parts" — spare parts catalog (B2B portal)
"/applications/{slug}" — application detail page (use real slug from the database list above)
RULES:
- ALWAYS prefer mode B for news, heritage, parts, and application detail pages.
- Only use mode A for scrolling within the homepage.
- When using mode B, use application slugs from the database list in this prompt.
- "show me textile drying" → url="/applications/textile-drying"
- "take me to the news" → url="/news"
- "show me the heritage" → url="/heritage"
- "show me the spare parts" → url="/parts"
- "show me the globe" → section="global"
- "go to the top" → section="technology"`,
inputSchema: z.object({ inputSchema: z.object({
section: z.string().describe('Target section ID'), section: z.string().optional().describe('Homepage element ID for same-page scroll'),
url: z.string().optional().describe('Route path for cross-page navigation (e.g. "/applications/textile-drying", "/news", "/heritage"). No locale prefix.'),
subAction: z.enum(['none', 'activate-tab', 'highlight-node']).default('none'), subAction: z.enum(['none', 'activate-tab', 'highlight-node']).default('none'),
tabId: z.string().optional().describe('Application slug to activate'), tabId: z.string().optional().describe('Application slug to activate on dashboard tab'),
nodeId: z.string().optional().describe('Globe node ID to highlight'), nodeId: z.string().optional().describe('Globe node ID to highlight'),
}), }),
}), }),
+154 -46
View File
@@ -1,12 +1,50 @@
// /src/app/api/consultation/route.ts // /src/app/api/consultation/route.ts
// Public API endpoint for ConsultationScheduler OperationsSignal // Public API endpoint for ConsultationScheduler -> OperationsSignal.
// Uses SMTP mailer (no Resend dependency) // Hardened (v2):
// - Zod schema validates every field, rejects malformed emails / oversize input.
// - Double-submit CSRF check rejects cross-site form posts.
// - escapeHtml() everywhere in the email template (no raw interpolation).
// - Structured logging (no silent console.error).
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { sendEmail } from "@/lib/mailer"; import { sendEmail } from "@/lib/mailer";
import { escapeHtml, escapeAttr, safeMailto } from "@/lib/escapeHtml";
import { log } from "@/lib/logger";
import { verifyCsrfToken, CSRF_COOKIE_NAME, CSRF_HEADER_NAME } from "@/lib/csrf";
const ConsultationSchema = z.object({
contact: z.object({
name: z.string().min(1).max(120),
email: z.string().email().max(254),
company: z.string().min(1).max(160),
phone: z.string().max(40).optional().nullable(),
message: z.string().max(4000).optional().nullable(),
preferredContact: z.enum(["email", "phone", "whatsapp"]).optional().nullable(),
timeframe: z.string().max(80).optional().nullable(),
}),
aiContext: z
.object({
industryLabel: z.string().max(120).optional().nullable(),
process: z.string().max(120).optional().nullable(),
estimatedSavingsPercent: z.number().min(0).max(100).optional().nullable(),
productionVolume: z.string().max(120).optional().nullable(),
conversationInsights: z.array(z.string().max(500)).max(20).optional().nullable(),
suggestedTopics: z.array(z.string().max(160)).max(20).optional().nullable(),
sessionId: z.string().uuid().optional().nullable(),
})
.partial()
.optional(),
meta: z
.object({
source: z.string().max(80).optional().nullable(),
url: z.string().url().max(500).optional().nullable(),
})
.partial()
.optional(),
});
// Helper: sequential ticket ID
async function generateConsultationTicketId(): Promise<string> { async function generateConsultationTicketId(): Promise<string> {
const year = new Date().getFullYear(); const year = new Date().getFullYear();
const count = await prisma.operationsSignal.count({ const count = await prisma.operationsSignal.count({
@@ -16,34 +54,54 @@ async function generateConsultationTicketId(): Promise<string> {
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
// ── 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 { try {
const body = await request.json(); const body = await request.json();
const { contact, aiContext, meta } = body; 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 },
);
}
if (!contact?.name || !contact?.email || !contact?.company) { const { contact, aiContext, meta } = parsed;
return NextResponse.json({ error: "Missing required contact fields" }, { status: 400 });
}
try {
const ticketId = await generateConsultationTicketId(); const ticketId = await generateConsultationTicketId();
// Build structured AI analysis // Build structured AI analysis (plain text, no markup needed)
const aiParts: string[] = []; const aiParts: string[] = [];
if (aiContext?.industryLabel && aiContext?.process) aiParts.push(`[INDUSTRY] ${aiContext.industryLabel}${aiContext.process}`); if (aiContext?.industryLabel && aiContext?.process)
if (aiContext?.estimatedSavingsPercent) aiParts.push(`[ESTIMATED SAVINGS] ~${aiContext.estimatedSavingsPercent}% energy reduction`); aiParts.push(`[INDUSTRY] ${aiContext.industryLabel} - ${aiContext.process}`);
if (aiContext?.estimatedSavingsPercent)
aiParts.push(`[ESTIMATED SAVINGS] ~${aiContext.estimatedSavingsPercent}% energy reduction`);
if (aiContext?.productionVolume) aiParts.push(`[PRODUCTION VOLUME] ${aiContext.productionVolume}`); if (aiContext?.productionVolume) aiParts.push(`[PRODUCTION VOLUME] ${aiContext.productionVolume}`);
if (aiContext?.conversationInsights?.length > 0) aiParts.push(`[AI DISCUSSION POINTS]\n${aiContext.conversationInsights.map((i: string) => `${i}`).join("\n")}`); if (aiContext?.conversationInsights?.length)
if (aiContext?.suggestedTopics?.length > 0) aiParts.push(`[SUGGESTED ENGINEERING TOPICS]\n${aiContext.suggestedTopics.map((t: string) => ` ${t}`).join("\n")}`); aiParts.push(`[AI DISCUSSION POINTS]\n${aiContext.conversationInsights.map((i) => `- ${i}`).join("\n")}`);
if (contact?.preferredContact) aiParts.push(`[PREFERRED CONTACT] ${contact.preferredContact.toUpperCase()}`); if (aiContext?.suggestedTopics?.length)
if (contact?.timeframe) aiParts.push(`[TIMEFRAME] ${contact.timeframe}`); aiParts.push(`[SUGGESTED ENGINEERING TOPICS]\n${aiContext.suggestedTopics.map((t) => `-> ${t}`).join("\n")}`);
if (meta?.source) aiParts.push(`[SOURCE] ${meta.source}${meta.url || "N/A"}`); if (contact.preferredContact) aiParts.push(`[PREFERRED CONTACT] ${contact.preferredContact.toUpperCase()}`);
if (contact.timeframe) aiParts.push(`[TIMEFRAME] ${contact.timeframe}`);
if (meta?.source) aiParts.push(`[SOURCE] ${meta.source} - ${meta.url || "N/A"}`);
const aiAnalysis = aiParts.join("\n\n"); const aiAnalysis = aiParts.join("\n\n");
const messageParts: string[] = []; const messageParts: string[] = [];
if (contact.message) messageParts.push(contact.message); if (contact.message) messageParts.push(contact.message);
if (contact.timeframe) messageParts.push(`Timeframe: ${contact.timeframe}`); if (contact.timeframe) messageParts.push(`Timeframe: ${contact.timeframe}`);
if (contact.preferredContact) messageParts.push(`Preferred contact: ${contact.preferredContact}`); if (contact.preferredContact) messageParts.push(`Preferred contact: ${contact.preferredContact}`);
// Save to DB
const signal = await prisma.operationsSignal.create({ const signal = await prisma.operationsSignal.create({
data: { data: {
ticketId, ticketId,
@@ -60,31 +118,49 @@ export async function POST(request: NextRequest) {
}, },
}); });
// Resolve email targets // ── Link conversation -> signal (best-effort, never blocks the response)
if (aiContext?.sessionId) {
try {
await prisma.aiConversation.updateMany({
where: { sessionId: aiContext.sessionId },
data: { outcome: "CONSULTATION", signalId: signal.id, closedAt: new Date() },
});
} catch (linkErr) {
log.warn("consultation.link_conversation_failed", { sessionId: aiContext.sessionId, err: String(linkErr) });
}
}
const route = await prisma.notificationRoute.findUnique({ where: { routeType: "CONSULTATION" } }); const route = await prisma.notificationRoute.findUnique({ where: { routeType: "CONSULTATION" } });
const targetEmails = route && route.isActive const targetEmails =
? route.emails.split(",").map((e: string) => e.trim()).filter(Boolean) route && route.isActive
: ["engineering@fluxsrl.com"]; ? route.emails.split(",").map((e: string) => e.trim()).filter(Boolean)
: ["engineering@fluxsrl.com"];
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://flux.com"; const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://flux.com";
// Send via SMTP
const emailResult = await sendEmail({ const emailResult = await sendEmail({
to: targetEmails, to: targetEmails,
subject: `[CONSULTATION] ${aiContext?.industryLabel || "General"} inquiry from ${contact.company} ${ticketId}`, subject: `[CONSULTATION] ${aiContext?.industryLabel || "General"} inquiry from ${contact.company} - ${ticketId}`,
html: generateConsultationEmail(contact, aiContext, ticketId, appUrl), html: generateConsultationEmail(contact, aiContext, ticketId, appUrl),
replyTo: contact.email, replyTo: contact.email,
}); });
// Track email delivery // Best-effort email tracking — the lead is already saved; never fail the
await prisma.operationsSignal.update({ // request (and risk a client retry / duplicate) over a telemetry update.
where: { id: signal.id }, try {
data: { await prisma.operationsSignal.update({
emailSentTo: emailResult.sentTo.join(", "), where: { id: signal.id },
emailSentAt: emailResult.sentAt, data: {
emailError: emailResult.error, emailSentTo: emailResult.sentTo.join(", "),
}, emailSentAt: emailResult.sentAt,
}); emailError: emailResult.error,
},
});
} catch (trackErr) {
log.warn("consultation.email_tracking_failed", { ticketId, err: String(trackErr) });
}
log.info("consultation.submitted", { ticketId, emailSent: emailResult.success });
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
@@ -93,33 +169,65 @@ export async function POST(request: NextRequest) {
emailError: emailResult.error, emailError: emailResult.error,
}); });
} catch (error) { } catch (error) {
console.error("Consultation API error:", error); log.error("consultation.submit_failed", error);
return NextResponse.json({ error: "Failed to submit consultation request" }, { status: 500 }); return NextResponse.json({ error: "Failed to submit consultation request" }, { status: 500 });
} }
} }
function generateConsultationEmail(contact: any, aiContext: any, ticketId: string, appUrl: string) { type ParsedContact = z.infer<typeof ConsultationSchema>["contact"];
const insights = (aiContext?.conversationInsights || []).map((i: string) => `<li style="margin-bottom: 6px;">${i}</li>`).join(""); type ParsedAiContext = z.infer<typeof ConsultationSchema>["aiContext"];
const topics = (aiContext?.suggestedTopics || []).map((t: string) => `<li style="margin-bottom: 4px; color: #0066CC;">${t}</li>`).join("");
function generateConsultationEmail(
contact: ParsedContact,
aiContext: ParsedAiContext | undefined,
ticketId: string,
_appUrl: string,
) {
const insightsHtml = (aiContext?.conversationInsights ?? [])
.map((i) => `<li style="margin-bottom: 6px;">${escapeHtml(i)}</li>`)
.join("");
const topicsHtml = (aiContext?.suggestedTopics ?? [])
.map((t) => `<li style="margin-bottom: 4px; color: #0066CC;">${escapeHtml(t)}</li>`)
.join("");
const safeName = escapeHtml(contact.name);
const safeCompany = escapeHtml(contact.company);
const safeEmail = escapeHtml(contact.email);
const mailHref = escapeAttr(safeMailto(contact.email));
const safePhone = contact.phone ? escapeHtml(contact.phone) : "";
const safePreferred = escapeHtml((contact.preferredContact || "email").toUpperCase());
const safeTimeframe = escapeHtml(contact.timeframe || "N/A");
const safeIndustry = aiContext?.industryLabel ? escapeHtml(aiContext.industryLabel) : "";
const safeProcess = aiContext?.process ? escapeHtml(aiContext.process) : "General";
const safeSavings = aiContext?.estimatedSavingsPercent
? escapeHtml(String(aiContext.estimatedSavingsPercent))
: "";
const safeVolume = aiContext?.productionVolume ? escapeHtml(aiContext.productionVolume) : "";
const safeMessage = contact.message ? escapeHtml(contact.message) : "";
const safeTicketId = escapeHtml(ticketId);
return ` return `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; color: #1D1D1F;"> <div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; color: #1D1D1F;">
<div style="border-bottom: 2px solid #00F0FF; padding-bottom: 20px; margin-bottom: 24px;"> <div style="border-bottom: 2px solid #00F0FF; padding-bottom: 20px; margin-bottom: 24px;">
<p style="color: #86868B; font-size: 12px; letter-spacing: 2px; text-transform: uppercase;">FLUX AI Engineering Consultation</p> <p style="color: #86868B; font-size: 12px; letter-spacing: 2px; text-transform: uppercase;">FLUX AI - Engineering Consultation</p>
<h1 style="margin: 8px 0 0 0; font-size: 22px;">New Consultation Request</h1> <h1 style="margin: 8px 0 0 0; font-size: 22px;">New Consultation Request</h1>
<p style="font-family: monospace; color: #00F0FF;">${ticketId}</p> <p style="font-family: monospace; color: #00F0FF;">${safeTicketId}</p>
</div> </div>
<div style="background: #F5F5F7; padding: 20px; border-radius: 12px; margin-bottom: 24px;"> <div style="background: #F5F5F7; padding: 20px; border-radius: 12px; margin-bottom: 24px;">
<p style="margin: 4px 0;"><strong>${contact.name}</strong> ${contact.company}</p> <p style="margin: 4px 0;"><strong>${safeName}</strong> - ${safeCompany}</p>
<p style="margin: 4px 0;">Email: <a href="mailto:${contact.email}" style="color: #0066CC;">${contact.email}</a></p> <p style="margin: 4px 0;">Email: <a href="${mailHref}" style="color: #0066CC;">${safeEmail}</a></p>
${contact.phone ? `<p style="margin: 4px 0;">Phone: ${contact.phone}</p>` : ""} ${safePhone ? `<p style="margin: 4px 0;">Phone: ${safePhone}</p>` : ""}
<p style="margin: 4px 0;">Preferred: <strong>${(contact.preferredContact || "email").toUpperCase()}</strong> · Timeframe: <strong>${contact.timeframe || "N/A"}</strong></p> <p style="margin: 4px 0;">Preferred: <strong>${safePreferred}</strong> &middot; Timeframe: <strong>${safeTimeframe}</strong></p>
</div> </div>
${aiContext?.industryLabel ? `<div style="background: #F0FBFF; border-left: 4px solid #00F0FF; padding: 16px 20px; border-radius: 0 12px 12px 0; margin-bottom: 24px;"><h3 style="margin: 0 0 8px 0; font-size: 13px; color: #00F0FF;">AI Context</h3><p style="margin: 4px 0;"><strong>Industry:</strong> ${aiContext.industryLabel}${aiContext.process || "General"}</p>${aiContext.estimatedSavingsPercent ? `<p style="color: #059669;"><strong>Savings:</strong> ~${aiContext.estimatedSavingsPercent}%</p>` : ""}${aiContext.productionVolume ? `<p><strong>Volume:</strong> ${aiContext.productionVolume}</p>` : ""}</div>` : ""} ${
${insights ? `<div style="margin-bottom: 24px;"><h3 style="font-size: 13px; color: #86868B;">Key Points</h3><ul style="padding-left: 20px;">${insights}</ul></div>` : ""} safeIndustry
${topics ? `<div style="margin-bottom: 24px;"><h3 style="font-size: 13px; color: #0066CC;">Prepare Topics</h3><ul style="padding-left: 20px; list-style: none;">${topics}</ul></div>` : ""} ? `<div style="background: #F0FBFF; border-left: 4px solid #00F0FF; padding: 16px 20px; border-radius: 0 12px 12px 0; margin-bottom: 24px;"><h3 style="margin: 0 0 8px 0; font-size: 13px; color: #00F0FF;">AI Context</h3><p style="margin: 4px 0;"><strong>Industry:</strong> ${safeIndustry} - ${safeProcess}</p>${safeSavings ? `<p style="color: #059669;"><strong>Savings:</strong> ~${safeSavings}%</p>` : ""}${safeVolume ? `<p><strong>Volume:</strong> ${safeVolume}</p>` : ""}</div>`
${contact.message ? `<div style="margin-bottom: 24px;"><h3 style="font-size: 13px; color: #86868B;">Client Notes</h3><div style="padding: 12px; border-left: 4px solid #1D1D1F; background: #FAFAFA;">${contact.message}</div></div>` : ""} : ""
<div style="border-top: 1px solid #E5E5EA; padding-top: 16px; margin-top: 32px; font-size: 11px; color: #86868B; text-align: center;"><p>FLUX Operations · Reply to contact ${contact.name} directly.</p></div> }
${insightsHtml ? `<div style="margin-bottom: 24px;"><h3 style="font-size: 13px; color: #86868B;">Key Points</h3><ul style="padding-left: 20px;">${insightsHtml}</ul></div>` : ""}
${topicsHtml ? `<div style="margin-bottom: 24px;"><h3 style="font-size: 13px; color: #0066CC;">Prepare Topics</h3><ul style="padding-left: 20px; list-style: none;">${topicsHtml}</ul></div>` : ""}
${safeMessage ? `<div style="margin-bottom: 24px;"><h3 style="font-size: 13px; color: #86868B;">Client Notes</h3><div style="padding: 12px; border-left: 4px solid #1D1D1F; background: #FAFAFA;">${safeMessage}</div></div>` : ""}
<div style="border-top: 1px solid #E5E5EA; padding-top: 16px; margin-top: 32px; font-size: 11px; color: #86868B; text-align: center;"><p>FLUX Operations &middot; Reply to contact ${safeName} directly.</p></div>
</div> </div>
`; `;
} }
+19
View File
@@ -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;
}
+36
View File
@@ -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 },
);
}
}
+39 -22
View File
@@ -2,19 +2,20 @@ import { NextRequest, NextResponse } from "next/server";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { revalidateContent } from "@/lib/revalidate"; import { revalidateContent } from "@/lib/revalidate";
import { detectFileType, expectedTypeForExtension } from "@/lib/fileType";
import { log } from "@/lib/logger";
// 1. REGLAS DE SEGURIDAD ESTRICTAS // 1. STRICT SECURITY RULES
const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp', '.mp4', '.mov']; const ALLOWED_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".mp4", ".mov"];
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB Límite const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const formData = await request.formData(); const formData = await request.formData();
const file = formData.get("file") as File; const file = formData.get("file") as File;
const ticketId = formData.get("ticketId") as string; const ticketId = formData.get("ticketId") as string;
const clientName = formData.get("clientName") as string || "unregistered"; const clientName = (formData.get("clientName") as string) || "unregistered";
// 2. VALIDACIONES INICIALES
if (!file || !ticketId) { if (!file || !ticketId) {
return NextResponse.json({ error: "Faltan datos requeridos (archivo o ticketId)" }, { status: 400 }); return NextResponse.json({ error: "Faltan datos requeridos (archivo o ticketId)" }, { status: 400 });
} }
@@ -25,50 +26,66 @@ export async function POST(request: NextRequest) {
const ext = path.extname(file.name).toLowerCase(); const ext = path.extname(file.name).toLowerCase();
if (!ALLOWED_EXTENSIONS.includes(ext)) { if (!ALLOWED_EXTENSIONS.includes(ext)) {
return NextResponse.json({ error: `Formato no permitido. Solo aceptamos: ${ALLOWED_EXTENSIONS.join(", ")}` }, { status: 400 }); return NextResponse.json(
{ error: `Formato no permitido. Solo aceptamos: ${ALLOWED_EXTENSIONS.join(", ")}` },
{ status: 415 },
);
} }
// 3. SANITIZACIÓN DE NOMBRES (Evita inyección de código y caracteres raros) // 2. SANITIZE NAMES
const safeTicketId = ticketId.replace(/[^a-zA-Z0-9-]/g, ""); const safeTicketId = ticketId.replace(/[^a-zA-Z0-9-]/g, "");
// Convertimos "David Herran!" a "david-herran"
const safeClientName = clientName.toLowerCase().replace(/[^a-z0-9]/g, "-").substring(0, 30); const safeClientName = clientName.toLowerCase().replace(/[^a-z0-9]/g, "-").substring(0, 30);
const folderName = `${safeTicketId}-${safeClientName}`; const folderName = `${safeTicketId}-${safeClientName}`;
// 4. CREACIÓN DE LA CARPETA DEL CLIENTE
// Ruta final: /public/operations-inbox/REQ-2026-X8Y-david-herran/
const uploadDir = path.join(process.cwd(), "public", "operations-inbox", folderName); const uploadDir = path.join(process.cwd(), "public", "operations-inbox", folderName);
// Escudo Anti-Hacking (Verifica que la ruta resuelta no se escape de la carpeta public) // 3. PATH TRAVERSAL GUARD
if (!path.resolve(uploadDir).startsWith(path.resolve(process.cwd(), "public", "operations-inbox"))) { if (!path.resolve(uploadDir).startsWith(path.resolve(process.cwd(), "public", "operations-inbox"))) {
return NextResponse.json({ error: "Ruta de directorio inválida" }, { status: 400 }); return NextResponse.json({ error: "Ruta de directorio inválida" }, { status: 400 });
} }
// 4. READ BUFFER FIRST so we can sniff magic bytes BEFORE writing to disk.
// This prevents stored-XSS payloads (HTML/JS renamed to .png).
const buffer = Buffer.from(await file.arrayBuffer());
const detected = detectFileType(buffer);
const expected = expectedTypeForExtension(ext);
if (!detected || (expected && detected !== expected && !(expected === "jpeg" && detected === "jpeg"))) {
log.warn("public_upload.magic_mismatch", {
ext,
detected,
expected,
size: file.size,
ticketId: safeTicketId,
});
return NextResponse.json(
{ error: "El contenido del archivo no coincide con su extensión." },
{ status: 415 },
);
}
// 5. CREATE FOLDER + WRITE
if (!fs.existsSync(uploadDir)) { if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true }); fs.mkdirSync(uploadDir, { recursive: true });
} }
// 5. GUARDAR EL ARCHIVO FÍSICAMENTE
const safeFileName = file.name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9._-]/g, ""); const safeFileName = file.name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9._-]/g, "");
const filePath = path.join(uploadDir, safeFileName); const filePath = path.join(uploadDir, safeFileName);
const buffer = Buffer.from(await file.arrayBuffer());
fs.writeFileSync(filePath, buffer); fs.writeFileSync(filePath, buffer);
// 6. DEVOLVER LA URL PÚBLICA PARA GUARDARLA EN POSTGRES
const publicUrl = `/operations-inbox/${folderName}/${safeFileName}`; const publicUrl = `/operations-inbox/${folderName}/${safeFileName}`;
// Invalida caché del operations-inbox / dashboard
revalidateContent({ scope: "operations-inbox", slug: folderName }); revalidateContent({ scope: "operations-inbox", slug: folderName });
log.info("public_upload.saved", { ticketId: safeTicketId, file: safeFileName, detected, bytes: file.size });
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
url: publicUrl, url: publicUrl,
fileName: safeFileName, fileName: safeFileName,
type: ext === '.mp4' || ext === '.mov' ? 'video' : 'image' type: ext === ".mp4" || ext === ".mov" ? "video" : "image",
}); });
} catch (error) { } catch (error) {
console.error("Error crítico en subida pública:", error); log.error("public_upload.failed", error);
return NextResponse.json({ error: "Error interno del servidor" }, { status: 500 }); return NextResponse.json({ error: "Error interno del servidor" }, { status: 500 });
} }
} }
+78
View File
@@ -0,0 +1,78 @@
"use client";
// Global error boundary — catches errors that bubble up past route-level
// error.tsx files. Renders its own <html>/<body> because the root layout
// errored too.
import { useEffect } from "react";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("[GlobalError]", error);
}, [error]);
return (
<html lang="en">
<body
style={{
fontFamily: "system-ui, -apple-system, sans-serif",
background: "#0A0A0C",
color: "#F5F5F7",
minHeight: "100vh",
margin: 0,
padding: "2rem",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div style={{ maxWidth: 720, width: "100%" }}>
<h1 style={{ fontSize: 28, fontWeight: 300, marginBottom: 16 }}>
Something went wrong on FLUX
</h1>
<p style={{ color: "#86868B", marginBottom: 24 }}>
The page hit an unexpected error. The team has been notified.
</p>
<pre
style={{
background: "#1D1D1F",
padding: 16,
borderRadius: 12,
overflow: "auto",
fontSize: 12,
color: "#FF6B6B",
border: "1px solid rgba(255,255,255,0.08)",
}}
>
{error.message || "Unknown error"}
{error.digest ? `\n\nDigest: ${error.digest}` : ""}
</pre>
<button
onClick={() => reset()}
style={{
marginTop: 24,
padding: "12px 24px",
background: "#00F0FF",
color: "#000",
border: "none",
borderRadius: 8,
fontSize: 14,
fontWeight: 500,
cursor: "pointer",
}}
>
Try again
</button>
</div>
</body>
</html>
);
}
@@ -7,6 +7,7 @@ import { revalidatePath } from "next/cache";
import { unstable_noStore as noStore } from "next/cache"; // 🔥 ANTI-CACHÉ import { unstable_noStore as noStore } from "next/cache"; // 🔥 ANTI-CACHÉ
// 🔥 IMPORTAMOS EL TRADUCTOR DE IA // 🔥 IMPORTAMOS EL TRADUCTOR DE IA
import { translateContentForCMS } from "@/lib/aiTranslator"; import { translateContentForCMS } from "@/lib/aiTranslator";
import { ensureAssetFolders } from "@/lib/assetFolders";
const generateSlug = (title: string) => { const generateSlug = (title: string) => {
return title.toLowerCase().trim().replace(/[^\w\s-]/g, '').replace(/[\s_-]+/g, '-').replace(/^-+|-+$/g, ''); return title.toLowerCase().trim().replace(/[^\w\s-]/g, '').replace(/[\s_-]+/g, '-').replace(/^-+|-+$/g, '');
@@ -17,7 +18,7 @@ export async function getApplications() {
noStore(); noStore();
try { try {
const apps = await prisma.application.findMany({ const apps = await prisma.application.findMany({
orderBy: { createdAt: "asc" } orderBy: [{ order: "asc" }, { createdAt: "asc" }]
}); });
return { success: true, apps }; return { success: true, apps };
} catch (error) { } catch (error) {
@@ -70,12 +71,16 @@ export async function createApplication(formData: FormData) {
translationsJson translationsJson
} }
}); });
// Pre-create the asset bucket folders so the editor's first upload
// (videos, renders, gallery, datasheet) lands somewhere that exists.
ensureAssetFolders("applications", slug);
revalidatePath("/hq-command/dashboard/applications"); revalidatePath("/hq-command/dashboard/applications");
revalidatePath("/[locale]", "layout"); revalidatePath("/[locale]", "layout");
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
return { error: "Failed to create application. Title might already exist." }; return { error: "Failed to create application. Title might already exist." };
} }
} }
@@ -156,6 +161,24 @@ export async function deleteApplication(slug: string) {
} }
} }
// 6b. REORDENAR APLICACIONES (drag-to-reorder, mismo patrón que HeroSlide)
// Recibe la lista de slugs en el nuevo orden y renumera el campo `order`
// en una sola transacción atómica.
export async function reorderApplications(orderedSlugs: string[]) {
try {
await prisma.$transaction(
orderedSlugs.map((slug, idx) =>
prisma.application.update({ where: { slug }, data: { order: idx } })
)
);
revalidatePath("/hq-command/dashboard/applications");
revalidatePath("/[locale]", "layout");
return { success: true };
} catch (e) {
return { error: "Failed to reorder applications." };
}
}
// 7. LA SEMILLA: AUTO-POBLAR LA BASE DE DATOS (Intacto) // 7. LA SEMILLA: AUTO-POBLAR LA BASE DE DATOS (Intacto)
export async function seedInitialApplications() { export async function seedInitialApplications() {
// ... Tu código actual de la semilla se queda exactamente igual ... // ... Tu código actual de la semilla se queda exactamente igual ...
@@ -6,31 +6,17 @@ import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
ArrowLeft, Layers, DatabaseZap, Loader2, X, CheckCircle2, FileText, LayoutTemplate, AlignLeft, Plus, Trash2, AlertCircle, Eye, EyeOff, Sparkles, ArrowLeft, Layers, DatabaseZap, Loader2, X, CheckCircle2, FileText, LayoutTemplate, AlignLeft, Plus, Trash2, AlertCircle, Eye, EyeOff, Sparkles,
Bold, Italic, Heading1, Heading2, Heading3, List, ListOrdered, Quote, Table, Minus, Image as ImageIcon, Video, Box, Type, Code, RotateCcw, RotateCw, Bold, Italic, Heading1, Heading2, Heading3, List, ListOrdered, Quote, Table, Minus, Image as ImageIcon, Video, Box, Type, Code, RotateCcw, RotateCw,
Maximize2, Minimize2, ChevronDown, FolderOpen, Upload, FolderPlus, ChevronRight, File, ArrowUpFromLine, Search, Grid3X3, LayoutList, Copy, Check Maximize2, Minimize2, ChevronDown, FolderOpen, Upload, FolderPlus, ChevronRight, File, ArrowUpFromLine, Search, Grid3X3, LayoutList, Copy, Check, GripVertical
} from "lucide-react"; } from "lucide-react";
import { getApplications, createApplication, updateApplicationData, toggleApplication, deleteApplication } from "./actions"; import { getApplications, createApplication, updateApplicationData, toggleApplication, deleteApplication, reorderApplications } from "./actions";
import { useHqUi } from "@/components/hq/Toast";
// ───────────────────────────────────────────────────────────────────────────── // AssetBucketBrowser is the unified picker. The applications page uses an
// 🔥 ASSET MANAGER — File Browser, Upload & Folder Creation // onInsert(markdownSyntax) callback, so we wrap with an adapter that maps
// ───────────────────────────────────────────────────────────────────────────── // the picker's onSelect(item) into the markdown syntax the editor expects.
// Connects to /api/assets to browse, upload, and organize media files import AssetBucketBrowser, { type SelectedAsset } from "@/components/hq/AssetBucketBrowser";
// within /public/applications/{slug}/
// ─────────────────────────────────────────────────────────────────────────────
interface AssetItem {
name: string;
type: "file" | "folder";
mediaType?: string;
extension?: string;
path: string;
publicUrl?: string;
size?: string;
sizeBytes?: number;
modifiedAt?: string;
childCount?: number;
}
interface AssetManagerProps { interface AssetManagerProps {
slug: string; slug: string;
@@ -40,326 +26,29 @@ interface AssetManagerProps {
} }
function AssetManager({ slug, isOpen, onClose, onInsert }: AssetManagerProps) { function AssetManager({ slug, isOpen, onClose, onInsert }: AssetManagerProps) {
const [currentPath, setCurrentPath] = useState(""); const handleSelect = (item: SelectedAsset) => {
const [items, setItems] = useState<AssetItem[]>([]);
const [breadcrumbs, setBreadcrumbs] = useState<{name: string; path: string}[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [showNewFolder, setShowNewFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const [searchQuery, setSearchQuery] = useState("");
const [copiedPath, setCopiedPath] = useState<string | null>(null);
const fetchAssets = useCallback(async (dirPath: string = "") => {
setIsLoading(true);
setError(null);
try {
const params = new URLSearchParams({ slug, path: dirPath });
const res = await fetch(`/api/assets?${params}`);
const data = await res.json();
if (data.success) {
setItems(data.items);
setBreadcrumbs(data.breadcrumbs);
setCurrentPath(dirPath);
} else {
setError(data.error || "Failed to load directory");
}
} catch (err) {
setError("Connection error — make sure /api/assets/route.ts exists.");
}
setIsLoading(false);
}, [slug]);
useEffect(() => {
if (isOpen) { fetchAssets(currentPath); setSearchQuery(""); }
}, [isOpen, fetchAssets]); // eslint-disable-line react-hooks/exhaustive-deps
const navigateTo = (folderPath: string) => { fetchAssets(folderPath); };
const uploadFile = async (file: File) => {
setIsUploading(true);
setUploadProgress(`Uploading ${file.name}...`);
try {
const formData = new FormData();
formData.append("slug", slug);
formData.append("path", currentPath);
formData.append("file", file);
const res = await fetch("/api/assets", { method: "POST", body: formData });
const data = await res.json();
if (data.success) {
setUploadProgress(`${data.file.name} uploaded`);
await fetchAssets(currentPath);
setTimeout(() => setUploadProgress(""), 2000);
} else {
setUploadProgress(`✗ Error: ${data.error}`);
setTimeout(() => setUploadProgress(""), 4000);
}
} catch (err) {
setUploadProgress("✗ Upload failed");
setTimeout(() => setUploadProgress(""), 3000);
}
setIsUploading(false);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files) Array.from(files).forEach(uploadFile);
e.target.value = "";
};
const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); };
const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); };
const handleDrop = (e: React.DragEvent) => {
e.preventDefault(); setIsDragging(false);
const files = e.dataTransfer.files;
if (files.length > 0) Array.from(files).forEach(uploadFile);
};
const createFolder = async () => {
if (!newFolderName.trim()) return;
try {
const res = await fetch("/api/assets", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug, folderName: newFolderName, parentPath: currentPath }),
});
const data = await res.json();
if (data.success) {
setShowNewFolder(false); setNewFolderName(""); await fetchAssets(currentPath);
} else { alert(data.error || "Failed to create folder"); }
} catch { alert("Connection error creating folder"); }
};
const deleteFile = async (filePath: string, fileName: string) => {
if (!confirm(`Delete "${fileName}"? This cannot be undone.`)) return;
try {
const res = await fetch("/api/assets", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug, filePath }),
});
const data = await res.json();
if (data.success) await fetchAssets(currentPath);
else alert(data.error);
} catch { alert("Failed to delete file"); }
};
const insertAsset = (item: AssetItem) => {
if (item.type === "folder") return;
const url = item.publicUrl || `/applications/${slug}/${item.path}`;
let syntax = ""; let syntax = "";
switch (item.mediaType) { switch (item.mediaType) {
case "image": syntax = `![${item.name}](${url})`; break; case "image": syntax = `![${item.name}](${item.publicUrl})`; break;
case "video": syntax = `[VIDEO:${url}]`; break; case "video": syntax = `[VIDEO:${item.publicUrl}]`; break;
case "model": syntax = `[3D:${url}]`; break; case "model": syntax = `[3D:${item.publicUrl}]`; break;
default: syntax = `[${item.name}](${url})`; default: syntax = `[${item.name}](${item.publicUrl})`;
} }
onInsert(syntax); onInsert(syntax);
onClose(); onClose();
}; };
const copyPath = (item: AssetItem) => {
const url = item.publicUrl || `/applications/${slug}/${item.path}`;
navigator.clipboard.writeText(url);
setCopiedPath(item.path);
setTimeout(() => setCopiedPath(null), 1500);
};
const filteredItems = searchQuery
? items.filter(item => item.name.toLowerCase().includes(searchQuery.toLowerCase()))
: items;
const renderThumbnail = (item: AssetItem) => {
if (item.type === "folder") return <div className="w-full h-full flex items-center justify-center bg-white/[0.02]"><FolderOpen size={28} className="text-purple-400/70" /></div>;
if (item.mediaType === "image" && item.publicUrl) return <img src={item.publicUrl} alt={item.name} className="w-full h-full object-cover" loading="lazy" />;
if (item.mediaType === "video") return <div className="w-full h-full flex items-center justify-center bg-blue-500/5"><Video size={24} className="text-blue-400/60" /></div>;
if (item.mediaType === "model") return <div className="w-full h-full flex items-center justify-center bg-purple-500/5"><Box size={24} className="text-purple-400/60" /></div>;
return <div className="w-full h-full flex items-center justify-center bg-white/[0.02]"><File size={24} className="text-[#86868B]/50" /></div>;
};
const typeBadge = (mediaType?: string) => {
const styles: Record<string, string> = {
image: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
video: "bg-blue-500/10 text-blue-400 border-blue-500/20",
model: "bg-purple-500/10 text-purple-400 border-purple-500/20",
document: "bg-amber-500/10 text-amber-400 border-amber-500/20",
};
return styles[mediaType || ""] || "bg-white/5 text-[#86868B] border-white/10";
};
if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 z-[70] flex items-center justify-center p-4 bg-black/85 backdrop-blur-md"> <AssetBucketBrowser
<div className="bg-[#0D0D0D] border border-white/10 w-full max-w-4xl rounded-[2rem] shadow-2xl flex flex-col max-h-[85vh] relative overflow-hidden" slug={slug}
onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop}> scope="applications"
isOpen={isOpen}
{isDragging && ( onClose={onClose}
<div className="absolute inset-0 z-50 bg-purple-500/10 border-2 border-dashed border-purple-500/50 rounded-[2rem] flex items-center justify-center backdrop-blur-sm"> onSelect={handleSelect}
<div className="text-center"> accentColor="#9333EA"
<ArrowUpFromLine size={48} className="text-purple-400 mx-auto mb-3 animate-bounce" /> />
<p className="text-purple-400 font-medium text-lg">Drop files to upload</p>
<p className="text-[#86868B] text-sm mt-1">to /applications/{slug}/{currentPath || "root"}</p>
</div>
</div>
)}
{/* Header */}
<div className="px-6 py-5 border-b border-white/10 shrink-0">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-500/15 rounded-xl text-purple-400"><FolderOpen size={20} /></div>
<div>
<h3 className="text-lg font-medium text-white">Asset Manager</h3>
<p className="text-[10px] text-[#86868B] uppercase tracking-widest font-mono">/public/applications/{slug}/</p>
</div>
</div>
<button onClick={onClose} className="text-[#86868B] hover:text-white p-2 hover:bg-white/5 rounded-xl transition-all"><X size={20} /></button>
</div>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-1 text-sm overflow-x-auto [scrollbar-width:none] flex-1 min-w-0">
{breadcrumbs.map((crumb, idx) => (
<span key={idx} className="flex items-center shrink-0">
{idx > 0 && <ChevronRight size={12} className="text-[#86868B]/50 mx-0.5" />}
<button onClick={() => navigateTo(crumb.path)} className={`px-2 py-1 rounded-lg transition-colors text-xs ${idx === breadcrumbs.length - 1 ? "text-purple-400 bg-purple-500/10" : "text-[#86868B] hover:text-white hover:bg-white/5"}`}>{crumb.name}</button>
</span>
))}
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="relative">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[#86868B]" />
<input value={searchQuery} onChange={e => setSearchQuery(e.target.value)} placeholder="Filter..." className="bg-white/5 border border-white/10 rounded-lg pl-8 pr-3 py-1.5 text-xs text-white w-32 focus:w-48 transition-all outline-none focus:border-purple-500/50" />
</div>
<div className="flex bg-white/5 rounded-lg border border-white/10 overflow-hidden">
<button onClick={() => setViewMode("grid")} className={`p-1.5 transition-colors ${viewMode === "grid" ? "bg-purple-500/20 text-purple-400" : "text-[#86868B] hover:text-white"}`}><Grid3X3 size={14} /></button>
<button onClick={() => setViewMode("list")} className={`p-1.5 transition-colors ${viewMode === "list" ? "bg-purple-500/20 text-purple-400" : "text-[#86868B] hover:text-white"}`}><LayoutList size={14} /></button>
</div>
<button onClick={() => setShowNewFolder(!showNewFolder)} className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-[#86868B] hover:text-white bg-white/5 border border-white/10 rounded-lg hover:bg-white/10 transition-all"><FolderPlus size={13} /> Folder</button>
<button onClick={() => fileInputRef.current?.click()} disabled={isUploading} className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-white bg-purple-500 rounded-lg hover:bg-purple-400 transition-all disabled:opacity-50 font-medium"><Upload size={13} /> Upload</button>
<input ref={fileInputRef} type="file" multiple accept=".jpg,.jpeg,.png,.webp,.gif,.svg,.avif,.mp4,.webm,.mov,.glb,.gltf,.usdz,.pdf" onChange={handleFileSelect} className="hidden" />
</div>
</div>
{showNewFolder && (
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-white/5">
<FolderPlus size={14} className="text-purple-400 shrink-0" />
<input value={newFolderName} onChange={e => setNewFolderName(e.target.value)} onKeyDown={e => { if (e.key === "Enter") createFolder(); if (e.key === "Escape") { setShowNewFolder(false); setNewFolderName(""); } }} placeholder="folder-name (lowercase, hyphens ok)" autoFocus className="flex-1 bg-black/40 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-white focus:border-purple-500 outline-none font-mono" />
<button onClick={createFolder} className="px-3 py-1.5 text-xs bg-purple-500 text-white rounded-lg hover:bg-purple-400 font-medium">Create</button>
<button onClick={() => { setShowNewFolder(false); setNewFolderName(""); }} className="px-3 py-1.5 text-xs text-[#86868B] hover:text-white">Cancel</button>
</div>
)}
{uploadProgress && (
<div className="mt-3 pt-3 border-t border-white/5">
<div className="flex items-center gap-2 text-xs">
{isUploading && <Loader2 size={12} className="animate-spin text-purple-400" />}
<span className={isUploading ? "text-purple-400" : uploadProgress.startsWith("✓") ? "text-emerald-400" : "text-red-400"}>{uploadProgress}</span>
</div>
</div>
)}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 [scrollbar-width:none]">
{isLoading ? (
<div className="flex items-center justify-center py-20"><Loader2 size={24} className="animate-spin text-purple-400" /></div>
) : error ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<AlertCircle size={32} className="text-red-400/50 mb-3" />
<p className="text-red-400/80 text-sm mb-1">{error}</p>
<p className="text-[#86868B] text-xs">Make sure <code className="bg-white/5 px-1.5 py-0.5 rounded text-purple-400">/api/assets/route.ts</code> exists.</p>
</div>
) : filteredItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<FolderOpen size={48} className="text-[#86868B]/20 mb-4" />
{searchQuery ? (
<p className="text-[#86868B] text-sm">No files matching &quot;{searchQuery}&quot;</p>
) : (
<>
<p className="text-[#86868B] text-sm mb-2">This directory is empty</p>
<p className="text-[#86868B]/60 text-xs">Upload files or create subfolders to organize your assets</p>
<div className="flex gap-2 mt-4">
{["images", "videos", "models"].map(folder => (
<button key={folder} onClick={async () => {
await fetch("/api/assets", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug, folderName: folder, parentPath: currentPath }) });
fetchAssets(currentPath);
}} className="px-3 py-2 text-xs text-purple-400 bg-purple-500/10 border border-purple-500/20 rounded-lg hover:bg-purple-500/20 transition-colors">+ {folder}/</button>
))}
</div>
</>
)}
</div>
) : viewMode === "grid" ? (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3">
{filteredItems.map((item) => (
<div key={item.path} className="group relative bg-[#111] border border-white/5 rounded-xl overflow-hidden hover:border-purple-500/30 transition-all cursor-pointer" onClick={() => item.type === "folder" ? navigateTo(item.path) : insertAsset(item)}>
<div className="aspect-square overflow-hidden bg-black/20">{renderThumbnail(item)}</div>
<div className="p-2">
<p className="text-[11px] text-white truncate font-medium">{item.name}</p>
<div className="flex items-center justify-between mt-1">
{item.type === "folder" ? (
<span className="text-[9px] text-[#86868B]">{item.childCount} items</span>
) : (
<span className={`text-[9px] px-1.5 py-0.5 rounded border ${typeBadge(item.mediaType)}`}>{item.mediaType}</span>
)}
{item.size && <span className="text-[9px] text-[#86868B]">{item.size}</span>}
</div>
</div>
{item.type === "file" && (
<div className="absolute top-1 right-1 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={(e) => { e.stopPropagation(); copyPath(item); }} className="p-1.5 bg-black/70 backdrop-blur-sm rounded-lg text-[#86868B] hover:text-white transition-colors" title="Copy path">
{copiedPath === item.path ? <Check size={11} className="text-emerald-400" /> : <Copy size={11} />}
</button>
<button onClick={(e) => { e.stopPropagation(); deleteFile(item.path, item.name); }} className="p-1.5 bg-black/70 backdrop-blur-sm rounded-lg text-[#86868B] hover:text-red-400 transition-colors" title="Delete"><Trash2 size={11} /></button>
</div>
)}
</div>
))}
</div>
) : (
<div className="space-y-1">
{filteredItems.map((item) => (
<div key={item.path} className="group flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-white/[0.03] transition-colors cursor-pointer" onClick={() => item.type === "folder" ? navigateTo(item.path) : insertAsset(item)}>
<div className="w-8 h-8 rounded-lg overflow-hidden shrink-0 border border-white/5">
{item.type === "folder" ? <div className="w-full h-full flex items-center justify-center bg-purple-500/10"><FolderOpen size={14} className="text-purple-400" /></div>
: item.mediaType === "image" && item.publicUrl ? <img src={item.publicUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
: <div className={`w-full h-full flex items-center justify-center ${item.mediaType === "video" ? "bg-blue-500/10" : item.mediaType === "model" ? "bg-purple-500/10" : "bg-white/5"}`}>
{item.mediaType === "video" ? <Video size={12} className="text-blue-400" /> : item.mediaType === "model" ? <Box size={12} className="text-purple-400" /> : <File size={12} className="text-[#86868B]" />}
</div>}
</div>
<span className="text-sm text-white flex-1 truncate">{item.name}</span>
{item.type === "file" && <span className={`text-[9px] px-2 py-0.5 rounded border shrink-0 ${typeBadge(item.mediaType)}`}>{item.extension}</span>}
{item.type === "folder" && <span className="text-[9px] text-[#86868B] shrink-0">{item.childCount} items</span>}
{item.size && <span className="text-[10px] text-[#86868B] w-16 text-right shrink-0">{item.size}</span>}
{item.type === "file" && (
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button onClick={(e) => { e.stopPropagation(); copyPath(item); }} className="p-1.5 rounded-lg text-[#86868B] hover:text-white hover:bg-white/10 transition-colors">{copiedPath === item.path ? <Check size={12} className="text-emerald-400" /> : <Copy size={12} />}</button>
<button onClick={(e) => { e.stopPropagation(); deleteFile(item.path, item.name); }} className="p-1.5 rounded-lg text-[#86868B] hover:text-red-400 hover:bg-red-500/10 transition-colors"><Trash2 size={12} /></button>
</div>
)}
{item.type === "folder" && <ChevronRight size={14} className="text-[#86868B]/50 shrink-0" />}
</div>
))}
</div>
)}
</div>
<div className="px-6 py-3 border-t border-white/10 flex items-center justify-between text-[10px] text-[#86868B] shrink-0 bg-black/20">
<span>{filteredItems.length} items{searchQuery ? " (filtered)" : ""} Click a file to insert into editor</span>
<span className="font-mono">Drag & drop supported</span>
</div>
</div>
</div>
); );
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// 🔥 MARKDOWN EDITOR — Rich Toolbar + Asset Manager // 🔥 MARKDOWN EDITOR — Rich Toolbar + Asset Manager
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -557,6 +246,7 @@ function MarkdownEditor({ name, defaultValue = "", required, rows = 12, placehol
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
export default function ApplicationsManager() { export default function ApplicationsManager() {
const ui = useHqUi();
const router = useRouter(); const router = useRouter();
const [apps, setApps] = useState<any[]>([]); const [apps, setApps] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -568,9 +258,29 @@ export default function ApplicationsManager() {
const [sections, setSections] = useState<any[]>([]); const [sections, setSections] = useState<any[]>([]);
const [dashboardMetrics, setDashboardMetrics] = useState<any[]>([]); const [dashboardMetrics, setDashboardMetrics] = useState<any[]>([]);
const [draggedSlug, setDraggedSlug] = useState<string | null>(null);
const fetchApps = async () => { setIsLoading(true); const res = await getApplications(); if (res.success && res.apps) setApps(res.apps); setIsLoading(false); }; const fetchApps = async () => { setIsLoading(true); const res = await getApplications(); if (res.success && res.apps) setApps(res.apps); setIsLoading(false); };
useEffect(() => { fetchApps(); }, []); useEffect(() => { fetchApps(); }, []);
// Drag-to-reorder — same pattern as the Hero panel. Optimistic local
// reorder, then persist the new order to the DB.
const onDropApp = async (targetSlug: string) => {
if (!draggedSlug || draggedSlug === targetSlug) return;
const slugs = apps.map((a) => a.slug);
const from = slugs.indexOf(draggedSlug);
const to = slugs.indexOf(targetSlug);
if (from < 0 || to < 0) return;
const reordered = [...slugs];
reordered.splice(from, 1);
reordered.splice(to, 0, draggedSlug);
setApps((prev) => reordered.map((s) => prev.find((a) => a.slug === s)!));
setDraggedSlug(null);
const res = await reorderApplications(reordered);
if (res?.error) { ui.toast(res.error, "error"); fetchApps(); }
else ui.toast("Order updated.", "success");
};
const openEditModal = (app: any) => { const openEditModal = (app: any) => {
setEditingApp(app); setActiveTab("basic"); setEditingApp(app); setActiveTab("basic");
try { setAdvantages(JSON.parse(app.advantagesJson || "[]")); } catch { setAdvantages([]); } try { setAdvantages(JSON.parse(app.advantagesJson || "[]")); } catch { setAdvantages([]); }
@@ -585,8 +295,14 @@ export default function ApplicationsManager() {
formData.append("sectionsJson", JSON.stringify(sections)); formData.append("sectionsJson", JSON.stringify(sections));
formData.append("dashboardMetricsJson", JSON.stringify(dashboardMetrics)); formData.append("dashboardMetricsJson", JSON.stringify(dashboardMetrics));
const res = await updateApplicationData(formData); const res = await updateApplicationData(formData);
if (res.error) { alert("Error saving data: " + res.error); } if (res.error) {
else { setEditingApp(null); await fetchApps(); router.refresh(); } ui.toast(`Error saving data: ${res.error}`, "error");
} else {
ui.toast("Application saved.", "success");
setEditingApp(null);
await fetchApps();
router.refresh();
}
setIsSubmitting(false); setIsSubmitting(false);
}; };
@@ -603,7 +319,7 @@ export default function ApplicationsManager() {
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-6"> <div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
<div> <div>
<h1 className="text-3xl font-light text-white flex items-center gap-3"><Layers className="text-purple-400" /> Knowledge Base</h1> <h1 className="text-3xl font-light text-white flex items-center gap-3"><Layers className="text-purple-400" /> Knowledge Base</h1>
<p className="text-[#86868B] mt-2">Manage the technical literature and general specifications of your application categories.</p> <p className="text-[#86868B] mt-2">Manage the technical literature and specifications. Drag rows by the handle to reorder how applications appear on the site.</p>
</div> </div>
<button onClick={() => setIsCreateModalOpen(true)} className="flex items-center gap-2 bg-purple-500/10 text-purple-400 border border-purple-500/20 px-5 py-2.5 rounded-xl font-medium hover:bg-purple-500 hover:text-white transition-all"><Plus size={18} /> New Application</button> <button onClick={() => setIsCreateModalOpen(true)} className="flex items-center gap-2 bg-purple-500/10 text-purple-400 border border-purple-500/20 px-5 py-2.5 rounded-xl font-medium hover:bg-purple-500 hover:text-white transition-all"><Plus size={18} /> New Application</button>
</div> </div>
@@ -612,18 +328,39 @@ export default function ApplicationsManager() {
<div className="bg-[#111] border border-white/5 rounded-3xl overflow-hidden shadow-2xl"> <div className="bg-[#111] border border-white/5 rounded-3xl overflow-hidden shadow-2xl">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-left border-collapse"> <table className="w-full text-left border-collapse">
<thead><tr className="border-b border-white/5 text-[10px] uppercase tracking-widest text-[#86868B] bg-black/40"><th className="p-6 font-semibold">Application Sector</th><th className="p-6 font-semibold">Status</th><th className="p-6 font-semibold">Visibility</th><th className="p-6 font-semibold text-right">Actions</th></tr></thead> <thead><tr className="border-b border-white/5 text-[10px] uppercase tracking-widest text-[#86868B] bg-black/40"><th className="p-6 font-semibold w-10"></th><th className="p-6 font-semibold">Application Sector</th><th className="p-6 font-semibold">Status</th><th className="p-6 font-semibold">Visibility</th><th className="p-6 font-semibold text-right">Actions</th></tr></thead>
<tbody> <tbody>
{isLoading ? ( {isLoading ? (
<tr><td colSpan={4} className="p-8 text-center text-[#86868B]"><Loader2 className="animate-spin mx-auto mb-2" size={24} /> Loading database...</td></tr> <tr><td colSpan={5} className="p-8 text-center text-[#86868B]"><Loader2 className="animate-spin mx-auto mb-2" size={24} /> Loading database...</td></tr>
) : apps.length === 0 ? (
<tr><td colSpan={5} className="p-8 text-center text-[#86868B]">No applications yet.</td></tr>
) : apps.map((app) => { ) : apps.map((app) => {
const isPopulated = app.heroDescription && app.heroDescription.length > 10; const isPopulated = app.heroDescription && app.heroDescription.length > 10;
return ( return (
<tr key={app.slug} className={`border-b border-white/5 transition-colors group ${app.isActive ? 'hover:bg-white/[0.02]' : 'opacity-50'}`}> <tr
key={app.slug}
draggable
onDragStart={() => setDraggedSlug(app.slug)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => onDropApp(app.slug)}
className={`border-b border-white/5 transition-colors group ${draggedSlug === app.slug ? 'opacity-40' : ''} ${app.isActive ? 'hover:bg-white/[0.02]' : 'opacity-50'}`}
>
<td className="p-6"><span className="cursor-grab text-[#86868B] hover:text-white inline-flex" title="Drag to reorder"><GripVertical size={16} /></span></td>
<td className="p-6"><p className="font-medium text-white">{app.title}</p><p className="text-xs text-[#86868B] font-mono mt-1">/{app.slug}</p></td> <td className="p-6"><p className="font-medium text-white">{app.title}</p><p className="text-xs text-[#86868B] font-mono mt-1">/{app.slug}</p></td>
<td className="p-6">{isPopulated ? <span className="bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-3 py-1 rounded-full text-[10px] uppercase tracking-wider flex items-center gap-1.5 w-fit font-semibold"><CheckCircle2 size={12} /> Populated</span> : <span className="bg-white/5 text-[#86868B] border border-white/10 px-3 py-1 rounded-full text-[10px] uppercase tracking-wider flex items-center gap-1.5 w-fit"><AlertCircle size={12} /> Pending Setup</span>}</td> <td className="p-6">{isPopulated ? <span className="bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-3 py-1 rounded-full text-[10px] uppercase tracking-wider flex items-center gap-1.5 w-fit font-semibold"><CheckCircle2 size={12} /> Populated</span> : <span className="bg-white/5 text-[#86868B] border border-white/10 px-3 py-1 rounded-full text-[10px] uppercase tracking-wider flex items-center gap-1.5 w-fit"><AlertCircle size={12} /> Pending Setup</span>}</td>
<td className="p-6"><button onClick={() => {toggleApplication(app.slug, app.isActive); fetchApps();}} className={`text-xs font-medium px-3 py-1 rounded-full flex items-center gap-1.5 transition-colors ${app.isActive ? 'bg-purple-500/10 text-purple-400' : 'bg-red-500/10 text-red-400'}`}>{app.isActive ? <><Eye size={12} /> Visible</> : <><EyeOff size={12} /> Hidden</>}</button></td> <td className="p-6"><button onClick={() => {toggleApplication(app.slug, app.isActive); fetchApps();}} className={`text-xs font-medium px-3 py-1 rounded-full flex items-center gap-1.5 transition-colors ${app.isActive ? 'bg-purple-500/10 text-purple-400' : 'bg-red-500/10 text-red-400'}`}>{app.isActive ? <><Eye size={12} /> Visible</> : <><EyeOff size={12} /> Hidden</>}</button></td>
<td className="p-6 text-right"><div className="flex justify-end gap-2"><button onClick={() => openEditModal(app)} className={`inline-flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-semibold transition-all ${isPopulated ? 'bg-white/5 text-white hover:bg-white/10' : 'bg-purple-500 text-white hover:bg-purple-400'}`}><DatabaseZap size={14} /> Manage Core</button><button onClick={() => { if(confirm("Delete this application forever?")) { deleteApplication(app.slug); fetchApps(); } }} className="text-[#86868B] hover:text-red-400 p-2 rounded-lg transition-colors opacity-0 group-hover:opacity-100"><Trash2 size={18}/></button></div></td> <td className="p-6 text-right"><div className="flex justify-end gap-2"><button onClick={() => openEditModal(app)} className={`inline-flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-semibold transition-all ${isPopulated ? 'bg-white/5 text-white hover:bg-white/10' : 'bg-purple-500 text-white hover:bg-purple-400'}`}><DatabaseZap size={14} /> Manage Core</button><button onClick={async () => {
const ok = await ui.confirm({
title: "Delete application",
message: `Permanently remove "${app.title}" from the catalog. The asset folder on disk is preserved.`,
confirmLabel: "Delete forever",
destructive: true,
});
if (!ok) return;
await deleteApplication(app.slug);
ui.toast("Application deleted.", "success");
fetchApps();
}} className="text-[#86868B] hover:text-red-400 p-2 rounded-lg transition-colors opacity-0 group-hover:opacity-100"><Trash2 size={18}/></button></div></td>
</tr> </tr>
); );
})} })}
@@ -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>
);
}
+3 -1
View File
@@ -7,8 +7,10 @@ import {
DownloadCloud, ShieldAlert, UploadCloud, Loader2, CheckCircle2 DownloadCloud, ShieldAlert, UploadCloud, Loader2, CheckCircle2
} from "lucide-react"; } from "lucide-react";
import { getSystemMetrics, exportDatabase, restoreDatabase } from "./actions"; import { getSystemMetrics, exportDatabase, restoreDatabase } from "./actions";
import { useHqUi } from "@/components/hq/Toast";
export default function SystemHealth() { export default function SystemHealth() {
const ui = useHqUi();
const [metrics, setMetrics] = useState<any>(null); const [metrics, setMetrics] = useState<any>(null);
const [isExporting, setIsExporting] = useState(false); const [isExporting, setIsExporting] = useState(false);
@@ -54,7 +56,7 @@ export default function SystemHealth() {
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} else { } else {
alert(res.error || "Export failed."); ui.toast(res.error || "Export failed.", "error");
} }
setIsExporting(false); setIsExporting(false);
}; };
@@ -92,4 +92,87 @@ export async function deleteHeritageSection(id: string) {
} catch (error) { } catch (error) {
return { error: "Failed to delete section." }; return { error: "Failed to delete section." };
} }
}
// PATCH GRANULAR — for inline auto-save in the new UI.
// Same pattern as patchTimelineEvent: only the supplied fields are written,
// AI translation kicks in when autoTranslate=true and a text field changed.
export async function patchHeritageSection(
id: string,
patch: {
type?: string;
title?: string | null;
content?: string | null;
mediaUrl?: string | null;
order?: number;
autoTranslate?: boolean;
}
) {
try {
if (!id) return { error: "Missing id" };
const data: any = {};
if (patch.type !== undefined) data.type = patch.type;
if (patch.title !== undefined) data.title = patch.title;
if (patch.content !== undefined) data.content = patch.content;
if (patch.mediaUrl !== undefined) data.mediaUrl = patch.mediaUrl;
if (patch.order !== undefined) data.order = patch.order;
if (patch.autoTranslate && (patch.title !== undefined || patch.content !== undefined)) {
const existing = await prisma.heritageSection.findUnique({ where: { id } });
const finalTitle = patch.title ?? existing?.title ?? "";
const finalContent = patch.content ?? existing?.content ?? "";
if (finalTitle || finalContent) {
const aiResult = await translateContentForCMS({ title: finalTitle, content: finalContent });
if (aiResult) data.translationsJson = JSON.stringify(aiResult);
}
}
await prisma.heritageSection.update({ where: { id }, data });
revalidatePath("/hq-command/dashboard/heritage");
revalidatePath("/[locale]/heritage", "layout");
return { success: true };
} catch (error: any) {
return { error: error.message || "Failed to update section." };
}
}
export async function reorderHeritageSections(orderedIds: string[]) {
try {
await prisma.$transaction(
orderedIds.map((id, idx) =>
prisma.heritageSection.update({ where: { id }, data: { order: idx } })
)
);
revalidatePath("/hq-command/dashboard/heritage");
revalidatePath("/[locale]/heritage", "layout");
return { success: true };
} catch (error: any) {
return { error: error.message || "Failed to reorder." };
}
}
export async function createHeritageStub(type: string = "text") {
try {
const last = await prisma.heritageSection.findFirst({
orderBy: { order: "desc" },
select: { order: true },
});
const nextOrder = last ? last.order + 1 : 0;
const section = await prisma.heritageSection.create({
data: {
type,
title: type === "text" ? "New section" : null,
content: type === "text" ? "" : null,
mediaUrl: null,
order: nextOrder,
},
});
revalidatePath("/hq-command/dashboard/heritage");
return { success: true, section };
} catch (error: any) {
return { error: error.message || "Failed to create section." };
}
} }
+339 -181
View File
@@ -1,210 +1,368 @@
"use client"; "use client";
import { useState, useEffect } from "react"; export const dynamic = "force-dynamic";
import { useState, useEffect, useCallback, useRef } from "react";
import Link from "next/link"; import Link from "next/link";
// 🔥 Agregamos Sparkles import {
import { ArrowLeft, BookOpen, Plus, Trash2, Loader2, X, Image as ImageIcon, FileText, Video, Edit3, Sparkles } from "lucide-react"; ArrowLeft, BookOpen, Plus, Trash2, Loader2, GripVertical, Sparkles,
import { getHeritageSections, createHeritageSection, updateHeritageSection, deleteHeritageSection } from "./actions"; Image as ImageIcon, FileText, Video, Check, Upload,
} from "lucide-react";
import {
getHeritageSections,
patchHeritageSection,
deleteHeritageSection,
reorderHeritageSections,
createHeritageStub,
} from "./actions";
import { useHqUi } from "@/components/hq/Toast";
interface SectionRow {
id: string;
type: string; // "text" | "image" | "video"
title: string | null;
content: string | null;
mediaUrl: string | null;
order: number;
translationsJson: string | null;
}
const TYPE_META: Record<string, { label: string; icon: typeof ImageIcon; color: string }> = {
text: { label: "Text", icon: FileText, color: "#FFFFFF" },
image: { label: "Image", icon: ImageIcon, color: "#00F0FF" },
video: { label: "Video", icon: Video, color: "#FF6B9D" },
};
export default function HeritageManager() { export default function HeritageManager() {
const [sections, setSections] = useState<any[]>([]); const ui = useHqUi();
const [isLoading, setIsLoading] = useState(true); const [sections, setSections] = useState<SectionRow[]>([]);
const [isModalOpen, setIsModalOpen] = useState(false); const [loading, setLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false); const [savingId, setSavingId] = useState<string | null>(null);
const [savedFlash, setSavedFlash] = useState<string | null>(null);
const [editingSec, setEditingSec] = useState<any | null>(null); const [draggedId, setDraggedId] = useState<string | null>(null);
const [sectionType, setSectionType] = useState("text"); const [autoTranslate, setAutoTranslate] = useState(true);
const fetchSections = async () => { const load = useCallback(async () => {
setIsLoading(true); setLoading(true);
const res = await getHeritageSections(); const res = await getHeritageSections();
if (res.success && res.sections) setSections(res.sections); if (res.success && res.sections) setSections(res.sections as SectionRow[]);
setIsLoading(false); setLoading(false);
}, []);
useEffect(() => { load(); }, [load]);
const flashSaved = (id: string) => {
setSavedFlash(id);
setTimeout(() => setSavedFlash(null), 1500);
}; };
useEffect(() => { fetchSections(); }, []); const patch = async (id: string, fields: Partial<SectionRow>) => {
setEvents(id, fields);
const openCreateModal = () => { setSavingId(id);
setEditingSec(null); await patchHeritageSection(id, { ...fields, autoTranslate });
setSectionType("text"); setSavingId(null);
setIsModalOpen(true); flashSaved(id);
}; };
const openEditModal = (sec: any) => { const setEvents = (id: string, fields: Partial<SectionRow>) => {
setEditingSec(sec); setSections((prev) => prev.map((s) => (s.id === id ? { ...s, ...fields } : s)));
setSectionType(sec.type);
setIsModalOpen(true);
}; };
const handleSave = async (e: React.FormEvent<HTMLFormElement>) => { const handleAdd = async (type: "text" | "image" | "video") => {
e.preventDefault(); const res = await createHeritageStub(type);
setIsSubmitting(true); if (res.success) await load();
const formData = new FormData(e.currentTarget);
if (editingSec) {
await updateHeritageSection(formData);
} else {
await createHeritageSection(formData);
}
setIsModalOpen(false);
fetchSections();
setIsSubmitting(false);
}; };
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
if (confirm("Remove this section from the Heritage page?")) { const ok = await ui.confirm({
await deleteHeritageSection(id); fetchSections(); title: "Delete section",
} message: "Permanently remove this section from the heritage page. This cannot be undone.",
confirmLabel: "Delete section",
destructive: true,
});
if (!ok) return;
await deleteHeritageSection(id);
ui.toast("Section deleted.", "success");
await load();
};
// Drag-drop reorder
const onDragStart = (id: string) => setDraggedId(id);
const onDragOver = (e: React.DragEvent) => e.preventDefault();
const onDrop = async (targetId: string) => {
if (!draggedId || draggedId === targetId) return;
const ids = sections.map((s) => s.id);
const fromIdx = ids.indexOf(draggedId);
const toIdx = ids.indexOf(targetId);
if (fromIdx < 0 || toIdx < 0) return;
const reordered = [...ids];
reordered.splice(fromIdx, 1);
reordered.splice(toIdx, 0, draggedId);
setSections((prev) =>
reordered.map((id, i) => ({ ...prev.find((s) => s.id === id)!, order: i }))
);
setDraggedId(null);
await reorderHeritageSections(reordered);
}; };
return ( return (
<div className="min-h-screen p-6 md:p-12 max-w-7xl mx-auto"> <div className="min-h-screen p-6 md:p-12 max-w-5xl mx-auto">
<div className="mb-10"> <Link
<Link href="/hq-command/dashboard" className="inline-flex items-center gap-2 text-sm text-[#86868B] hover:text-white transition-colors mb-6 group"> href="/hq-command/dashboard"
<ArrowLeft size={16} className="group-hover:-translate-x-1 transition-transform" /> Back to Command Center className="inline-flex items-center gap-2 text-sm text-[#86868B] hover:text-white mb-6 transition-colors"
</Link> >
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-6"> <ArrowLeft size={14} /> Back to Dashboard
<div> </Link>
<h1 className="text-3xl font-light text-white flex items-center gap-3">
<BookOpen className="text-white" /> The FLUX Heritage <div className="flex flex-col md:flex-row md:items-end justify-between gap-4 mb-8">
</h1> <div>
<p className="text-[#86868B] mt-2">Build Patrizio's deep story page block by block (Text, Images, Video).</p> <div className="flex items-center gap-2 text-white mb-2">
<BookOpen size={16} />
<span className="text-[10px] uppercase tracking-widest font-bold">Our Heritage</span>
</div> </div>
<button onClick={openCreateModal} className="flex items-center gap-2 bg-white text-black px-5 py-2.5 rounded-xl font-medium hover:bg-gray-200 transition-all"> <h1 className="text-3xl md:text-4xl font-light text-white">
<Plus size={18} /> Add Content Block Patrizio's <span className="font-medium">Story.</span>
</h1>
<p className="text-[#86868B] mt-2 text-sm">
Build the page block by block. Drag to reorder. Edits auto-save.
</p>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => handleAdd("text")}
className="flex items-center gap-2 bg-white/10 text-white border border-white/10 px-3 py-2 rounded-lg text-xs font-medium hover:bg-white/15 transition-colors"
>
<FileText size={13} /> Text
</button>
<button
onClick={() => handleAdd("image")}
className="flex items-center gap-2 bg-[#00F0FF]/10 text-[#00F0FF] border border-[#00F0FF]/15 px-3 py-2 rounded-lg text-xs font-medium hover:bg-[#00F0FF]/20 transition-colors"
>
<ImageIcon size={13} /> Image
</button>
<button
onClick={() => handleAdd("video")}
className="flex items-center gap-2 bg-[#FF6B9D]/10 text-[#FF6B9D] border border-[#FF6B9D]/15 px-3 py-2 rounded-lg text-xs font-medium hover:bg-[#FF6B9D]/20 transition-colors"
>
<Video size={13} /> Video
</button> </button>
</div> </div>
</div> </div>
<div className="space-y-4"> <label className="flex items-center gap-2 mb-6 text-xs text-[#86868B] cursor-pointer">
{isLoading ? ( <input
<div className="py-12 text-center text-[#86868B]"><Loader2 className="animate-spin mx-auto mb-2" size={24} /> Loading...</div> type="checkbox"
) : sections.length === 0 ? ( checked={autoTranslate}
<div className="p-12 text-center border border-white/10 rounded-3xl bg-black/20 text-[#86868B]">The Heritage page is currently empty. Add the first text block.</div> onChange={(e) => setAutoTranslate(e.target.checked)}
) : ( className="accent-[#00F0FF]"
sections.map((sec) => ( />
<div key={sec.id} className="bg-[#111] border border-white/5 p-6 rounded-2xl flex items-start justify-between group hover:bg-white/[0.02] transition-colors shadow-lg"> <Sparkles size={12} className="text-[#00F0FF]" />
<div className="flex gap-4 w-full"> Auto-translate edits to IT, VEC, ES, DE
<div className="mt-1 text-[#86868B] shrink-0"> </label>
{sec.type === 'text' ? <FileText size={20}/> : sec.type === 'image' ? <ImageIcon size={20}/> : <Video size={20}/>}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-[10px] uppercase tracking-widest text-white/50 border border-white/10 px-2 py-0.5 rounded bg-black/40">Order: {sec.order}</span>
<span className="text-sm font-medium text-white">{sec.title || "Untitled Block"}</span>
</div>
{sec.content && <p className="text-xs text-[#86868B] max-w-2xl line-clamp-2 mt-2 leading-relaxed">{sec.content}</p>}
{sec.mediaUrl && (
<p className="text-xs text-[#00F0FF] mt-2 font-mono">
{sec.type === 'video' ? `/heritage/videos/${sec.mediaUrl}` : `/heritage/${sec.mediaUrl}`}
</p>
)}
</div>
</div>
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-all shrink-0 ml-4">
<button onClick={() => openEditModal(sec)} className="text-[#86868B] hover:text-white p-2 bg-white/5 rounded-lg"><Edit3 size={16}/></button>
<button onClick={() => handleDelete(sec.id)} className="text-[#86868B] hover:text-red-400 p-2 bg-red-500/10 rounded-lg"><Trash2 size={16}/></button>
</div>
</div>
))
)}
</div>
{isModalOpen && ( {loading ? (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"> <div className="flex items-center justify-center py-20 text-[#86868B]">
<div className="bg-[#111] border border-white/10 w-full max-w-xl rounded-[2rem] p-0 relative shadow-2xl flex flex-col max-h-[90vh]"> <Loader2 className="animate-spin mr-2" size={16} /> Loading sections…
</div>
<div className="p-6 md:p-8 border-b border-white/10 relative shrink-0"> ) : sections.length === 0 ? (
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-white to-transparent"></div> <div className="text-center py-20 text-[#86868B] bg-white/[0.02] rounded-3xl border border-white/5">
<button onClick={() => setIsModalOpen(false)} className="absolute top-6 right-6 text-[#86868B] hover:text-white transition-colors"><X size={20} /></button> <BookOpen size={32} className="mx-auto mb-3 opacity-40" />
<h3 className="text-2xl font-light text-white">{editingSec ? "Edit Story Block" : "Add Story Block"}</h3> <p>No sections yet.</p>
</div> <p className="text-xs mt-1">Add a Text, Image, or Video block above to get started.</p>
</div>
{/* 🔥 LE DAMOS UN ID AL FORMULARIO 🔥 */} ) : (
<form id="heritage-form" onSubmit={handleSave} className="flex-1 overflow-y-auto p-6 md:p-8 space-y-5 [scrollbar-width:none]"> <div className="space-y-3">
<input type="hidden" name="id" value={editingSec?.id || ""} /> {sections.map((section) => (
<SectionCard
<div className="grid grid-cols-2 gap-4"> key={section.id}
<div> section={section}
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Block Type</label> isSaving={savingId === section.id}
<select name="type" value={sectionType} onChange={(e) => setSectionType(e.target.value)} className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-white outline-none"> justSaved={savedFlash === section.id}
<option value="text">📝 Text (Markdown)</option> isDragging={draggedId === section.id}
<option value="image">🖼️ Large Image</option> onDragStart={() => onDragStart(section.id)}
<option value="video">▶️ Local Video (.mp4)</option> onDragOver={onDragOver}
</select> onDrop={() => onDrop(section.id)}
</div> onPatch={(fields) => patch(section.id, fields)}
<div> onDelete={() => handleDelete(section.id)}
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Display Order (1, 2...)</label> />
<input name="order" type="number" required defaultValue={editingSec?.order || 1} className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-white outline-none" /> ))}
</div>
</div>
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Title (Optional)</label>
<input name="title" type="text" defaultValue={editingSec?.title} placeholder="e.g., The Early Days in Italy" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-white outline-none" />
</div>
{sectionType === "text" && (
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5 flex justify-between items-center">
<span>Story Content (Markdown)</span>
</label>
<textarea name="content" defaultValue={editingSec?.content} required rows={10} className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-4 text-white text-sm focus:border-white outline-none resize-none leading-relaxed mb-3" placeholder="Write the history here..." />
<div className="bg-white/5 border border-white/10 rounded-xl p-4 text-xs text-[#86868B] font-mono leading-relaxed">
<p className="text-white font-semibold uppercase tracking-widest mb-2 text-[10px]">Markdown Cheat Sheet:</p>
<p><strong># Title 1</strong> &nbsp;|&nbsp; <strong>## Title 2</strong> &nbsp;|&nbsp; <strong>**Bold**</strong></p>
<p className="mt-1"><strong>- List Item</strong> &nbsp;|&nbsp; <strong>&gt; Blockquote</strong></p>
<p className="mt-1 text-[#00F0FF]"><strong>![Image Description](/heritage/image-name.jpg)</strong></p>
<div className="mt-3 pt-3 border-t border-white/10">
<p className="mb-1"><strong>Tables (Last column highlights automatically):</strong></p>
<p>| Year | Milestone | Innovation |</p>
<p>|---|---|---|</p>
<p>| 1980 | First Patent | Radiofrequency |</p>
</div>
</div>
</div>
)}
{sectionType !== "text" && (
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">
{sectionType === "image" ? "Image Filename (in /public/heritage/)" : "Video Filename (in /public/heritage/videos/)"}
</label>
<input name="mediaUrl" type="text" defaultValue={editingSec?.mediaUrl} required placeholder={sectionType === "image" ? "e.g., patrizio-1980.jpg" : "e.g., history-1980.mp4"} className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-[#00F0FF] font-mono text-sm focus:border-white outline-none" />
</div>
)}
{/* 🔥 SWITCH DE LA IA AÑADIDO 🔥 */}
<div className="bg-gradient-to-r from-white/10 to-transparent border border-white/20 p-4 rounded-xl flex items-center justify-between mt-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-white/20 rounded-lg text-white"><Sparkles size={18} /></div>
<div>
<p className="text-sm text-white font-medium">Flux AI Auto-Translate</p>
<p className="text-[10px] text-[#86868B] uppercase tracking-widest mt-0.5">Translates Markdown to IT, VEC, ES, DE</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input type="checkbox" name="autoTranslate" defaultChecked className="sr-only peer" />
<div className="w-11 h-6 bg-black/50 border border-white/10 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-white"></div>
</label>
</div>
</form>
<div className="p-6 border-t border-white/10 bg-[#111] rounded-b-[2rem] flex justify-end gap-3 shrink-0">
<button type="button" onClick={() => setIsModalOpen(false)} className="px-5 py-3 text-sm font-medium text-[#86868B] hover:text-white transition-colors">Cancel</button>
{/* 🔥 APUNTAMOS AL FORMULARIO 🔥 */}
<button onClick={() => (document.getElementById("heritage-form") as HTMLFormElement)?.requestSubmit()} disabled={isSubmitting} className="w-full md:w-auto bg-white text-black py-3 px-8 rounded-xl text-sm font-semibold hover:bg-gray-200 transition-colors disabled:opacity-50 flex justify-center items-center gap-2">
{isSubmitting ? <Loader2 className="animate-spin mx-auto" size={18}/> : (editingSec ? "Save Changes" : "Add to Heritage Page")}
</button>
</div>
</div>
</div> </div>
)} )}
</div> </div>
); );
} }
// ─── Section card ─────────────────────────────────────────────────
function SectionCard({
section, isSaving, justSaved, isDragging,
onDragStart, onDragOver, onDrop, onPatch, onDelete,
}: {
section: SectionRow;
isSaving: boolean;
justSaved: boolean;
isDragging: boolean;
onDragStart: () => void;
onDragOver: (e: React.DragEvent) => void;
onDrop: () => void;
onPatch: (fields: Partial<SectionRow>) => void;
onDelete: () => void;
}) {
const meta = TYPE_META[section.type] || TYPE_META.text;
const Icon = meta.icon;
return (
<div
draggable
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
className={`bg-[#111] border border-white/10 rounded-2xl p-4 transition-all ${isDragging ? "opacity-50" : ""}`}
>
<div className="flex items-center gap-3 mb-3">
<button className="cursor-grab text-[#86868B] hover:text-white p-1" title="Drag to reorder">
<GripVertical size={16} />
</button>
<div
className="flex items-center gap-2 px-3 py-1 rounded-full text-xs font-medium"
style={{ backgroundColor: `${meta.color}15`, color: meta.color, border: `1px solid ${meta.color}30` }}
>
<Icon size={12} /> {meta.label}
</div>
<input
type="text"
value={section.title || ""}
onChange={(e) => onPatch({ title: e.target.value })}
onBlur={(e) => onPatch({ title: e.target.value })}
placeholder={section.type === "text" ? "Section title (optional)" : "Caption / alt text"}
className="flex-1 bg-transparent text-white text-sm font-medium outline-none focus:bg-white/[0.04] rounded px-2 py-1 -mx-2 -my-1"
/>
<div className="flex items-center gap-2 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={onDelete}
className="p-2 rounded-lg text-rose-400 hover:bg-rose-500/10"
title="Delete section"
>
<Trash2 size={14} />
</button>
</div>
{section.type === "text" ? (
<textarea
value={section.content || ""}
onChange={(e) => onPatch({ content: e.target.value })}
onBlur={(e) => onPatch({ content: e.target.value })}
placeholder="Write the content here. Markdown supported (**bold**, # H1, ## H2, > quote, - list, | tables |)."
rows={6}
className="w-full bg-black/40 border border-white/10 text-[#E5E5EA] text-sm rounded-lg px-3 py-2 outline-none focus:border-white/30 resize-y leading-relaxed font-mono"
/>
) : (
<MediaPicker
type={section.type}
mediaUrl={section.mediaUrl}
onChange={(url) => onPatch({ mediaUrl: url })}
/>
)}
</div>
);
}
// ─── Media picker (image / video block) ───────────────────────────
function MediaPicker({
type, mediaUrl, onChange,
}: {
type: string;
mediaUrl: string | null;
onChange: (url: string) => void;
}) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const upload = async (file: File) => {
setIsUploading(true);
setError(null);
try {
// Heritage assets live under /public/heritage/[videos]/. We use the
// generic /api/assets endpoint with the cases scope as an interim
// (heritage isn't a SCOPE_ROOT yet expose it later if needed).
const fd = new FormData();
fd.append("scope", "branding"); // branding is flat, fine for heritage too
fd.append("file", file);
const res = await fetch("/api/assets", { method: "POST", body: fd });
const data = await res.json();
if (data.success) {
// Store just the filename so the public page renders /heritage/<file>
// — keeps the existing path convention.
onChange(data.file.name);
} else {
setError(data.error || "Upload failed");
}
} catch (err: any) {
setError(err.message || "Upload failed");
}
setIsUploading(false);
};
const accept = type === "image" ? "image/*" : "video/*";
const previewUrl = mediaUrl
? mediaUrl.startsWith("/") || mediaUrl.startsWith("http")
? mediaUrl
: `/heritage/${type === "video" ? "videos/" : ""}${mediaUrl}`
: null;
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept={accept}
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) upload(file);
e.target.value = "";
}}
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="inline-flex items-center gap-2 bg-[#00F0FF]/10 text-[#00F0FF] hover:bg-[#00F0FF]/20 px-3 py-2 rounded-lg text-xs font-medium transition-colors disabled:opacity-50"
>
{isUploading ? <Loader2 size={12} className="animate-spin" /> : <Upload size={12} />}
{isUploading ? "Uploading…" : "Upload"}
</button>
<input
type="text"
value={mediaUrl || ""}
onChange={(e) => onChange(e.target.value)}
placeholder={`…or paste filename (e.g. ${type === "video" ? "video.mp4" : "photo.jpg"})`}
className="flex-1 bg-black/40 border border-white/10 text-white text-xs rounded-lg px-3 py-2 outline-none focus:border-[#00F0FF]/40 font-mono"
/>
</div>
{error && <div className="text-rose-400 text-xs">{error}</div>}
{previewUrl && (
<div className="relative w-full aspect-video bg-black rounded-lg overflow-hidden border border-white/10">
{type === "image" ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={previewUrl} alt="" className="w-full h-full object-contain" />
) : (
<video src={previewUrl} controls className="w-full h-full" />
)}
</div>
)}
</div>
);
}
@@ -1,9 +1,16 @@
"use server"; "use server";
import fs from "fs";
import path from "path";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { revalidateContent } from "@/lib/revalidate"; import { revalidateContent } from "@/lib/revalidate";
import { translateContentForCMS } from "@/lib/aiTranslator"; import { translateContentForCMS } from "@/lib/aiTranslator";
const FOOTAGE_DIR = path.join(process.cwd(), "public", "footage", "main");
const FOOTAGE_PUBLIC_PREFIX = "/footage/main";
const SUPPORTED_RE = /\.(png|jpe?g|webp|mp4|webm|mov)$/i;
const VIDEO_EXT_RE = /\.(mp4|webm|mov)$/i;
export async function getHeroSlides() { export async function getHeroSlides() {
try { try {
const slides = await prisma.heroSlide.findMany({ const slides = await prisma.heroSlide.findMany({
@@ -143,3 +150,136 @@ function safeParse<T>(json: string | null | undefined, fallback: T): any {
return fallback; return fallback;
} }
} }
// ─── Bridge between filesystem footage and DB-managed HeroSlides ─────
// The site historically rendered the hero from /public/footage/main without
// a database. Files dropped there are still visible on the live site (the
// home page falls back to a filesystem scan when HeroSlide is empty), but
// the editor can't manage them — no focal point, no per-slide caption, no
// order toggle. These two actions surface the existing files in the HQ UI
// so the editor can click "Import" and bring them under DB control.
export interface FootageFile {
filename: string;
publicUrl: string;
mediaType: "image" | "video";
size: string;
bytes: number;
modifiedAt: string;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/** List footage/main files that don't yet have a corresponding HeroSlide. */
export async function listImportableFootage() {
try {
if (!fs.existsSync(FOOTAGE_DIR)) {
return { success: true, available: [], importedCount: 0 };
}
const allFiles = fs
.readdirSync(FOOTAGE_DIR)
.filter((name) => SUPPORTED_RE.test(name))
.filter((name) => !name.startsWith("."));
// Map of mediaUrl → exists in DB. We treat a slide as "already imported"
// if any HeroSlide row references the file path we'd assign here.
const existing = await prisma.heroSlide.findMany({
select: { mediaUrl: true },
});
const importedSet = new Set(existing.map((s: any) => s.mediaUrl));
const available: FootageFile[] = allFiles
.filter((filename) => !importedSet.has(`${FOOTAGE_PUBLIC_PREFIX}/${filename}`))
.map((filename): FootageFile => {
const fullPath = path.join(FOOTAGE_DIR, filename);
const stat = fs.statSync(fullPath);
const mediaType: "image" | "video" = VIDEO_EXT_RE.test(filename) ? "video" : "image";
return {
filename,
publicUrl: `${FOOTAGE_PUBLIC_PREFIX}/${filename}`,
mediaType,
size: formatBytes(stat.size),
bytes: stat.size,
modifiedAt: stat.mtime.toISOString(),
};
})
.sort((a, b) => a.filename.localeCompare(b.filename));
return {
success: true,
available,
importedCount: existing.length,
};
} catch (error: any) {
return { error: error.message || "Failed to scan footage folder." };
}
}
/** Create HeroSlide rows for the given filenames, appended after current order. */
export async function importFootageFiles(filenames: string[]) {
try {
if (!Array.isArray(filenames) || filenames.length === 0) {
return { error: "No files selected." };
}
const last = await prisma.heroSlide.findFirst({
orderBy: { order: "desc" },
select: { order: true },
});
let nextOrder = last ? last.order + 1 : 0;
const created: string[] = [];
const skipped: { filename: string; reason: string }[] = [];
for (const raw of filenames) {
// Sanity: only accept filenames (no path traversal), supported ext.
const filename = path.basename(raw);
if (!SUPPORTED_RE.test(filename)) {
skipped.push({ filename, reason: "Unsupported extension" });
continue;
}
const fullPath = path.join(FOOTAGE_DIR, filename);
if (!fs.existsSync(fullPath)) {
skipped.push({ filename, reason: "File not found on disk" });
continue;
}
const mediaUrl = `${FOOTAGE_PUBLIC_PREFIX}/${filename}`;
const already = await prisma.heroSlide.findFirst({ where: { mediaUrl } });
if (already) {
skipped.push({ filename, reason: "Already imported" });
continue;
}
// Default alt = filename without extension, dashes/underscores → spaces.
const altText = filename
.replace(/\.[^.]+$/, "")
.replace(/[-_]+/g, " ")
.replace(/^\d+\s+/, "") // strip leading "01 " ordering prefix
.trim();
await prisma.heroSlide.create({
data: {
mediaUrl,
mediaType: VIDEO_EXT_RE.test(filename) ? "video" : "image",
altText: altText || filename,
order: nextOrder,
isActive: true,
},
});
created.push(filename);
nextOrder++;
}
revalidateContent({ scope: "hero" });
revalidateContent({ scope: "footage" });
return { success: true, created, skipped };
} catch (error: any) {
return { error: error.message || "Import failed." };
}
}
+136 -3
View File
@@ -7,6 +7,7 @@ import Link from "next/link";
import { import {
ArrowLeft, Image as ImageIcon, Plus, Trash2, Loader2, GripVertical, ArrowLeft, Image as ImageIcon, Plus, Trash2, Loader2, GripVertical,
Eye, EyeOff, Crosshair, Sparkles, Upload, Check, X, ChevronDown, Eye, EyeOff, Crosshair, Sparkles, Upload, Check, X, ChevronDown,
FolderInput, Video,
} from "lucide-react"; } from "lucide-react";
import { import {
getHeroSlides, getHeroSlides,
@@ -14,7 +15,11 @@ import {
updateHeroSlide, updateHeroSlide,
deleteHeroSlide, deleteHeroSlide,
reorderHeroSlides, reorderHeroSlides,
listImportableFootage,
importFootageFiles,
type FootageFile,
} from "./actions"; } from "./actions";
import { useHqUi } from "@/components/hq/Toast";
interface SlideRow { interface SlideRow {
id: string; id: string;
@@ -34,6 +39,7 @@ function safeParseJson<T>(json: string | null | undefined, fallback: T): any {
} }
export default function HeroDashboard() { export default function HeroDashboard() {
const ui = useHqUi();
const [slides, setSlides] = useState<SlideRow[]>([]); const [slides, setSlides] = useState<SlideRow[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [savingId, setSavingId] = useState<string | null>(null); const [savingId, setSavingId] = useState<string | null>(null);
@@ -45,15 +51,48 @@ export default function HeroDashboard() {
const [draggedId, setDraggedId] = useState<string | null>(null); const [draggedId, setDraggedId] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// Files that exist in /public/footage/main but aren't in HeroSlide yet.
const [importable, setImportable] = useState<FootageFile[]>([]);
const [importPicked, setImportPicked] = useState<Set<string>>(new Set());
const [importBusy, setImportBusy] = useState(false);
const loadSlides = useCallback(async () => { const loadSlides = useCallback(async () => {
setLoading(true); setLoading(true);
const res = await getHeroSlides(); const [slidesRes, importableRes] = await Promise.all([
if (res.success && res.slides) setSlides(res.slides as SlideRow[]); getHeroSlides(),
listImportableFootage(),
]);
if (slidesRes.success && slidesRes.slides) setSlides(slidesRes.slides as SlideRow[]);
if (importableRes.success && importableRes.available) {
setImportable(importableRes.available);
setImportPicked(new Set());
}
setLoading(false); setLoading(false);
}, []); }, []);
useEffect(() => { loadSlides(); }, [loadSlides]); useEffect(() => { loadSlides(); }, [loadSlides]);
// ─── Import existing footage ────────────────────────────────────
const togglePick = (filename: string) => {
setImportPicked((prev) => {
const next = new Set(prev);
if (next.has(filename)) next.delete(filename);
else next.add(filename);
return next;
});
};
const pickAllImportable = () => {
if (importPicked.size === importable.length) setImportPicked(new Set());
else setImportPicked(new Set(importable.map((f) => f.filename)));
};
const handleImport = async (filenames: string[]) => {
if (filenames.length === 0) return;
setImportBusy(true);
await importFootageFiles(filenames);
setImportBusy(false);
await loadSlides();
};
const flashSaved = (id: string) => { const flashSaved = (id: string) => {
setSavedFlash(id); setSavedFlash(id);
setTimeout(() => setSavedFlash(null), 1500); setTimeout(() => setSavedFlash(null), 1500);
@@ -66,6 +105,7 @@ export default function HeroDashboard() {
try { try {
const fd = new FormData(); const fd = new FormData();
fd.append("scope", "footage"); fd.append("scope", "footage");
fd.append("optimize", "1");
fd.append("file", file); fd.append("file", file);
const res = await fetch("/api/assets", { method: "POST", body: fd }); const res = await fetch("/api/assets", { method: "POST", body: fd });
const data = await res.json(); const data = await res.json();
@@ -104,8 +144,15 @@ export default function HeroDashboard() {
}; };
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
if (!confirm("Delete this slide? The image file stays on disk and can be re-added.")) return; const ok = await ui.confirm({
title: "Delete slide",
message: "Removes the slide from the carousel. The image file stays on disk and can be re-imported later.",
confirmLabel: "Delete slide",
destructive: true,
});
if (!ok) return;
await deleteHeroSlide(id); await deleteHeroSlide(id);
ui.toast("Slide deleted.", "success");
await loadSlides(); await loadSlides();
}; };
@@ -194,6 +241,92 @@ export default function HeroDashboard() {
</div> </div>
</div> </div>
{/* Import existing footage panel — only shown when there are files
in /public/footage/main that aren't tracked as HeroSlide rows yet.
Lets editors bring their existing assets under DB management
without re-uploading. */}
{importable.length > 0 && (
<div className="mb-8 border border-amber-400/20 bg-gradient-to-br from-amber-400/[0.04] to-transparent rounded-2xl overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-amber-400/15">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-amber-400/15 text-amber-400 flex items-center justify-center">
<FolderInput size={16} />
</div>
<div>
<div className="text-sm font-medium text-white">
{importable.length} file{importable.length > 1 ? "s" : ""} in /public/footage/main not yet imported
</div>
<div className="text-xs text-[#86868B] leading-relaxed mt-0.5">
These are showing on the live site as fallback. Import them to manage focal point, captions and order from here.
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={pickAllImportable}
className="text-xs text-[#86868B] hover:text-white px-2 py-1.5"
>
{importPicked.size === importable.length ? "Clear" : "Select all"}
</button>
<button
onClick={() => handleImport(importable.map((f) => f.filename))}
disabled={importBusy}
className="bg-amber-400/15 text-amber-400 hover:bg-amber-400/25 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50 inline-flex items-center gap-1.5"
>
{importBusy ? <Loader2 size={12} className="animate-spin" /> : <FolderInput size={12} />}
Import all
</button>
{importPicked.size > 0 && (
<button
onClick={() => handleImport(Array.from(importPicked))}
disabled={importBusy}
className="bg-amber-400 text-black hover:bg-amber-300 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50 inline-flex items-center gap-1.5"
>
Import {importPicked.size} selected
</button>
)}
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-2 p-4">
{importable.map((f) => {
const picked = importPicked.has(f.filename);
return (
<button
key={f.filename}
onClick={() => togglePick(f.filename)}
className={`relative group rounded-lg overflow-hidden border transition-all text-left ${
picked
? "border-amber-400 ring-2 ring-amber-400/30"
: "border-white/10 hover:border-white/20"
}`}
>
<div className="aspect-video bg-black">
{f.mediaType === "image" ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={f.publicUrl} alt={f.filename} className="w-full h-full object-cover" loading="lazy" />
) : (
<div className="w-full h-full flex items-center justify-center bg-blue-500/5">
<Video size={24} className="text-blue-400/60" />
</div>
)}
</div>
<div className="p-2">
<div className="text-[10px] text-white truncate font-mono">{f.filename}</div>
<div className="text-[9px] text-[#86868B] mt-0.5">{f.size}</div>
</div>
{picked && (
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded bg-amber-400 text-black flex items-center justify-center">
<Check size={12} />
</div>
)}
</button>
);
})}
</div>
</div>
)}
{/* Slides list */} {/* Slides list */}
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-20 text-[#86868B]"> <div className="flex items-center justify-center py-20 text-[#86868B]">
+140 -28
View File
@@ -1,15 +1,18 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import Link from "next/link"; import Link from "next/link";
import { import {
ArrowLeft, Radar, Inbox, CheckCircle2, Clock, AlertCircle, ArrowLeft, Radar, Inbox, CheckCircle2, Clock, AlertCircle,
Package, Video, Sparkles, Building2, Mail, Phone, ShieldAlert, Package, Video, Sparkles, Building2, Mail, Phone, ShieldAlert,
Trash2, Loader2, Settings, X, MailCheck, MailX, Users, Key Trash2, Loader2, Settings, X, MailCheck, MailX, Users, Key, Check,
Search, MailQuestion,
} from "lucide-react"; } from "lucide-react";
import { getSignals, updateSignalStatus, resolveAndCleanSignal, getNotificationRoutes, updateNotificationRoute, deleteSignal, resendSignalEmail, getClients, approveAccessRequest, deleteClient } from "./actions"; import { getSignals, updateSignalStatus, resolveAndCleanSignal, getNotificationRoutes, updateNotificationRoute, deleteSignal, resendSignalEmail, getClients, approveAccessRequest, deleteClient } from "./actions";
import { useHqUi } from "@/components/hq/Toast";
export default function OperationsInbox() { export default function OperationsInbox() {
const ui = useHqUi();
const [viewMode, setViewMode] = useState<"SIGNALS" | "CLIENTS">("SIGNALS"); const [viewMode, setViewMode] = useState<"SIGNALS" | "CLIENTS">("SIGNALS");
const [signals, setSignals] = useState<any[]>([]); const [signals, setSignals] = useState<any[]>([]);
@@ -23,6 +26,11 @@ export default function OperationsInbox() {
const [filterType, setFilterType] = useState<string>("ALL"); const [filterType, setFilterType] = useState<string>("ALL");
const [filterStatus, setFilterStatus] = useState<string>("ALL"); const [filterStatus, setFilterStatus] = useState<string>("ALL");
const [searchQuery, setSearchQuery] = useState("");
// Auto-save routing: track per-route save state
const [routeSaveStatus, setRouteSaveStatus] = useState<Record<string, "idle" | "saving" | "saved" | "error">>({});
const debounceTimers = useRef<Record<string, NodeJS.Timeout>>({});
const fetchInitialData = async () => { const fetchInitialData = async () => {
setIsLoading(true); setIsLoading(true);
@@ -39,36 +47,121 @@ export default function OperationsInbox() {
useEffect(() => { fetchInitialData(); }, []); useEffect(() => { fetchInitialData(); }, []);
const handleSaveRoute = async (type: string, emails: string) => { await updateNotificationRoute(type, emails); alert(`Routing for ${type} updated.`); }; // Auto-save a route after 800ms of inactivity
const handleStatusChange = async (id: string, status: string) => { setIsProcessing(true); await updateSignalStatus(id, status); await fetchInitialData(); if (activeSignal?.id === id) setActiveSignal((p: any) => ({ ...p, status })); setIsProcessing(false); }; const autoSaveRoute = useCallback((type: string, emails: string) => {
const handleResolveAndClean = async (id: string) => { if (!confirm("Permanently delete attached files and mark as resolved?")) return; setIsProcessing(true); const res = await resolveAndCleanSignal(id); if (res.success) { await fetchInitialData(); setActiveSignal(null); } setIsProcessing(false); }; if (debounceTimers.current[type]) clearTimeout(debounceTimers.current[type]);
const handleDelete = async (id: string) => { if (!confirm("Permanently delete this ticket?")) return; setIsProcessing(true); await deleteSignal(id); await fetchInitialData(); setActiveSignal(null); setIsProcessing(false); }; setRouteSaveStatus(prev => ({ ...prev, [type]: "idle" }));
const handleResendEmail = async (id: string) => { setIsProcessing(true); const res = await resendSignalEmail(id); if (res.success) { alert("Email reminder sent."); await fetchInitialData(); if (activeSignal?.id === id) setActiveSignal((p: any) => ({ ...p, emailSentAt: new Date(), emailError: null })); } else { alert("Failed: " + res.error); } setIsProcessing(false); }; debounceTimers.current[type] = setTimeout(async () => {
setRouteSaveStatus(prev => ({ ...prev, [type]: "saving" }));
// APROBAR CLIENTE const res = await updateNotificationRoute(type, emails);
const handleApproveClient = async (signalId: string) => { if ((res as any)?.error) {
if (!confirm("Approve this client for B2B portal access? They will receive an email.")) return; setRouteSaveStatus(prev => ({ ...prev, [type]: "error" }));
ui.toast(`Failed to save ${type} routing: ${(res as any).error}`, "error");
} else {
setRouteSaveStatus(prev => ({ ...prev, [type]: "saved" }));
setTimeout(() => setRouteSaveStatus(prev => ({ ...prev, [type]: "idle" })), 2000);
}
}, 800);
}, [ui]);
const handleRouteChange = (type: string, emails: string) => {
setRoutes(prev => ({ ...prev, [type]: emails }));
autoSaveRoute(type, emails);
};
const handleStatusChange = async (id: string, status: string) => {
setIsProcessing(true); setIsProcessing(true);
const res = await approveAccessRequest(signalId); await updateSignalStatus(id, status);
await fetchInitialData();
if (activeSignal?.id === id) setActiveSignal((p: any) => ({ ...p, status }));
setIsProcessing(false);
};
const handleResolveAndClean = async (id: string) => {
const ok = await ui.confirm({
title: "Resolve & purge attachments",
message: "Mark this ticket as resolved and permanently delete the attached diagnostic files. This cannot be undone.",
confirmLabel: "Resolve & purge",
destructive: true,
});
if (!ok) return;
setIsProcessing(true);
const res = await resolveAndCleanSignal(id);
if (res.success) { if (res.success) {
alert("Client approved and email sent!"); ui.toast("Ticket resolved. Attachments purged.", "success");
await fetchInitialData(); await fetchInitialData();
setActiveSignal(null); setActiveSignal(null);
} else { } else {
alert("Failed: " + res.error); ui.toast("Failed to resolve ticket.", "error");
}
setIsProcessing(false);
};
const handleDelete = async (id: string) => {
const ok = await ui.confirm({
title: "Delete ticket",
message: "This permanently removes the ticket from the database. The action cannot be undone.",
confirmLabel: "Delete ticket",
destructive: true,
});
if (!ok) return;
setIsProcessing(true);
await deleteSignal(id);
ui.toast("Ticket deleted.", "success");
await fetchInitialData();
setActiveSignal(null);
setIsProcessing(false);
};
const handleResendEmail = async (id: string) => {
setIsProcessing(true);
const res = await resendSignalEmail(id);
if (res.success) {
ui.toast("Email reminder sent.", "success");
await fetchInitialData();
if (activeSignal?.id === id) setActiveSignal((p: any) => ({ ...p, emailSentAt: new Date(), emailError: null }));
} else {
ui.toast(`Resend failed: ${res.error}`, "error");
}
setIsProcessing(false);
};
// APROBAR CLIENTE
const handleApproveClient = async (signalId: string) => {
const ok = await ui.confirm({
title: "Approve B2B access",
message: "Grant this client access to the B2B portal? They'll receive an approval email immediately.",
confirmLabel: "Approve & notify",
});
if (!ok) return;
setIsProcessing(true);
const res = await approveAccessRequest(signalId);
if (res.success) {
ui.toast("Client approved. Email sent.", "success");
await fetchInitialData();
setActiveSignal(null);
} else {
ui.toast(`Approval failed: ${res.error}`, "error");
} }
setIsProcessing(false); setIsProcessing(false);
}; };
// BORRAR CLIENTE // BORRAR CLIENTE
const handleDeleteClient = async (clientId: string) => { const handleDeleteClient = async (clientId: string) => {
if (!confirm("Permanently delete this client? Their past tickets will be kept but unlinked from their account.")) return; const ok = await ui.confirm({
title: "Delete B2B client",
message: "Permanently delete this client account. Past tickets are preserved but unlinked from them.",
confirmLabel: "Delete client",
destructive: true,
});
if (!ok) return;
setIsProcessing(true); setIsProcessing(true);
const res = await deleteClient(clientId); const res = await deleteClient(clientId);
if (res.success) { if (res.success) {
ui.toast("Client deleted.", "success");
await fetchInitialData(); await fetchInitialData();
} else { } else {
alert("Failed: " + res.error); ui.toast(`Delete failed: ${res.error}`, "error");
} }
setIsProcessing(false); setIsProcessing(false);
}; };
@@ -85,6 +178,12 @@ export default function OperationsInbox() {
const filtered = signals.filter(s => { const filtered = signals.filter(s => {
if (filterType !== "ALL" && s.type !== filterType) return false; if (filterType !== "ALL" && s.type !== filterType) return false;
if (filterStatus !== "ALL" && s.status !== filterStatus) return false; if (filterStatus !== "ALL" && s.status !== filterStatus) return false;
if (searchQuery) {
const q = searchQuery.toLowerCase();
const match = [s.clientName, s.clientCompany, s.clientEmail, s.ticketId]
.some(field => field?.toLowerCase().includes(q));
if (!match) return false;
}
return true; return true;
}); });
@@ -127,6 +226,10 @@ export default function OperationsInbox() {
<button key={s} onClick={() => setFilterStatus(s)} className={`text-[9px] uppercase tracking-widest font-bold px-2 py-1 rounded-lg border transition-all ${filterStatus === s ? "bg-white/10 border-white/20 text-white" : "border-transparent text-[#86868B] hover:text-white"}`}>{s === "ALL" ? "All Status" : s}</button> <button key={s} onClick={() => setFilterStatus(s)} className={`text-[9px] uppercase tracking-widest font-bold px-2 py-1 rounded-lg border transition-all ${filterStatus === s ? "bg-white/10 border-white/20 text-white" : "border-transparent text-[#86868B] hover:text-white"}`}>{s === "ALL" ? "All Status" : s}</button>
))} ))}
</div> </div>
<div className="relative mt-3">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#86868B]" />
<input type="text" value={searchQuery} onChange={e => setSearchQuery(e.target.value)} placeholder="Search name, company, email, ticket…" className="w-full bg-white/5 border border-white/10 rounded-xl pl-9 pr-3 py-2 text-xs text-white placeholder-[#86868B]/60 outline-none focus:border-white/30 transition-colors" />
</div>
</div> </div>
<div className="flex-1 overflow-y-auto [scrollbar-width:none] p-3 space-y-2"> <div className="flex-1 overflow-y-auto [scrollbar-width:none] p-3 space-y-2">
{isLoading ? <div className="p-10 text-center text-[#86868B]"><Loader2 size={24} className="animate-spin mx-auto mb-2" /></div> {isLoading ? <div className="p-10 text-center text-[#86868B]"><Loader2 size={24} className="animate-spin mx-auto mb-2" /></div>
@@ -145,7 +248,11 @@ export default function OperationsInbox() {
<span title={signal.emailError} className="flex items-center"> <span title={signal.emailError} className="flex items-center">
<MailX size={11} className="text-red-400" /> <MailX size={11} className="text-red-400" />
</span> </span>
) : null} ) : (
<span title="No email sent" className="flex items-center">
<MailQuestion size={11} className="text-[#86868B]/50" />
</span>
)}
{getStatusIcon(signal.status)} {getStatusIcon(signal.status)}
</div> </div>
</div> </div>
@@ -292,15 +399,20 @@ export default function OperationsInbox() {
<div className="flex items-center gap-3 mb-2"><Mail className="text-rose-500" size={24} /><h3 className="text-2xl font-light text-white">Email Routing</h3></div> <div className="flex items-center gap-3 mb-2"><Mail className="text-rose-500" size={24} /><h3 className="text-2xl font-light text-white">Email Routing</h3></div>
<p className="text-sm text-[#86868B] mb-6">Configure which team members receive alerts. Separate emails with commas.</p> <p className="text-sm text-[#86868B] mb-6">Configure which team members receive alerts. Separate emails with commas.</p>
<div className="space-y-5"> <div className="space-y-5">
{[{ id: "ORDER", label: "Spare Part Orders", color: "text-amber-500" }, { id: "DIAGNOSTIC", label: "Tech Support / Diagnostics", color: "text-rose-500" }, { id: "CONSULTATION", label: "Engineering Consultations", color: "text-[#00F0FF]" }].map(route => ( {[{ id: "ORDER", label: "Spare Part Orders", color: "text-amber-500" }, { id: "DIAGNOSTIC", label: "Tech Support / Diagnostics", color: "text-rose-500" }, { id: "CONSULTATION", label: "Engineering Consultations", color: "text-[#00F0FF]" }].map(route => {
<div key={route.id} className="bg-black/40 border border-white/5 rounded-xl p-4"> const status = routeSaveStatus[route.id] || "idle";
<label className={`block text-[10px] uppercase tracking-widest font-bold mb-2 ${route.color}`}>{route.label}</label> return (
<div className="flex gap-2"> <div key={route.id} className="bg-black/40 border border-white/5 rounded-xl p-4">
<input type="text" value={(routes as any)[route.id]} onChange={e => setRoutes({...routes, [route.id]: e.target.value})} className="flex-1 bg-transparent border-b border-white/20 text-white text-sm pb-1 outline-none focus:border-white font-mono" placeholder="e.g. sales@flux.com, ceo@flux.com" /> <div className="flex justify-between items-center mb-2">
<button onClick={() => handleSaveRoute(route.id, (routes as any)[route.id])} className="text-xs bg-white/10 hover:bg-white/20 text-white px-3 py-1 rounded-lg font-medium">Save</button> <label className={`text-[10px] uppercase tracking-widest font-bold ${route.color}`}>{route.label}</label>
<span className={`text-[9px] font-medium transition-opacity duration-300 ${status === "idle" ? "opacity-0" : "opacity-100"} ${status === "saving" ? "text-[#86868B]" : status === "saved" ? "text-emerald-400" : status === "error" ? "text-red-400" : ""}`}>
{status === "saving" ? "Saving…" : status === "saved" ? "✓ Saved" : status === "error" ? "✗ Failed" : ""}
</span>
</div>
<input type="text" value={(routes as any)[route.id]} onChange={e => handleRouteChange(route.id, e.target.value)} className="w-full bg-transparent border-b border-white/20 text-white text-sm pb-1 outline-none focus:border-white font-mono" placeholder="e.g. sales@flux.com, ceo@flux.com" />
</div> </div>
</div> );
))} })}
</div> </div>
</div> </div>
</div> </div>
@@ -6,6 +6,7 @@ import { revalidatePath } from "next/cache";
// 🔥 Importamos el motor de traducción robusto // 🔥 Importamos el motor de traducción robusto
import { translateContentForCMS } from "@/lib/aiTranslator"; import { translateContentForCMS } from "@/lib/aiTranslator";
import { ensureAssetFolders, titleToSlug } from "@/lib/assetFolders";
// 1. OBTENER TODOS LOS NODOS // 1. OBTENER TODOS LOS NODOS
export async function getNodes() { export async function getNodes() {
@@ -40,6 +41,11 @@ export async function createNode(formData: FormData) {
} }
}); });
// Pre-create the asset bucket folders so the editor's first upload
// (videos, renders, gallery, datasheet, models) lands somewhere that
// already exists — no more "EACCES because the dir wasn't created".
ensureAssetFolders("cases", titleToSlug(title));
revalidatePath("/hq-command/dashboard/network"); revalidatePath("/hq-command/dashboard/network");
revalidatePath("/[locale]", "layout"); revalidatePath("/[locale]", "layout");
return { success: true }; return { success: true };
@@ -48,6 +54,19 @@ export async function createNode(formData: FormData) {
} }
} }
// 2b. REPARAR / GARANTIZAR CARPETAS DE ASSETS PARA UN NODO EXISTENTE.
// Útil para nodos creados antes de que ensureAssetFolders existiera.
export async function ensureNodeAssetFolders(id: string) {
try {
const node = await prisma.globalNode.findUnique({ where: { id }, select: { title: true } });
if (!node) return { error: "Node not found." };
ensureAssetFolders("cases", titleToSlug(node.title));
return { success: true };
} catch (error: any) {
return { error: error.message || "Failed to ensure asset folders." };
}
}
// 3. ELIMINAR UN NODO // 3. ELIMINAR UN NODO
export async function deleteNode(id: string) { export async function deleteNode(id: string) {
try { try {
+22 -215
View File
@@ -14,216 +14,11 @@ import {
import { getNodes, createNode, deleteNode, toggleNodeStatus, updateNodeCaseStudy, searchSatelliteLocation } from "./actions"; import { getNodes, createNode, deleteNode, toggleNodeStatus, updateNodeCaseStudy, searchSatelliteLocation } from "./actions";
import { getApplications } from "../applications/actions"; import { getApplications } from "../applications/actions";
// ───────────────────────────────────────────────────────────────────────────── // AssetBucketBrowser is the unified picker — single source of truth across HQ.
// ASSET MANAGER — Reusable file browser for /public/cases/{slug}/ // Aliased to AssetManager so existing JSX call sites remain untouched.
// ───────────────────────────────────────────────────────────────────────────── import AssetBucketBrowser from "@/components/hq/AssetBucketBrowser";
import { useHqUi } from "@/components/hq/Toast";
interface AssetItem { const AssetManager = AssetBucketBrowser;
name: string; type: "file" | "folder"; mediaType?: string; extension?: string;
path: string; publicUrl?: string; size?: string; childCount?: number;
}
interface AssetManagerProps {
slug: string;
scope?: string;
isOpen: boolean;
onClose: () => void;
onSelect: (item: { name: string; publicUrl: string; mediaType: string; path: string }) => void;
accentColor?: string;
initialPath?: string;
}
function AssetManager({ slug, scope = "cases", isOpen, onClose, onSelect, accentColor = "#00F0FF", initialPath = "" }: AssetManagerProps) {
const [currentPath, setCurrentPath] = useState(initialPath);
const [items, setItems] = useState<AssetItem[]>([]);
const [breadcrumbs, setBreadcrumbs] = useState<{name: string; path: string}[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [showNewFolder, setShowNewFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const [searchQuery, setSearchQuery] = useState("");
const [copiedPath, setCopiedPath] = useState<string | null>(null);
const fetchAssets = useCallback(async (dirPath: string = "") => {
setIsLoading(true); setError(null);
try {
const params = new URLSearchParams({ scope, slug, path: dirPath });
const res = await fetch(`/api/assets?${params}`);
const data = await res.json();
if (data.success) { setItems(data.items); setBreadcrumbs(data.breadcrumbs); setCurrentPath(dirPath); }
else setError(data.error || "Failed to load");
} catch { setError("Connection error — check /api/assets/route.ts"); }
setIsLoading(false);
}, [scope, slug]);
useEffect(() => { if (isOpen) { fetchAssets(initialPath || currentPath); setSearchQuery(""); } }, [isOpen]); // eslint-disable-line
const uploadFile = async (file: File) => {
setIsUploading(true); setUploadProgress(`Uploading ${file.name}...`);
try {
const fd = new FormData();
fd.append("scope", scope); fd.append("slug", slug); fd.append("path", currentPath); fd.append("file", file);
const res = await fetch("/api/assets", { method: "POST", body: fd });
const data = await res.json();
if (data.success) { setUploadProgress("✓ " + data.file.name); await fetchAssets(currentPath); setTimeout(() => setUploadProgress(""), 2000); }
else { setUploadProgress("✗ " + data.error); setTimeout(() => setUploadProgress(""), 4000); }
} catch { setUploadProgress("✗ Failed"); setTimeout(() => setUploadProgress(""), 3000); }
setIsUploading(false);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { if (e.target.files) Array.from(e.target.files).forEach(uploadFile); e.target.value = ""; };
const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); };
const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); };
const handleDrop = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); if (e.dataTransfer.files.length > 0) Array.from(e.dataTransfer.files).forEach(uploadFile); };
const createFolder = async () => {
if (!newFolderName.trim()) return;
try {
const res = await fetch("/api/assets", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, slug, folderName: newFolderName, parentPath: currentPath }) });
const data = await res.json();
if (data.success) { setShowNewFolder(false); setNewFolderName(""); await fetchAssets(currentPath); }
else alert(data.error);
} catch { alert("Connection error"); }
};
const deleteFile = async (filePath: string, fileName: string) => {
if (!confirm('Delete "' + fileName + '"?')) return;
try {
const res = await fetch("/api/assets", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, slug, filePath }) });
const data = await res.json(); if (data.success) await fetchAssets(currentPath); else alert(data.error);
} catch { alert("Failed to delete"); }
};
const handleSelect = (item: AssetItem, e?: React.MouseEvent) => {
if (e) { e.preventDefault(); e.stopPropagation(); }
if (item.type === "folder") { fetchAssets(item.path); return; }
onSelect({ name: item.name, publicUrl: item.publicUrl || "/" + scope + "/" + slug + "/" + item.path, mediaType: item.mediaType || "unknown", path: item.path });
// Don't close automatically — let user select multiple files or close manually
};
const copyPath = (item: AssetItem) => {
navigator.clipboard.writeText(item.publicUrl || "/" + scope + "/" + slug + "/" + item.path);
setCopiedPath(item.path); setTimeout(() => setCopiedPath(null), 1500);
};
const filtered = searchQuery ? items.filter(i => i.name.toLowerCase().includes(searchQuery.toLowerCase())) : items;
const typeBadge = (mt?: string) => {
const s: Record<string, string> = { image: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", video: "bg-blue-500/10 text-blue-400 border-blue-500/20", model: "bg-purple-500/10 text-purple-400 border-purple-500/20" };
return s[mt || ""] || "bg-white/5 text-[#86868B] border-white/10";
};
const renderThumb = (item: AssetItem) => {
if (item.type === "folder") return <div className="w-full h-full flex items-center justify-center bg-white/[0.02]"><FolderOpen size={28} style={{ color: accentColor, opacity: 0.7 }} /></div>;
if (item.mediaType === "image" && item.publicUrl) return <img src={item.publicUrl} alt={item.name} className="w-full h-full object-cover" loading="lazy" />;
if (item.mediaType === "video") return <div className="w-full h-full flex items-center justify-center bg-blue-500/5"><Video size={24} className="text-blue-400/60" /></div>;
if (item.mediaType === "model") return <div className="w-full h-full flex items-center justify-center bg-purple-500/5"><Box size={24} className="text-purple-400/60" /></div>;
return <div className="w-full h-full flex items-center justify-center bg-white/[0.02]"><File size={24} className="text-[#86868B]/50" /></div>;
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[70] flex items-center justify-center p-4 bg-black/85 backdrop-blur-md" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<div className="bg-[#0D0D0D] border border-white/10 w-full max-w-4xl rounded-[2rem] shadow-2xl flex flex-col max-h-[85vh] relative overflow-hidden" onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} onClick={(e) => e.stopPropagation()}>
{isDragging && (
<div className="absolute inset-0 z-50 border-2 border-dashed rounded-[2rem] flex items-center justify-center backdrop-blur-sm" style={{ borderColor: accentColor + "80", background: accentColor + "15" }}>
<div className="text-center">
<ArrowUpFromLine size={48} style={{ color: accentColor }} className="mx-auto mb-3 animate-bounce" />
<p style={{ color: accentColor }} className="font-medium text-lg">Drop files to upload</p>
<p className="text-[#86868B] text-sm mt-1">to /{scope}/{slug}/{currentPath || "root"}</p>
</div>
</div>
)}
<div className="px-6 py-5 border-b border-white/10 shrink-0">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-xl" style={{ background: accentColor + "20", color: accentColor }}><FolderOpen size={20} /></div>
<div><h3 className="text-lg font-medium text-white">Asset Manager</h3><p className="text-[10px] text-[#86868B] uppercase tracking-widest font-mono">/public/{scope}/{slug}/</p></div>
</div>
<button onClick={onClose} className="text-[#86868B] hover:text-white p-2 hover:bg-white/5 rounded-xl transition-all"><X size={20} /></button>
</div>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-1 text-sm overflow-x-auto [scrollbar-width:none] flex-1 min-w-0">
{breadcrumbs.map((crumb, idx) => (
<span key={idx} className="flex items-center shrink-0">
{idx > 0 && <ChevronRight size={12} className="text-[#86868B]/50 mx-0.5" />}
<button onClick={() => fetchAssets(crumb.path)} className={`px-2 py-1 rounded-lg transition-colors text-xs ${idx === breadcrumbs.length - 1 ? "bg-white/10" : "text-[#86868B] hover:text-white hover:bg-white/5"}`} style={idx === breadcrumbs.length - 1 ? { color: accentColor } : {}}>{crumb.name}</button>
</span>
))}
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="relative"><Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[#86868B]" /><input value={searchQuery} onChange={e => setSearchQuery(e.target.value)} placeholder="Filter..." className="bg-white/5 border border-white/10 rounded-lg pl-8 pr-3 py-1.5 text-xs text-white w-32 focus:w-48 transition-all outline-none" /></div>
<div className="flex bg-white/5 rounded-lg border border-white/10 overflow-hidden">
<button onClick={() => setViewMode("grid")} className={`p-1.5 transition-colors ${viewMode === "grid" ? "text-[#00F0FF] bg-white/10" : "text-[#86868B]"}`}><Grid3X3 size={14} /></button>
<button onClick={() => setViewMode("list")} className={`p-1.5 transition-colors ${viewMode === "list" ? "text-[#00F0FF] bg-white/10" : "text-[#86868B]"}`}><LayoutList size={14} /></button>
</div>
<button onClick={() => setShowNewFolder(!showNewFolder)} className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-[#86868B] hover:text-white bg-white/5 border border-white/10 rounded-lg"><FolderPlus size={13} /> Folder</button>
<button onClick={() => fileInputRef.current?.click()} disabled={isUploading} className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-black rounded-lg font-medium disabled:opacity-50" style={{ background: accentColor }}><Upload size={13} /> Upload</button>
<input ref={fileInputRef} type="file" multiple accept=".jpg,.jpeg,.png,.webp,.gif,.svg,.avif,.mp4,.webm,.mov,.glb,.gltf,.usdz,.pdf" onChange={handleFileSelect} className="hidden" />
</div>
</div>
{showNewFolder && (
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-white/5">
<FolderPlus size={14} style={{ color: accentColor }} />
<input value={newFolderName} onChange={e => setNewFolderName(e.target.value)} onKeyDown={e => { if (e.key === "Enter") createFolder(); if (e.key === "Escape") { setShowNewFolder(false); setNewFolderName(""); } }} placeholder="folder-name" autoFocus className="flex-1 bg-black/40 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-white outline-none font-mono" />
<button onClick={createFolder} className="px-3 py-1.5 text-xs text-black rounded-lg font-medium" style={{ background: accentColor }}>Create</button>
<button onClick={() => { setShowNewFolder(false); setNewFolderName(""); }} className="px-3 py-1.5 text-xs text-[#86868B]">Cancel</button>
</div>
)}
{uploadProgress && <div className="mt-3 pt-3 border-t border-white/5 flex items-center gap-2 text-xs">{isUploading && <Loader2 size={12} className="animate-spin" style={{ color: accentColor }} />}<span className={uploadProgress.startsWith("✓") ? "text-emerald-400" : uploadProgress.startsWith("✗") ? "text-red-400" : ""} style={isUploading ? { color: accentColor } : {}}>{uploadProgress}</span></div>}
</div>
<div className="flex-1 overflow-y-auto p-4 [scrollbar-width:none]">
{isLoading ? <div className="flex items-center justify-center py-20"><Loader2 size={24} className="animate-spin" style={{ color: accentColor }} /></div>
: error ? <div className="flex flex-col items-center justify-center py-20 text-center"><X size={32} className="text-red-400/50 mb-3" /><p className="text-red-400/80 text-sm">{error}</p></div>
: filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<FolderOpen size={48} className="text-[#86868B]/20 mb-4" />
{searchQuery ? <p className="text-[#86868B] text-sm">No matches</p> : (
<><p className="text-[#86868B] text-sm mb-2">Empty directory</p>
<div className="flex gap-2 mt-4">{["images", "videos", "models", "renders"].map(f => (
<button key={f} onClick={async () => { await fetch("/api/assets", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, slug, folderName: f, parentPath: currentPath }) }); fetchAssets(currentPath); }} className="px-3 py-2 text-xs border rounded-lg" style={{ color: accentColor, borderColor: accentColor + "30" }}>+ {f}/</button>
))}</div></>
)}
</div>
) : viewMode === "grid" ? (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3">
{filtered.map(item => (
<div key={item.path} className="group relative bg-[#111] border border-white/5 rounded-xl overflow-hidden hover:border-white/20 transition-all cursor-pointer" onClick={(e) => handleSelect(item, e)}>
<div className="aspect-square overflow-hidden bg-black/20">{renderThumb(item)}</div>
<div className="p-2"><p className="text-[11px] text-white truncate font-medium">{item.name}</p>
<div className="flex items-center justify-between mt-1">
{item.type === "folder" ? <span className="text-[9px] text-[#86868B]">{item.childCount} items</span> : <span className={`text-[9px] px-1.5 py-0.5 rounded border ${typeBadge(item.mediaType)}`}>{item.mediaType}</span>}
{item.size && <span className="text-[9px] text-[#86868B]">{item.size}</span>}
</div>
</div>
{item.type === "file" && <div className="absolute top-1 right-1 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity"><button onClick={e => { e.stopPropagation(); copyPath(item); }} className="p-1.5 bg-black/70 backdrop-blur-sm rounded-lg text-[#86868B] hover:text-white">{copiedPath === item.path ? <Check size={11} className="text-emerald-400" /> : <Copy size={11} />}</button><button onClick={e => { e.stopPropagation(); deleteFile(item.path, item.name); }} className="p-1.5 bg-black/70 backdrop-blur-sm rounded-lg text-[#86868B] hover:text-red-400"><Trash2 size={11} /></button></div>}
</div>
))}
</div>
) : (
<div className="space-y-1">{filtered.map(item => (
<div key={item.path} className="group flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-white/[0.03] cursor-pointer" onClick={(e) => handleSelect(item, e)}>
<div className="w-8 h-8 rounded-lg overflow-hidden shrink-0 border border-white/5">{renderThumb(item)}</div>
<span className="text-sm text-white flex-1 truncate">{item.name}</span>
{item.type === "file" && <span className={`text-[9px] px-2 py-0.5 rounded border shrink-0 ${typeBadge(item.mediaType)}`}>{item.extension}</span>}
{item.type === "folder" && <><span className="text-[9px] text-[#86868B]">{item.childCount} items</span><ChevronRight size={14} className="text-[#86868B]/50" /></>}
{item.size && <span className="text-[10px] text-[#86868B] w-16 text-right shrink-0">{item.size}</span>}
</div>
))}</div>
)}
</div>
<div className="px-6 py-3 border-t border-white/10 flex items-center justify-between text-[10px] text-[#86868B] shrink-0 bg-black/20"><span>{filtered.length} items Click to select</span><span className="font-mono">Drag & drop supported</span></div>
</div>
</div>
);
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// MARKDOWN EDITOR — Cyan-themed for Network/Cases // MARKDOWN EDITOR — Cyan-themed for Network/Cases
@@ -382,6 +177,7 @@ function MarkdownEditorCyan({ name, defaultValue = "", required, rows = 10, plac
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
export default function NetworkManager() { export default function NetworkManager() {
const ui = useHqUi();
const [nodes, setNodes] = useState<any[]>([]); const [nodes, setNodes] = useState<any[]>([]);
const [appsList, setAppsList] = useState<any[]>([]); const [appsList, setAppsList] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -468,7 +264,18 @@ export default function NetworkManager() {
setIsSavingCaseStudy(false); setIsSavingCaseStudy(false);
}; };
const handleDelete = async (id: string) => { if (confirm("Delete this deployment?")) { await deleteNode(id); fetchNodesAndApps(); } }; const handleDelete = async (id: string) => {
const ok = await ui.confirm({
title: "Delete deployment",
message: "Permanently remove this case from the global map. The asset folder on disk is kept for safety.",
confirmLabel: "Delete deployment",
destructive: true,
});
if (!ok) return;
await deleteNode(id);
ui.toast("Deployment deleted.", "success");
fetchNodesAndApps();
};
const handleToggle = async (id: string, status: boolean) => { await toggleNodeStatus(id, status); fetchNodesAndApps(); }; const handleToggle = async (id: string, status: boolean) => { await toggleNodeStatus(id, status); fetchNodesAndApps(); };
const availableTabs = [ const availableTabs = [
@@ -595,12 +402,12 @@ export default function NetworkManager() {
<div className={activeTab === "3d" ? "block animate-in fade-in" : "hidden"}> <div className={activeTab === "3d" ? "block animate-in fade-in" : "hidden"}>
<p className="text-xs text-[#86868B] mb-6">3D models, dimensions, and renders for the AR viewer.</p> <p className="text-xs text-[#86868B] mb-6">3D models, dimensions, and renders for the AR viewer.</p>
<div className="mb-8"> <div className="mb-8">
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5 flex items-center gap-2"><Box size={12}/> 3D Model (AR) public/cases/models</label> <label className="block text-[10px] uppercase tracking-widest text-[#A855F7] mb-1.5 flex items-center gap-2"><Box size={12}/> 3D Model (AR)</label>
<div className="flex gap-2"> <div className="flex gap-2">
<input name="model3DPath" defaultValue={editingNode.model3DPath || ""} placeholder="e.g., flxd60a.glb" className="flex-1 bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-purple-400 font-mono text-sm focus:border-purple-400 outline-none" /> <input name="model3DPath" defaultValue={editingNode.model3DPath || ""} placeholder="e.g., flxd60a.glb" className="flex-1 bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-[#A855F7] font-mono text-sm focus:border-[#A855F7] outline-none" />
<button type="button" onClick={() => { setThreeDAssetTarget("model"); setThreeDAssetsOpen(true); }} className="flex items-center gap-1.5 px-4 py-3 text-xs text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 rounded-xl hover:bg-emerald-500/20 font-medium shrink-0"><FolderOpen size={14} /> Browse</button> <button type="button" onClick={() => { setThreeDAssetTarget("model"); setThreeDAssetsOpen(true); }} className="flex items-center gap-1.5 px-4 py-3 text-xs text-[#A855F7] bg-[#A855F7]/10 border border-[#A855F7]/20 rounded-xl hover:bg-[#A855F7]/20 font-medium shrink-0"><FolderOpen size={14} /> 3D Models</button>
</div> </div>
<p className="text-[9px] text-[#86868B] mt-1.5">GLB for Android/desktop. USDZ (iOS) auto-derived.</p> <p className="text-[9px] text-[#86868B] mt-1.5">GLB for Android/desktop. USDZ (iOS) auto-derived. Files stored in the dedicated 3D Models bucket.</p>
</div> </div>
{/* DIMENSIONS PANEL */} {/* DIMENSIONS PANEL */}
+6 -1
View File
@@ -3,7 +3,8 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
// 🔥 Importamos nuestra nueva IA traductora // 🔥 Importamos nuestra nueva IA traductora
import { translateContentForCMS } from "@/lib/aiTranslator"; import { translateContentForCMS } from "@/lib/aiTranslator";
import { ensureAssetFolders } from "@/lib/assetFolders";
export async function getNewsArticles() { export async function getNewsArticles() {
try { try {
@@ -49,6 +50,10 @@ export async function createNewsArticle(formData: FormData) {
} }
}); });
// Pre-create the asset bucket folders so the editor's first upload
// lands somewhere that already exists.
ensureAssetFolders("news", slug);
revalidatePath("/news"); revalidatePath("/news");
revalidatePath("/[locale]/news", "layout"); revalidatePath("/[locale]/news", "layout");
return { success: true }; return { success: true };
+16 -176
View File
@@ -14,181 +14,11 @@ import {
} from "lucide-react"; } from "lucide-react";
import { getNewsArticles, createNewsArticle, updateNewsArticle, deleteNewsArticle } from "./actions"; import { getNewsArticles, createNewsArticle, updateNewsArticle, deleteNewsArticle } from "./actions";
// ───────────────────────────────────────────────────────────────────────────── // AssetBucketBrowser is the unified picker — single source of truth across HQ.
// ASSET MANAGER — Reusable file browser (same as Network but for news scope) // Aliased to AssetManager so existing JSX call sites remain untouched.
// ───────────────────────────────────────────────────────────────────────────── import AssetBucketBrowser from "@/components/hq/AssetBucketBrowser";
import { useHqUi } from "@/components/hq/Toast";
interface AssetItem { const AssetManager = AssetBucketBrowser;
name: string; type: "file" | "folder"; mediaType?: string; extension?: string;
path: string; publicUrl?: string; size?: string; childCount?: number;
}
interface AssetManagerProps {
slug: string; scope?: string; isOpen: boolean; onClose: () => void;
onSelect: (item: { name: string; publicUrl: string; mediaType: string; path: string }) => void;
accentColor?: string; initialPath?: string;
}
function AssetManager({ slug, scope = "news", isOpen, onClose, onSelect, accentColor = "#00F0FF", initialPath = "" }: AssetManagerProps) {
const [currentPath, setCurrentPath] = useState(initialPath);
const [items, setItems] = useState<AssetItem[]>([]);
const [breadcrumbs, setBreadcrumbs] = useState<{name: string; path: string}[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [showNewFolder, setShowNewFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const [searchQuery, setSearchQuery] = useState("");
const [copiedPath, setCopiedPath] = useState<string | null>(null);
const fetchAssets = useCallback(async (dirPath: string = "") => {
setIsLoading(true); setError(null);
try {
const params = new URLSearchParams({ scope, slug, path: dirPath });
const res = await fetch(`/api/assets?${params}`);
const data = await res.json();
if (data.success) { setItems(data.items); setBreadcrumbs(data.breadcrumbs); setCurrentPath(dirPath); }
else setError(data.error || "Failed to load");
} catch { setError("Connection error — check /api/assets/route.ts"); }
setIsLoading(false);
}, [scope, slug]);
useEffect(() => { if (isOpen) { fetchAssets(initialPath || currentPath); setSearchQuery(""); } }, [isOpen]); // eslint-disable-line
const uploadFile = async (file: File) => {
setIsUploading(true); setUploadProgress(`Uploading ${file.name}...`);
try {
const fd = new FormData();
fd.append("scope", scope); fd.append("slug", slug); fd.append("path", currentPath); fd.append("file", file);
const res = await fetch("/api/assets", { method: "POST", body: fd });
const data = await res.json();
if (data.success) { setUploadProgress("✓ " + data.file.name); await fetchAssets(currentPath); setTimeout(() => setUploadProgress(""), 2000); }
else { setUploadProgress("✗ " + data.error); setTimeout(() => setUploadProgress(""), 4000); }
} catch { setUploadProgress("✗ Failed"); setTimeout(() => setUploadProgress(""), 3000); }
setIsUploading(false);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { if (e.target.files) Array.from(e.target.files).forEach(uploadFile); e.target.value = ""; };
const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); };
const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); };
const handleDrop = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); if (e.dataTransfer.files.length > 0) Array.from(e.dataTransfer.files).forEach(uploadFile); };
const createFolder = async () => {
if (!newFolderName.trim()) return;
try {
const res = await fetch("/api/assets", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, slug, folderName: newFolderName, parentPath: currentPath }) });
const data = await res.json();
if (data.success) { setShowNewFolder(false); setNewFolderName(""); await fetchAssets(currentPath); }
else alert(data.error);
} catch { alert("Connection error"); }
};
const deleteFile = async (filePath: string, fileName: string) => {
if (!confirm('Delete "' + fileName + '"?')) return;
try {
const res = await fetch("/api/assets", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, slug, filePath }) });
const data = await res.json(); if (data.success) await fetchAssets(currentPath); else alert(data.error);
} catch { alert("Failed"); }
};
const handleSelect = (item: AssetItem, e?: React.MouseEvent) => {
if (e) { e.preventDefault(); e.stopPropagation(); }
if (item.type === "folder") { fetchAssets(item.path); return; }
onSelect({ name: item.name, publicUrl: item.publicUrl || "/" + scope + "/" + slug + "/" + item.path, mediaType: item.mediaType || "unknown", path: item.path });
//onClose();
};
const copyPath = (item: AssetItem) => {
navigator.clipboard.writeText(item.publicUrl || "/" + scope + "/" + slug + "/" + item.path);
setCopiedPath(item.path); setTimeout(() => setCopiedPath(null), 1500);
};
const filtered = searchQuery ? items.filter(i => i.name.toLowerCase().includes(searchQuery.toLowerCase())) : items;
const typeBadge = (mt?: string) => {
const s: Record<string, string> = { image: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", video: "bg-blue-500/10 text-blue-400 border-blue-500/20", model: "bg-purple-500/10 text-purple-400 border-purple-500/20" };
return s[mt || ""] || "bg-white/5 text-[#86868B] border-white/10";
};
const renderThumb = (item: AssetItem) => {
if (item.type === "folder") return <div className="w-full h-full flex items-center justify-center bg-white/[0.02]"><FolderOpen size={28} style={{ color: accentColor, opacity: 0.7 }} /></div>;
if (item.mediaType === "image" && item.publicUrl) return <img src={item.publicUrl} alt={item.name} className="w-full h-full object-cover" loading="lazy" />;
if (item.mediaType === "video") return <div className="w-full h-full flex items-center justify-center bg-blue-500/5"><Video size={24} className="text-blue-400/60" /></div>;
return <div className="w-full h-full flex items-center justify-center bg-white/[0.02]"><File size={24} className="text-[#86868B]/50" /></div>;
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[70] flex items-center justify-center p-4 bg-black/85 backdrop-blur-md" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<div className="bg-[#0D0D0D] border border-white/10 w-full max-w-4xl rounded-[2rem] shadow-2xl flex flex-col max-h-[85vh] relative overflow-hidden" onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} onClick={(e) => e.stopPropagation()}>
{isDragging && (
<div className="absolute inset-0 z-50 border-2 border-dashed rounded-[2rem] flex items-center justify-center backdrop-blur-sm" style={{ borderColor: accentColor + "80", background: accentColor + "15" }}>
<div className="text-center"><ArrowUpFromLine size={48} style={{ color: accentColor }} className="mx-auto mb-3 animate-bounce" /><p style={{ color: accentColor }} className="font-medium text-lg">Drop files to upload</p></div>
</div>
)}
<div className="px-6 py-5 border-b border-white/10 shrink-0">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3"><div className="p-2 rounded-xl" style={{ background: accentColor + "20", color: accentColor }}><FolderOpen size={20} /></div><div><h3 className="text-lg font-medium text-white">Asset Manager</h3><p className="text-[10px] text-[#86868B] uppercase tracking-widest font-mono">/public/{scope}/{slug}/</p></div></div>
<button onClick={onClose} className="text-[#86868B] hover:text-white p-2 hover:bg-white/5 rounded-xl"><X size={20} /></button>
</div>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-1 text-sm overflow-x-auto [scrollbar-width:none] flex-1 min-w-0">
{breadcrumbs.map((crumb, idx) => (<span key={idx} className="flex items-center shrink-0">{idx > 0 && <ChevronRight size={12} className="text-[#86868B]/50 mx-0.5" />}<button onClick={() => fetchAssets(crumb.path)} className={`px-2 py-1 rounded-lg text-xs ${idx === breadcrumbs.length - 1 ? "bg-white/10" : "text-[#86868B] hover:text-white hover:bg-white/5"}`} style={idx === breadcrumbs.length - 1 ? { color: accentColor } : {}}>{crumb.name}</button></span>))}
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="relative"><Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[#86868B]" /><input value={searchQuery} onChange={e => setSearchQuery(e.target.value)} placeholder="Filter..." className="bg-white/5 border border-white/10 rounded-lg pl-8 pr-3 py-1.5 text-xs text-white w-32 focus:w-48 transition-all outline-none" /></div>
<div className="flex bg-white/5 rounded-lg border border-white/10 overflow-hidden">
<button onClick={() => setViewMode("grid")} className={`p-1.5 ${viewMode === "grid" ? "text-[#00F0FF] bg-white/10" : "text-[#86868B]"}`}><Grid3X3 size={14} /></button>
<button onClick={() => setViewMode("list")} className={`p-1.5 ${viewMode === "list" ? "text-[#00F0FF] bg-white/10" : "text-[#86868B]"}`}><LayoutList size={14} /></button>
</div>
<button onClick={() => setShowNewFolder(!showNewFolder)} className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-[#86868B] hover:text-white bg-white/5 border border-white/10 rounded-lg"><FolderPlus size={13} /> Folder</button>
<button onClick={() => fileInputRef.current?.click()} disabled={isUploading} className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-black rounded-lg font-medium disabled:opacity-50" style={{ background: accentColor }}><Upload size={13} /> Upload</button>
<input ref={fileInputRef} type="file" multiple accept=".jpg,.jpeg,.png,.webp,.gif,.svg,.avif,.mp4,.webm,.mov,.pdf" onChange={handleFileSelect} className="hidden" />
</div>
</div>
{showNewFolder && (<div className="flex items-center gap-2 mt-3 pt-3 border-t border-white/5"><FolderPlus size={14} style={{ color: accentColor }} /><input value={newFolderName} onChange={e => setNewFolderName(e.target.value)} onKeyDown={e => { if (e.key === "Enter") createFolder(); if (e.key === "Escape") { setShowNewFolder(false); setNewFolderName(""); } }} placeholder="folder-name" autoFocus className="flex-1 bg-black/40 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-white outline-none font-mono" /><button onClick={createFolder} className="px-3 py-1.5 text-xs text-black rounded-lg font-medium" style={{ background: accentColor }}>Create</button><button onClick={() => { setShowNewFolder(false); setNewFolderName(""); }} className="px-3 py-1.5 text-xs text-[#86868B]">Cancel</button></div>)}
{uploadProgress && <div className="mt-3 pt-3 border-t border-white/5 flex items-center gap-2 text-xs">{isUploading && <Loader2 size={12} className="animate-spin" style={{ color: accentColor }} />}<span className={uploadProgress.startsWith("✓") ? "text-emerald-400" : uploadProgress.startsWith("✗") ? "text-red-400" : ""} style={isUploading ? { color: accentColor } : {}}>{uploadProgress}</span></div>}
</div>
<div className="flex-1 overflow-y-auto p-4 [scrollbar-width:none]">
{isLoading ? <div className="flex items-center justify-center py-20"><Loader2 size={24} className="animate-spin" style={{ color: accentColor }} /></div>
: error ? <div className="flex flex-col items-center justify-center py-20 text-center"><X size={32} className="text-red-400/50 mb-3" /><p className="text-red-400/80 text-sm">{error}</p></div>
: filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center"><FolderOpen size={48} className="text-[#86868B]/20 mb-4" />
{searchQuery ? <p className="text-[#86868B] text-sm">No matches</p> : <><p className="text-[#86868B] text-sm mb-2">Empty directory</p><div className="flex gap-2 mt-4">{["images", "gallery"].map(f => (<button key={f} onClick={async () => { await fetch("/api/assets", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, slug, folderName: f, parentPath: currentPath }) }); fetchAssets(currentPath); }} className="px-3 py-2 text-xs border rounded-lg" style={{ color: accentColor, borderColor: accentColor + "30" }}>+ {f}/</button>))}</div></>}
</div>
) : viewMode === "grid" ? (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3">
{filtered.map(item => (
<div key={item.path} className="group relative bg-[#111] border border-white/5 rounded-xl overflow-hidden hover:border-white/20 transition-all cursor-pointer" onClick={(e) => handleSelect(item, e)}>
<div className="aspect-square overflow-hidden bg-black/20">{renderThumb(item)}</div>
<div className="p-2"><p className="text-[11px] text-white truncate font-medium">{item.name}</p>
<div className="flex items-center justify-between mt-1">{item.type === "folder" ? <span className="text-[9px] text-[#86868B]">{item.childCount} items</span> : <span className={`text-[9px] px-1.5 py-0.5 rounded border ${typeBadge(item.mediaType)}`}>{item.mediaType}</span>}{item.size && <span className="text-[9px] text-[#86868B]">{item.size}</span>}</div>
</div>
{item.type === "file" && <div className="absolute top-1 right-1 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity"><button onClick={e => { e.stopPropagation(); copyPath(item); }} className="p-1.5 bg-black/70 backdrop-blur-sm rounded-lg text-[#86868B] hover:text-white">{copiedPath === item.path ? <Check size={11} className="text-emerald-400" /> : <Copy size={11} />}</button><button onClick={e => { e.stopPropagation(); deleteFile(item.path, item.name); }} className="p-1.5 bg-black/70 backdrop-blur-sm rounded-lg text-[#86868B] hover:text-red-400"><Trash2 size={11} /></button></div>}
</div>
))}
</div>
) : (
<div className="space-y-1">{filtered.map(item => (
<div key={item.path} className="group flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-white/[0.03] cursor-pointer" onClick={(e) => handleSelect(item, e)}>
<div className="w-8 h-8 rounded-lg overflow-hidden shrink-0 border border-white/5">{renderThumb(item)}</div>
<span className="text-sm text-white flex-1 truncate">{item.name}</span>
{item.type === "file" && <span className={`text-[9px] px-2 py-0.5 rounded border shrink-0 ${typeBadge(item.mediaType)}`}>{item.extension}</span>}
{item.type === "folder" && <><span className="text-[9px] text-[#86868B]">{item.childCount} items</span><ChevronRight size={14} className="text-[#86868B]/50" /></>}
{item.size && <span className="text-[10px] text-[#86868B] w-16 text-right shrink-0">{item.size}</span>}
</div>
))}</div>
)}
</div>
<div className="px-6 py-3 border-t border-white/10 flex items-center justify-between text-[10px] text-[#86868B] shrink-0 bg-black/20"><span>{filtered.length} items Click to select</span><span className="font-mono">Drag & drop</span></div>
</div>
</div>
);
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// MARKDOWN EDITOR — Cyan-themed for News articles // MARKDOWN EDITOR — Cyan-themed for News articles
@@ -324,6 +154,7 @@ function MarkdownEditorCyan({ name, defaultValue = "", required, rows = 12, plac
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
export default function NewsManager() { export default function NewsManager() {
const ui = useHqUi();
const [articles, setArticles] = useState<any[]>([]); const [articles, setArticles] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
@@ -368,7 +199,16 @@ export default function NewsManager() {
}; };
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
if (confirm("Delete this article?")) { await deleteNewsArticle(id); fetchArticles(); } const ok = await ui.confirm({
title: "Delete article",
message: "Permanently remove this news article. The asset folder on disk is preserved for safety.",
confirmLabel: "Delete article",
destructive: true,
});
if (!ok) return;
await deleteNewsArticle(id);
ui.toast("Article deleted.", "success");
fetchArticles();
}; };
return ( return (
+156 -4
View File
@@ -18,15 +18,65 @@ import {
Server, Server,
Image as ImageIcon, Image as ImageIcon,
Settings as SettingsIcon, Settings as SettingsIcon,
TrendingUp,
TrendingDown,
Minus,
MailCheck,
AlertCircle,
UserCheck,
Package,
Sparkles,
Stethoscope,
KeyRound,
} from "lucide-react"; } from "lucide-react";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { logoutAdmin } from "@/app/hq-command/login/actions"; import { logoutAdmin } from "@/app/hq-command/login/actions";
export const revalidate = 0; export const revalidate = 0;
export default async function DashboardPage() { export default async function DashboardPage() {
const nodesCount = await prisma.globalNode.count({ where: { isActive: true } }); const now = new Date();
const appsCount = await prisma.application.count({ where: { isActive: true } }); const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const sixtyDaysAgo = new Date(now.getTime() - 60 * 24 * 60 * 60 * 1000);
let nodesCount = 0, appsCount = 0;
let signalsPending = 0, signalsThisMonth = 0, signalsLastMonth = 0;
let signalsOrders = 0, signalsDiag = 0, signalsConsult = 0, signalsAccess = 0;
let emailSent = 0, emailFailed = 0;
let clientsTotal = 0, clientsApproved = 0;
try {
[
nodesCount, appsCount,
signalsPending, signalsThisMonth, signalsLastMonth,
signalsOrders, signalsDiag, signalsConsult, signalsAccess,
emailSent, emailFailed,
clientsTotal, clientsApproved,
] = await Promise.all([
prisma.globalNode.count({ where: { isActive: true } }),
prisma.application.count({ where: { isActive: true } }),
prisma.operationsSignal.count({ where: { status: "PENDING" } }),
prisma.operationsSignal.count({ where: { createdAt: { gte: thirtyDaysAgo } } }),
prisma.operationsSignal.count({ where: { createdAt: { gte: sixtyDaysAgo, lt: thirtyDaysAgo } } }),
prisma.operationsSignal.count({ where: { type: "ORDER" } }),
prisma.operationsSignal.count({ where: { type: "DIAGNOSTIC" } }),
prisma.operationsSignal.count({ where: { type: "CONSULTATION" } }),
prisma.operationsSignal.count({ where: { type: "ACCESS_REQUEST" } }),
prisma.operationsSignal.count({ where: { emailSentAt: { not: null } } }),
prisma.operationsSignal.count({ where: { emailError: { not: null }, emailSentAt: null } }),
prisma.clientUser.count(),
prisma.clientUser.count({ where: { isApproved: true } }),
]);
} catch (e) {
console.error("[dashboard] Analytics query failed:", e);
}
const signalsTotal = signalsOrders + signalsDiag + signalsConsult + signalsAccess;
const emailTotal = emailSent + emailFailed;
const emailRate = emailTotal > 0 ? Math.round((emailSent / emailTotal) * 100) : 100;
const monthTrend = signalsLastMonth > 0
? Math.round(((signalsThisMonth - signalsLastMonth) / signalsLastMonth) * 100)
: signalsThisMonth > 0 ? 100 : 0;
const modules = [ const modules = [
{ {
@@ -83,6 +133,15 @@ export default async function DashboardPage() {
bg: "bg-white/10", bg: "bg-white/10",
border: "hover:border-white/50" border: "hover:border-white/50"
}, },
{
title: "The Team",
description: "Add team members with photo, bio and social links. Drag to reorder.",
icon: Users,
href: "/hq-command/dashboard/team",
color: "text-sky-400",
bg: "bg-sky-500/10",
border: "hover:border-sky-500/50"
},
{ {
title: "Component Matrix", title: "Component Matrix",
description: "Manage the spare parts catalog, pricing, and SKUs.", description: "Manage the spare parts catalog, pricing, and SKUs.",
@@ -127,6 +186,15 @@ export default async function DashboardPage() {
color: "text-fuchsia-400", color: "text-fuchsia-400",
bg: "bg-fuchsia-500/10", bg: "bg-fuchsia-500/10",
border: "hover:border-fuchsia-500/50" border: "hover:border-fuchsia-500/50"
},
{
title: "FluxAI Conversations",
description: "Funnel analytics, top industries, and full transcripts of every chat with FluxAI.",
icon: Sparkles,
href: "/hq-command/dashboard/conversations",
color: "text-[#00F0FF]",
bg: "bg-[#00F0FF]/10",
border: "hover:border-[#00F0FF]/50"
} }
]; ];
@@ -184,6 +252,90 @@ export default async function DashboardPage() {
</div> </div>
</div> </div>
{/* ── Operations Intelligence ─────────────────────────────────────── */}
<div className="mb-6">
<span className="text-[10px] uppercase tracking-widest text-[#86868B] font-semibold">Operations Intelligence</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
{/* Signals This Month */}
<div className="bg-[#111] border border-white/5 p-5 rounded-2xl shadow-lg">
<span className="text-[10px] uppercase tracking-widest text-[#86868B] block mb-2">Signals · 30d</span>
<div className="flex items-end justify-between">
<span className="text-2xl font-medium text-white">{signalsThisMonth}</span>
{monthTrend !== 0 ? (
<span className={`flex items-center gap-1 text-xs font-medium ${monthTrend > 0 ? "text-emerald-400" : "text-rose-400"}`}>
{monthTrend > 0 ? <TrendingUp size={14} /> : <TrendingDown size={14} />}
{monthTrend > 0 ? "+" : ""}{monthTrend}%
</span>
) : (
<span className="flex items-center gap-1 text-xs text-[#86868B]"><Minus size={14} /> flat</span>
)}
</div>
</div>
{/* Pending Actions */}
<div className={`bg-[#111] border p-5 rounded-2xl shadow-lg ${signalsPending > 0 ? "border-rose-500/30" : "border-white/5"}`}>
<span className="text-[10px] uppercase tracking-widest text-[#86868B] block mb-2">Pending Actions</span>
<div className="flex items-end justify-between">
<span className={`text-2xl font-medium ${signalsPending > 0 ? "text-rose-400" : "text-emerald-400"}`}>{signalsPending}</span>
{signalsPending > 0 ? <AlertCircle size={18} className="text-rose-400/50" /> : <Activity size={18} className="text-emerald-400/30" />}
</div>
</div>
{/* Email Delivery */}
<div className="bg-[#111] border border-white/5 p-5 rounded-2xl shadow-lg">
<span className="text-[10px] uppercase tracking-widest text-[#86868B] block mb-2">Email Delivery</span>
<div className="flex items-end justify-between">
<span className="text-2xl font-medium text-white">{emailRate}%</span>
<div className="flex items-center gap-2 text-[10px] text-[#86868B]">
<span className="flex items-center gap-1"><MailCheck size={12} className="text-emerald-400" />{emailSent}</span>
{emailFailed > 0 && <span className="text-rose-400">{emailFailed} fail</span>}
</div>
</div>
</div>
{/* B2B Clients */}
<div className="bg-[#111] border border-white/5 p-5 rounded-2xl shadow-lg">
<span className="text-[10px] uppercase tracking-widest text-[#86868B] block mb-2">B2B Clients</span>
<div className="flex items-end justify-between">
<span className="text-2xl font-medium text-white">{clientsApproved}<span className="text-sm text-[#86868B] font-normal">/{clientsTotal}</span></span>
<UserCheck size={18} className="text-emerald-400/30" />
</div>
</div>
</div>
{/* Signal Type Breakdown */}
{signalsTotal > 0 && (
<div className="bg-[#111] border border-white/5 rounded-2xl p-5 mb-12 shadow-lg">
<span className="text-[10px] uppercase tracking-widest text-[#86868B] block mb-4">Signal Breakdown · All Time</span>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[
{ label: "Orders", count: signalsOrders, icon: Package, color: "text-amber-400", bg: "bg-amber-500/10", bar: "bg-amber-500" },
{ label: "Diagnostics", count: signalsDiag, icon: Stethoscope, color: "text-rose-400", bg: "bg-rose-500/10", bar: "bg-rose-500" },
{ label: "Consultations", count: signalsConsult, icon: Sparkles, color: "text-[#00F0FF]", bg: "bg-[#00F0FF]/10", bar: "bg-[#00F0FF]" },
{ label: "B2B Access", count: signalsAccess, icon: KeyRound, color: "text-emerald-400", bg: "bg-emerald-500/10", bar: "bg-emerald-500" },
].map(s => {
const pct = signalsTotal > 0 ? (s.count / signalsTotal) * 100 : 0;
return (
<div key={s.label} className={`${s.bg} rounded-xl p-4 flex flex-col gap-3`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<s.icon size={14} className={s.color} />
<span className={`text-[10px] uppercase tracking-widest font-bold ${s.color}`}>{s.label}</span>
</div>
<span className="text-lg font-medium text-white">{s.count}</span>
</div>
<div className="h-1.5 bg-black/30 rounded-full overflow-hidden">
<div className={`h-full ${s.bar} rounded-full transition-all duration-700`} style={{ width: `${pct}%` }} />
</div>
</div>
);
})}
</div>
</div>
)}
<div className="mb-6"> <div className="mb-6">
<span className="text-[10px] uppercase tracking-widest text-[#86868B] font-semibold">Core Modules</span> <span className="text-[10px] uppercase tracking-widest text-[#86868B] font-semibold">Core Modules</span>
</div> </div>
+18 -112
View File
@@ -12,117 +12,11 @@ import {
} from "lucide-react"; } from "lucide-react";
import { getParts, createPart, updatePart, deletePart, togglePartStatus, getPartsCatalogHero, updatePartsCatalogHero } from "./actions"; import { getParts, createPart, updatePart, deletePart, togglePartStatus, getPartsCatalogHero, updatePartsCatalogHero } from "./actions";
// ───────────────────────────────────────────────────────────────────────────── // AssetBucketBrowser is the unified picker — single source of truth across HQ.
// ASSET MANAGER — Reusable (scope=parts, /public/parts/{sku}/) // Aliased to AssetManager so existing JSX call sites remain untouched.
// ───────────────────────────────────────────────────────────────────────────── import AssetBucketBrowser from "@/components/hq/AssetBucketBrowser";
import { useHqUi } from "@/components/hq/Toast";
interface AssetItem { name: string; type: "file"|"folder"; mediaType?: string; extension?: string; path: string; publicUrl?: string; size?: string; childCount?: number; } const AssetManager = AssetBucketBrowser;
interface AssetManagerProps { slug: string; scope?: string; isOpen: boolean; onClose: () => void; onSelect: (item: { name: string; publicUrl: string; mediaType: string; path: string }) => void; accentColor?: string; initialPath?: string; }
function AssetManager({ slug, scope = "parts", isOpen, onClose, onSelect, accentColor = "#f59e0b", initialPath = "" }: AssetManagerProps) {
const [currentPath, setCurrentPath] = useState(initialPath);
const [items, setItems] = useState<AssetItem[]>([]);
const [breadcrumbs, setBreadcrumbs] = useState<{name: string; path: string}[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string|null>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [showNewFolder, setShowNewFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [viewMode, setViewMode] = useState<"grid"|"list">("grid");
const [searchQuery, setSearchQuery] = useState("");
const [copiedPath, setCopiedPath] = useState<string|null>(null);
const fetchAssets = useCallback(async (dirPath: string = "") => {
setIsLoading(true); setError(null);
try {
const params = new URLSearchParams({ scope, slug, path: dirPath });
const res = await fetch(`/api/assets?${params}`);
const data = await res.json();
if (data.success) { setItems(data.items); setBreadcrumbs(data.breadcrumbs); setCurrentPath(dirPath); }
else setError(data.error);
} catch { setError("Connection error"); }
setIsLoading(false);
}, [scope, slug]);
useEffect(() => { if (isOpen) { fetchAssets(initialPath || currentPath); setSearchQuery(""); } }, [isOpen]); // eslint-disable-line
const uploadFile = async (file: File) => {
setIsUploading(true); setUploadProgress(`Uploading ${file.name}...`);
try {
const fd = new FormData(); fd.append("scope", scope); fd.append("slug", slug); fd.append("path", currentPath); fd.append("file", file);
const res = await fetch("/api/assets", { method: "POST", body: fd });
const data = await res.json();
if (data.success) { setUploadProgress("✓ " + data.file.name); await fetchAssets(currentPath); setTimeout(() => setUploadProgress(""), 2000); }
else { setUploadProgress("✗ " + data.error); setTimeout(() => setUploadProgress(""), 4000); }
} catch { setUploadProgress("✗ Failed"); setTimeout(() => setUploadProgress(""), 3000); }
setIsUploading(false);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { if (e.target.files) Array.from(e.target.files).forEach(uploadFile); e.target.value = ""; };
const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); };
const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); };
const handleDrop = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); if (e.dataTransfer.files.length > 0) Array.from(e.dataTransfer.files).forEach(uploadFile); };
const createFolder = async () => { if (!newFolderName.trim()) return; try { const res = await fetch("/api/assets", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, slug, folderName: newFolderName, parentPath: currentPath }) }); const data = await res.json(); if (data.success) { setShowNewFolder(false); setNewFolderName(""); await fetchAssets(currentPath); } else alert(data.error); } catch { alert("Error"); } };
const deleteFile = async (fp: string, fn: string) => { if (!confirm('Delete "'+fn+'"?')) return; try { const res = await fetch("/api/assets", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, slug, filePath: fp }) }); const data = await res.json(); if (data.success) await fetchAssets(currentPath); else alert(data.error); } catch { alert("Failed"); } };
const handleSelect = (item: AssetItem, e?: React.MouseEvent) => {
if (e) { e.preventDefault(); e.stopPropagation(); }
if (item.type === "folder") { fetchAssets(item.path); return; }
onSelect({ name: item.name, publicUrl: item.publicUrl || "/"+scope+"/"+slug+"/"+item.path, mediaType: item.mediaType || "unknown", path: item.path });
//onClose();
};
const copyPath = (item: AssetItem) => { navigator.clipboard.writeText(item.publicUrl || "/"+scope+"/"+slug+"/"+item.path); setCopiedPath(item.path); setTimeout(() => setCopiedPath(null), 1500); };
const filtered = searchQuery ? items.filter(i => i.name.toLowerCase().includes(searchQuery.toLowerCase())) : items;
const typeBadge = (mt?: string) => ({ image: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", video: "bg-blue-500/10 text-blue-400 border-blue-500/20", model: "bg-purple-500/10 text-purple-400 border-purple-500/20" }[mt || ""] || "bg-white/5 text-[#86868B] border-white/10");
const renderThumb = (item: AssetItem) => {
if (item.type === "folder") return <div className="w-full h-full flex items-center justify-center bg-white/[0.02]"><FolderOpen size={28} style={{ color: accentColor, opacity: 0.7 }} /></div>;
if (item.mediaType === "image" && item.publicUrl) return <img src={item.publicUrl} alt={item.name} className="w-full h-full object-cover" loading="lazy" />;
return <div className="w-full h-full flex items-center justify-center bg-white/[0.02]"><File size={24} className="text-[#86868B]/50" /></div>;
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[70] flex items-center justify-center p-4 bg-black/85 backdrop-blur-md" onClick={e => { e.preventDefault(); e.stopPropagation(); }}>
<div className="bg-[#0D0D0D] border border-white/10 w-full max-w-4xl rounded-[2rem] shadow-2xl flex flex-col max-h-[85vh] relative overflow-hidden" onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} onClick={e => e.stopPropagation()}>
{isDragging && <div className="absolute inset-0 z-50 border-2 border-dashed rounded-[2rem] flex items-center justify-center backdrop-blur-sm" style={{ borderColor: accentColor+"80", background: accentColor+"15" }}><div className="text-center"><ArrowUpFromLine size={48} style={{ color: accentColor }} className="mx-auto mb-3 animate-bounce" /><p style={{ color: accentColor }} className="font-medium text-lg">Drop files</p></div></div>}
<div className="px-6 py-5 border-b border-white/10 shrink-0">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3"><div className="p-2 rounded-xl" style={{ background: accentColor+"20", color: accentColor }}><FolderOpen size={20} /></div><div><h3 className="text-lg font-medium text-white">Asset Manager</h3><p className="text-[10px] text-[#86868B] uppercase tracking-widest font-mono">/public/{scope}/{slug}/</p></div></div>
<button onClick={onClose} className="text-[#86868B] hover:text-white p-2 hover:bg-white/5 rounded-xl"><X size={20} /></button>
</div>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-1 text-sm overflow-x-auto [scrollbar-width:none] flex-1 min-w-0">{breadcrumbs.map((c, i) => (<span key={i} className="flex items-center shrink-0">{i > 0 && <ChevronRight size={12} className="text-[#86868B]/50 mx-0.5" />}<button onClick={() => fetchAssets(c.path)} className={`px-2 py-1 rounded-lg text-xs ${i === breadcrumbs.length-1 ? "bg-white/10" : "text-[#86868B] hover:text-white hover:bg-white/5"}`} style={i === breadcrumbs.length-1 ? { color: accentColor } : {}}>{c.name}</button></span>))}</div>
<div className="flex items-center gap-2 shrink-0">
<div className="relative"><Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[#86868B]" /><input value={searchQuery} onChange={e => setSearchQuery(e.target.value)} placeholder="Filter..." className="bg-white/5 border border-white/10 rounded-lg pl-8 pr-3 py-1.5 text-xs text-white w-32 focus:w-48 transition-all outline-none" /></div>
<button onClick={() => setShowNewFolder(!showNewFolder)} className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-[#86868B] hover:text-white bg-white/5 border border-white/10 rounded-lg"><FolderPlus size={13} /> Folder</button>
<button onClick={() => fileInputRef.current?.click()} disabled={isUploading} className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-black rounded-lg font-medium disabled:opacity-50" style={{ background: accentColor }}><Upload size={13} /> Upload</button>
<input ref={fileInputRef} type="file" multiple accept=".jpg,.jpeg,.png,.webp,.gif,.svg,.avif,.pdf" onChange={handleFileSelect} className="hidden" />
</div>
</div>
{showNewFolder && <div className="flex items-center gap-2 mt-3 pt-3 border-t border-white/5"><FolderPlus size={14} style={{ color: accentColor }} /><input value={newFolderName} onChange={e => setNewFolderName(e.target.value)} onKeyDown={e => { if (e.key === "Enter") createFolder(); if (e.key === "Escape") { setShowNewFolder(false); setNewFolderName(""); } }} placeholder="folder-name" autoFocus className="flex-1 bg-black/40 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-white outline-none font-mono" /><button onClick={createFolder} className="px-3 py-1.5 text-xs text-black rounded-lg font-medium" style={{ background: accentColor }}>Create</button><button onClick={() => { setShowNewFolder(false); setNewFolderName(""); }} className="px-3 py-1.5 text-xs text-[#86868B]">Cancel</button></div>}
{uploadProgress && <div className="mt-3 pt-3 border-t border-white/5 flex items-center gap-2 text-xs">{isUploading && <Loader2 size={12} className="animate-spin" style={{ color: accentColor }} />}<span className={uploadProgress.startsWith("✓") ? "text-emerald-400" : uploadProgress.startsWith("✗") ? "text-red-400" : ""} style={isUploading ? { color: accentColor } : {}}>{uploadProgress}</span></div>}
</div>
<div className="flex-1 overflow-y-auto p-4 [scrollbar-width:none]">
{isLoading ? <div className="flex items-center justify-center py-20"><Loader2 size={24} className="animate-spin" style={{ color: accentColor }} /></div>
: error ? <div className="text-center py-20"><X size={32} className="text-red-400/50 mx-auto mb-3" /><p className="text-red-400/80 text-sm">{error}</p></div>
: filtered.length === 0 ? <div className="text-center py-20"><FolderOpen size={48} className="text-[#86868B]/20 mx-auto mb-4" />{searchQuery ? <p className="text-[#86868B] text-sm">No matches</p> : <p className="text-[#86868B] text-sm">Empty upload product photos here</p>}</div>
: <div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3">{filtered.map(item => (
<div key={item.path} className="group relative bg-[#111] border border-white/5 rounded-xl overflow-hidden hover:border-white/20 cursor-pointer" onClick={e => handleSelect(item, e)}>
<div className="aspect-square overflow-hidden bg-black/20">{renderThumb(item)}</div>
<div className="p-2"><p className="text-[11px] text-white truncate font-medium">{item.name}</p><div className="flex items-center justify-between mt-1">{item.type === "folder" ? <span className="text-[9px] text-[#86868B]">{item.childCount} items</span> : <span className={`text-[9px] px-1.5 py-0.5 rounded border ${typeBadge(item.mediaType)}`}>{item.mediaType}</span>}{item.size && <span className="text-[9px] text-[#86868B]">{item.size}</span>}</div></div>
{item.type === "file" && <div className="absolute top-1 right-1 flex gap-1 opacity-0 group-hover:opacity-100"><button onClick={e => { e.stopPropagation(); copyPath(item); }} className="p-1.5 bg-black/70 rounded-lg text-[#86868B] hover:text-white">{copiedPath === item.path ? <Check size={11} className="text-emerald-400" /> : <Copy size={11} />}</button><button onClick={e => { e.stopPropagation(); deleteFile(item.path, item.name); }} className="p-1.5 bg-black/70 rounded-lg text-[#86868B] hover:text-red-400"><Trash2 size={11} /></button></div>}
</div>
))}</div>}
</div>
<div className="px-6 py-3 border-t border-white/10 flex items-center justify-between text-[10px] text-[#86868B] shrink-0 bg-black/20"><span>{filtered.length} items Click to select</span><span className="font-mono">Drag & drop</span></div>
</div>
</div>
);
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// MARKDOWN EDITOR — Amber-themed for Parts descriptions // MARKDOWN EDITOR — Amber-themed for Parts descriptions
@@ -205,6 +99,7 @@ function MarkdownEditorAmber({ name, defaultValue = "", required, rows = 8, plac
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
export default function PartsManager() { export default function PartsManager() {
const ui = useHqUi();
const [parts, setParts] = useState<any[]>([]); const [parts, setParts] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isCreateOpen, setIsCreateOpen] = useState(false); const [isCreateOpen, setIsCreateOpen] = useState(false);
@@ -296,7 +191,18 @@ export default function PartsManager() {
<td className="p-6"><p className="font-medium text-white">{part.title}</p><p className="text-xs text-amber-400/70 font-mono mt-1">{part.sku}</p></td> <td className="p-6"><p className="font-medium text-white">{part.title}</p><p className="text-xs text-amber-400/70 font-mono mt-1">{part.sku}</p></td>
<td className="p-6">{part.showPrice && part.price ? <span className="text-white font-mono">{part.price.toFixed(2)}</span> : <span className="text-[9px] text-[#86868B] uppercase tracking-widest">Quote Based</span>}</td> <td className="p-6">{part.showPrice && part.price ? <span className="text-white font-mono">{part.price.toFixed(2)}</span> : <span className="text-[9px] text-[#86868B] uppercase tracking-widest">Quote Based</span>}</td>
<td className="p-6"><button onClick={async () => { await togglePartStatus(part.id, part.isActive); fetchInitialData(); }} className={`text-xs font-medium px-3 py-1 rounded-full flex items-center gap-1.5 ${part.isActive ? 'bg-emerald-500/10 text-emerald-400' : 'bg-red-500/10 text-red-400'}`}>{part.isActive ? <><Eye size={12} /> Active</> : <><EyeOff size={12} /> Hidden</>}</button></td> <td className="p-6"><button onClick={async () => { await togglePartStatus(part.id, part.isActive); fetchInitialData(); }} className={`text-xs font-medium px-3 py-1 rounded-full flex items-center gap-1.5 ${part.isActive ? 'bg-emerald-500/10 text-emerald-400' : 'bg-red-500/10 text-red-400'}`}>{part.isActive ? <><Eye size={12} /> Active</> : <><EyeOff size={12} /> Hidden</>}</button></td>
<td className="p-6 text-right"><div className="flex justify-end gap-2"><button onClick={() => openEdit(part)} className="text-[#86868B] hover:text-amber-400 hover:bg-amber-400/10 p-2 rounded-lg opacity-0 group-hover:opacity-100"><Edit3 size={18} /></button><button onClick={async () => { if (confirm("Delete?")) { await deletePart(part.id); fetchInitialData(); } }} className="text-[#86868B] hover:text-red-400 hover:bg-red-400/10 p-2 rounded-lg opacity-0 group-hover:opacity-100"><Trash2 size={18} /></button></div></td> <td className="p-6 text-right"><div className="flex justify-end gap-2"><button onClick={() => openEdit(part)} className="text-[#86868B] hover:text-amber-400 hover:bg-amber-400/10 p-2 rounded-lg opacity-0 group-hover:opacity-100"><Edit3 size={18} /></button><button onClick={async () => {
const ok = await ui.confirm({
title: "Delete component",
message: `Permanently remove "${part.title}" (SKU ${part.sku}) from the catalog. This cannot be undone.`,
confirmLabel: "Delete component",
destructive: true,
});
if (!ok) return;
await deletePart(part.id);
ui.toast("Component deleted.", "success");
fetchInitialData();
}} className="text-[#86868B] hover:text-red-400 hover:bg-red-400/10 p-2 rounded-lg opacity-0 group-hover:opacity-100"><Trash2 size={18} /></button></div></td>
</tr> </tr>
))} ))}
</tbody></table></div></div> </tbody></table></div></div>
+148 -7
View File
@@ -170,16 +170,11 @@ function BrandingTab({
field. Changes appear on the live site within 60 seconds, no rebuild needed. field. Changes appear on the live site within 60 seconds, no rebuild needed.
</Tip> </Tip>
<ImageField <FaviconMasterField />
label="Favicon"
helper="PNG, square, transparent background. Minimum 512×512. Auto-resized for tabs and bookmarks."
value={value.faviconUrl}
onChange={(url) => onChange({ ...value, faviconUrl: url })}
/>
<ImageField <ImageField
label="Apple Touch Icon" label="Apple Touch Icon"
helper="PNG, 180×180. Shown when users add the site to their iPhone home screen." helper="Auto-generated from the master favicon above. Override here only if you want a different image for iOS home screens."
value={value.appleTouchIconUrl} value={value.appleTouchIconUrl}
onChange={(url) => onChange({ ...value, appleTouchIconUrl: url })} onChange={(url) => onChange({ ...value, appleTouchIconUrl: url })}
/> />
@@ -275,6 +270,21 @@ function FooterTab({
<TextField label="Country" value={value.hqCountry} onChange={(v) => onChange({ ...value, hqCountry: v })} /> <TextField label="Country" value={value.hqCountry} onChange={(v) => onChange({ ...value, hqCountry: v })} />
</FieldGroup> </FieldGroup>
<FieldGroup title="Headquarters contact">
<TextField
label="Sales email"
value={value.hqEmail}
onChange={(v) => onChange({ ...value, hqEmail: v })}
placeholder="sales@lethepowerflux.com"
/>
<TextField
label="Phone (international format)"
value={value.hqPhone}
onChange={(v) => onChange({ ...value, hqPhone: v })}
placeholder="+39 0424 287 492"
/>
</FieldGroup>
<FieldGroup title="Legal"> <FieldGroup title="Legal">
<TextField label="Copyright holder" value={value.copyrightHolder} onChange={(v) => onChange({ ...value, copyrightHolder: v })} /> <TextField label="Copyright holder" value={value.copyrightHolder} onChange={(v) => onChange({ ...value, copyrightHolder: v })} />
<TextField label="Privacy policy URL" value={value.privacyUrl} onChange={(v) => onChange({ ...value, privacyUrl: v })} /> <TextField label="Privacy policy URL" value={value.privacyUrl} onChange={(v) => onChange({ ...value, privacyUrl: v })} />
@@ -389,6 +399,7 @@ function ImageField({
try { try {
const fd = new FormData(); const fd = new FormData();
fd.append("scope", "branding"); fd.append("scope", "branding");
fd.append("optimize", "1");
fd.append("file", file); fd.append("file", file);
const res = await fetch("/api/assets", { method: "POST", body: fd }); const res = await fetch("/api/assets", { method: "POST", body: fd });
const data = await res.json(); const data = await res.json();
@@ -479,3 +490,133 @@ function SaveButton({
</div> </div>
); );
} }
// ─── Favicon master field ────────────────────────────────────────
// One upload → six PNG variants. Calls /api/branding/favicon which
// uses sharp to resize the source into 16/32/48/180/192/512 PNGs and
// drops them under /public/branding/. The root layout auto-detects
// the variant set and emits the right <link rel="icon"> tags.
function FaviconMasterField() {
const fileInputRef = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<{
master?: string;
variants?: { url: string; size: number; description: string; bytes: number }[];
warnings?: string[];
error?: string;
} | null>(null);
// Cache buster: appended to the master preview so re-uploads are visible.
const [cacheBust, setCacheBust] = useState(() => Date.now());
const upload = async (file: File) => {
setBusy(true);
setResult(null);
try {
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/branding/favicon", { method: "POST", body: fd });
const data = await res.json();
if (data.success) {
setResult({
master: data.master,
variants: data.variants,
warnings: data.warnings,
});
setCacheBust(Date.now());
} else {
setResult({ error: data.error || "Upload failed" });
}
} catch (err: any) {
setResult({ error: err.message || "Upload failed" });
}
setBusy(false);
};
return (
<div className="bg-gradient-to-br from-[#00F0FF]/[0.04] to-transparent border border-[#00F0FF]/20 rounded-2xl p-5">
<div className="flex items-start gap-4 mb-4">
<div className="w-10 h-10 rounded-xl bg-[#00F0FF]/10 text-[#00F0FF] flex items-center justify-center flex-shrink-0">
<ImageIcon size={18} />
</div>
<div className="flex-1">
<div className="text-sm font-medium text-white mb-1">Brand master image all favicon sizes</div>
<div className="text-xs text-[#86868B] leading-relaxed">
Upload <strong>one square PNG</strong> at minimum 512×512 (transparent background recommended).
We&apos;ll generate every size browsers, iOS, Android and Windows look for
16×16, 32×32, 48×48, 180×180, 192×192, 512×512 and wire them all into the page head.
Re-upload anytime to refresh.
</div>
</div>
</div>
<div className="flex flex-col md:flex-row md:items-center gap-4">
<div className="w-32 h-32 bg-black rounded-xl overflow-hidden border border-white/10 flex items-center justify-center flex-shrink-0">
{result?.master ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={`${result.master}?v=${cacheBust}`} alt="Favicon master" className="w-full h-full object-contain" />
) : (
<ImageIcon size={32} className="text-[#86868B]/40" />
)}
</div>
<div className="flex-1 space-y-2">
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) upload(file);
e.target.value = "";
}}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={busy}
className="inline-flex items-center gap-2 bg-[#00F0FF] text-black hover:bg-[#00F0FF]/80 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
>
{busy ? <Loader2 size={14} className="animate-spin" /> : <Upload size={14} />}
{busy ? "Generating variants…" : "Upload master image"}
</button>
{result?.error && (
<div className="text-rose-400 text-xs">{result.error}</div>
)}
{result?.warnings && result.warnings.length > 0 && (
<div className="text-amber-400 text-xs space-y-1">
{result.warnings.map((w, i) => (
<div key={i} className="flex items-start gap-1.5">
<Info size={11} className="mt-0.5 flex-shrink-0" /> <span>{w}</span>
</div>
))}
</div>
)}
{result?.variants && result.variants.length > 0 && (
<div className="bg-black/40 border border-white/10 rounded-lg p-3 mt-3">
<div className="text-[10px] uppercase tracking-widest text-emerald-400 font-bold mb-2 flex items-center gap-1.5">
<Check size={11} /> {result.variants.length} variants generated
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{result.variants.map((v) => (
<div key={v.url} className="bg-white/[0.02] border border-white/5 rounded p-2 flex items-center gap-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={`${v.url}?v=${cacheBust}`} alt="" className="w-8 h-8 object-contain bg-black rounded" />
<div className="min-w-0">
<div className="text-[11px] text-white font-mono">{v.size}×{v.size}</div>
<div className="text-[9px] text-[#86868B] truncate">{v.description}</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}
@@ -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" };
}
}
+365
View File
@@ -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 &amp; 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 &amp; 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>
);
}
@@ -97,6 +97,95 @@ export async function deleteTimelineEvent(id: string) {
} }
} }
// 4b. PATCH GRANULAR — for inline auto-save in the new UI.
// Only the fields present on `patch` are written, so the editor can update
// title/year/description independently as they tab between fields without
// having to push the whole row back through the formData-style action.
export async function patchTimelineEvent(
id: string,
patch: {
year?: string;
title?: string;
description?: string;
order?: number;
isActive?: boolean;
autoTranslate?: boolean;
}
) {
try {
if (!id) return { error: "Missing id" };
const data: any = {};
if (patch.year !== undefined) data.year = patch.year;
if (patch.title !== undefined) data.title = patch.title;
if (patch.description !== undefined) data.description = patch.description;
if (patch.order !== undefined) data.order = patch.order;
if (patch.isActive !== undefined) data.isActive = patch.isActive;
// Re-run AI translation when text fields change AND the editor opted in.
if (patch.autoTranslate && (patch.title !== undefined || patch.description !== undefined)) {
const existing = await prisma.timelineEvent.findUnique({ where: { id } });
const finalTitle = patch.title ?? existing?.title ?? "";
const finalDesc = patch.description ?? existing?.description ?? "";
const aiResult = await translateContentForCMS({ title: finalTitle, description: finalDesc });
if (aiResult) data.translationsJson = JSON.stringify(aiResult);
}
await prisma.timelineEvent.update({ where: { id }, data });
revalidatePath("/hq-command/dashboard/timeline");
revalidatePath("/[locale]", "layout");
return { success: true };
} catch (error: any) {
return { error: error.message || "Failed to update milestone." };
}
}
// 4c. REORDENAR — drag-drop in the UI commits a new ordering for every row
// in a single transaction.
export async function reorderTimelineEvents(orderedIds: string[]) {
try {
await prisma.$transaction(
orderedIds.map((id, idx) =>
prisma.timelineEvent.update({ where: { id }, data: { order: idx } })
)
);
revalidatePath("/hq-command/dashboard/timeline");
revalidatePath("/[locale]", "layout");
return { success: true };
} catch (error: any) {
return { error: error.message || "Failed to reorder." };
}
}
// 4d. CREAR HITO MÍNIMO — for the new UI's "+ Add" flow.
// The full create flow with AI is still exposed via createTimelineEvent;
// this one just inserts a stub that the editor immediately fills in via
// patchTimelineEvent as they type.
export async function createTimelineStub() {
try {
const last = await prisma.timelineEvent.findFirst({
orderBy: { order: "desc" },
select: { order: true },
});
const nextOrder = last ? last.order + 1 : 0;
const event = await prisma.timelineEvent.create({
data: {
year: new Date().getFullYear().toString(),
title: "New milestone",
description: "",
order: nextOrder,
isActive: true,
},
});
revalidatePath("/hq-command/dashboard/timeline");
return { success: true, event };
} catch (error: any) {
return { error: error.message || "Failed to create milestone." };
}
}
// 5. LA SEMILLA // 5. LA SEMILLA
export async function seedTimeline() { export async function seedTimeline() {
try { try {
+229 -176
View File
@@ -1,209 +1,262 @@
"use client"; "use client";
import { useState, useEffect } from "react"; export const dynamic = "force-dynamic";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link"; import Link from "next/link";
import { ArrowLeft, History, Plus, Trash2, Loader2, X, DatabaseZap, Clock, Edit3, Sparkles } from "lucide-react"; import {
import { getTimelineEvents, createTimelineEvent, updateTimelineEvent, deleteTimelineEvent, seedTimeline } from "./actions"; ArrowLeft, History, Plus, Trash2, Loader2, GripVertical, Eye, EyeOff,
Sparkles, Check, DatabaseZap, Clock,
} from "lucide-react";
import {
getTimelineEvents,
patchTimelineEvent,
deleteTimelineEvent,
reorderTimelineEvents,
createTimelineStub,
seedTimeline,
} from "./actions";
import { useHqUi } from "@/components/hq/Toast";
interface EventRow {
id: string;
year: string;
title: string;
description: string;
order: number;
isActive: boolean;
translationsJson: string | null;
}
export default function TimelineManager() { export default function TimelineManager() {
const [events, setEvents] = useState<any[]>([]); const ui = useHqUi();
const [isLoading, setIsLoading] = useState(true); const [events, setEvents] = useState<EventRow[]>([]);
const [isSeeding, setIsSeeding] = useState(false); const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false); const [seeding, setSeeding] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false); const [savingId, setSavingId] = useState<string | null>(null);
const [editingEvent, setEditingEvent] = useState<any | null>(null); const [savedFlash, setSavedFlash] = useState<string | null>(null);
const [error, setError] = useState(""); const [draggedId, setDraggedId] = useState<string | null>(null);
const [autoTranslate, setAutoTranslate] = useState(true);
const fetchEvents = async () => { const load = useCallback(async () => {
setIsLoading(true); setLoading(true);
const res = await getTimelineEvents(); const res = await getTimelineEvents();
if (res.success && res.events) setEvents(res.events); if (res.success && res.events) setEvents(res.events as EventRow[]);
setIsLoading(false); setLoading(false);
}, []);
useEffect(() => { load(); }, [load]);
const flashSaved = (id: string) => {
setSavedFlash(id);
setTimeout(() => setSavedFlash(null), 1500);
}; };
useEffect(() => { fetchEvents(); }, []); // ─── Patch (auto-save) ──────────────────────────────────────────
const patch = async (id: string, fields: Partial<EventRow> & { autoTranslate?: boolean }) => {
const handleSeed = async () => { setEvents((prev) => prev.map((e) => (e.id === id ? { ...e, ...fields } : e)));
setIsSeeding(true); setError(""); setSavingId(id);
const res = await seedTimeline(); await patchTimelineEvent(id, { ...fields, autoTranslate });
if (res.error) setError(res.error); else await fetchEvents(); setSavingId(null);
setIsSeeding(false); flashSaved(id);
}; };
const openCreateModal = () => { const handleAdd = async () => {
setEditingEvent(null); const res = await createTimelineStub();
setIsModalOpen(true); if (res.success) await load();
};
const openEditModal = (event: any) => {
setEditingEvent(event);
setIsModalOpen(true);
};
const handleSave = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true); setError("");
const formData = new FormData(e.currentTarget);
let res;
if (editingEvent) {
res = await updateTimelineEvent(formData);
} else {
res = await createTimelineEvent(formData);
}
if (res.error) {
setError(res.error);
} else {
setIsModalOpen(false);
fetchEvents();
}
setIsSubmitting(false);
}; };
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
if (confirm("Are you sure you want to delete this historical milestone?")) { const ok = await ui.confirm({
await deleteTimelineEvent(id); title: "Delete milestone",
fetchEvents(); message: "Permanently remove this milestone from the company timeline. This cannot be undone.",
} confirmLabel: "Delete milestone",
destructive: true,
});
if (!ok) return;
await deleteTimelineEvent(id);
ui.toast("Milestone deleted.", "success");
await load();
};
const handleSeed = async () => {
setSeeding(true);
await seedTimeline();
setSeeding(false);
await load();
};
// ─── Drag-drop reorder ──────────────────────────────────────────
const onDragStart = (id: string) => setDraggedId(id);
const onDragOver = (e: React.DragEvent) => e.preventDefault();
const onDrop = async (targetId: string) => {
if (!draggedId || draggedId === targetId) return;
const ids = events.map((e) => e.id);
const fromIdx = ids.indexOf(draggedId);
const toIdx = ids.indexOf(targetId);
if (fromIdx < 0 || toIdx < 0) return;
const reordered = [...ids];
reordered.splice(fromIdx, 1);
reordered.splice(toIdx, 0, draggedId);
setEvents((prev) =>
reordered.map((id, i) => ({ ...prev.find((e) => e.id === id)!, order: i }))
);
setDraggedId(null);
await reorderTimelineEvents(reordered);
}; };
return ( return (
<div className="min-h-screen p-6 md:p-12 max-w-7xl mx-auto"> <div className="min-h-screen p-6 md:p-12 max-w-5xl mx-auto">
<Link
{/* HEADER */} href="/hq-command/dashboard"
<div className="mb-10"> className="inline-flex items-center gap-2 text-sm text-[#86868B] hover:text-amber-400 mb-6 transition-colors"
<Link href="/hq-command/dashboard" className="inline-flex items-center gap-2 text-sm text-[#86868B] hover:text-amber-400 transition-colors mb-6 group"> >
<ArrowLeft size={16} className="group-hover:-translate-x-1 transition-transform" /> Back to Command Center <ArrowLeft size={14} /> Back to Dashboard
</Link> </Link>
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
<div> <div className="flex flex-col md:flex-row md:items-end justify-between gap-4 mb-8">
<h1 className="text-3xl font-light text-white flex items-center gap-3"> <div>
<History className="text-amber-400" /> Company Legacy <div className="flex items-center gap-2 text-amber-400 mb-2">
</h1> <History size={16} />
<p className="text-[#86868B] mt-2">Manage historical milestones and the timeline of FLUX.</p> <span className="text-[10px] uppercase tracking-widest font-bold">Company Legacy</span>
</div> </div>
<h1 className="text-3xl md:text-4xl font-light text-white">
<div className="flex gap-3"> Timeline of <span className="font-medium">FLUX.</span>
{!isLoading && events.length === 0 && ( </h1>
<button onClick={handleSeed} disabled={isSeeding} className="flex items-center gap-2 bg-amber-500/10 text-amber-400 border border-amber-500/20 px-5 py-2.5 rounded-xl font-medium hover:bg-amber-500 hover:text-black transition-all"> <p className="text-[#86868B] mt-2 text-sm">
{isSeeding ? <Loader2 size={18} className="animate-spin" /> : <DatabaseZap size={18} />} Seed History Drag to reorder. Edit any field auto-saves as you tab away.
</button> </p>
)} </div>
<button onClick={openCreateModal} className="flex items-center gap-2 bg-white text-black px-5 py-2.5 rounded-xl font-medium hover:bg-gray-200 transition-all">
<Plus size={18} /> Add Milestone <div className="flex flex-wrap gap-2">
{!loading && events.length === 0 && (
<button
onClick={handleSeed}
disabled={seeding}
className="flex items-center gap-2 bg-amber-500/10 text-amber-400 border border-amber-500/20 px-4 py-2 rounded-lg text-sm font-medium hover:bg-amber-500/20 transition-colors disabled:opacity-50"
>
{seeding ? <Loader2 size={14} className="animate-spin" /> : <DatabaseZap size={14} />}
Seed defaults
</button> </button>
</div> )}
<button
onClick={handleAdd}
className="flex items-center gap-2 bg-amber-400 text-black px-4 py-2 rounded-lg text-sm font-medium hover:bg-amber-300 transition-colors"
>
<Plus size={14} /> Add milestone
</button>
</div> </div>
</div> </div>
{error && <div className="mb-8 p-4 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-sm">{error}</div>} {/* Auto-translate toggle (global, applies to every patch) */}
<label className="flex items-center gap-2 mb-6 text-xs text-[#86868B] cursor-pointer">
<input
type="checkbox"
checked={autoTranslate}
onChange={(e) => setAutoTranslate(e.target.checked)}
className="accent-amber-400"
/>
<Sparkles size={12} className="text-amber-400" />
Auto-translate edits to IT, VEC, ES, DE
</label>
{/* TIMELINE LIST */} {/* Timeline list */}
<div className="bg-[#111] border border-white/5 rounded-3xl overflow-hidden shadow-2xl p-6 md:p-10"> {loading ? (
{isLoading ? ( <div className="flex items-center justify-center py-20 text-[#86868B]">
<div className="py-12 text-center text-[#86868B]"><Loader2 className="animate-spin mx-auto mb-2" size={24} /> Loading legacy data...</div> <Loader2 className="animate-spin mr-2" size={16} /> Loading milestones
) : events.length === 0 ? ( </div>
<div className="py-12 text-center"><Clock size={48} className="mx-auto text-amber-400/30 mb-4" /><p className="text-[#86868B]">No milestones recorded yet.</p></div> ) : events.length === 0 ? (
) : ( <div className="text-center py-20 text-[#86868B] bg-white/[0.02] rounded-3xl border border-white/5">
<div className="space-y-6 relative before:absolute before:inset-0 before:ml-5 before:-translate-x-px md:before:mx-auto md:before:translate-x-0 before:h-full before:w-0.5 before:bg-gradient-to-b before:from-transparent before:via-white/10 before:to-transparent"> <Clock size={32} className="mx-auto mb-3 text-amber-400/30" />
{events.map((event) => ( <p>No milestones yet.</p>
<div key={event.id} className="relative flex items-center justify-between md:justify-normal md:odd:flex-row-reverse group is-active"> <p className="text-xs mt-1">Click "Seed defaults" to start, or "Add milestone" to write your own.</p>
{/* Timeline Dot */} </div>
<div className="flex items-center justify-center w-10 h-10 rounded-full border border-white/20 bg-[#111] text-amber-400 shadow shrink-0 md:order-1 md:group-odd:-translate-x-1/2 md:group-even:translate-x-1/2 relative z-10"> ) : (
<div className="w-3 h-3 bg-amber-400 rounded-full"></div> <div className="space-y-3">
</div> {events.map((event) => {
{/* Content Card */} const isSaving = savingId === event.id;
<div className="w-[calc(100%-4rem)] md:w-[calc(50%-3rem)] bg-black/40 border border-white/5 p-6 rounded-2xl hover:border-amber-400/30 transition-colors relative"> const justSaved = savedFlash === event.id;
{/* Botones de acción */}
<div className="absolute top-4 right-4 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => openEditModal(event)} className="text-[#86868B] hover:text-amber-400 p-1"><Edit3 size={16}/></button>
<button onClick={() => handleDelete(event.id)} className="text-[#86868B] hover:text-red-400 p-1"><Trash2 size={16}/></button>
</div>
<span className="text-amber-400 font-mono text-sm tracking-widest bg-amber-400/10 px-3 py-1 rounded-full inline-block mb-3">{event.year}</span> return (
<h4 className="text-xl text-white font-medium mb-2">{event.title}</h4> <div
<p className="text-[#86868B] text-sm leading-relaxed line-clamp-3">{event.description}</p> key={event.id}
<p className="text-[10px] text-white/20 mt-4 uppercase tracking-widest">Order: {event.order}</p> draggable
</div> onDragStart={() => onDragStart(event.id)}
</div> onDragOver={onDragOver}
))} onDrop={() => onDrop(event.id)}
</div> className={`bg-[#111] border rounded-2xl p-4 transition-all ${
)} draggedId === event.id ? "opacity-50" : ""
</div> } ${event.isActive ? "border-white/10" : "border-white/5 opacity-60"}`}
>
<div className="flex items-start gap-3">
<button className="cursor-grab text-[#86868B] hover:text-white p-1 mt-2" title="Drag to reorder">
<GripVertical size={16} />
</button>
{/* CREATE / EDIT MODAL */} <div className="flex-1 min-w-0 space-y-3">
{isModalOpen && ( <div className="flex flex-wrap items-center gap-2">
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"> <input
<div className="bg-[#111] border border-white/10 w-full max-w-3xl rounded-[2rem] p-0 relative shadow-2xl flex flex-col max-h-[90vh]"> type="text"
<div className="p-6 md:p-8 border-b border-white/10 relative shrink-0"> value={event.year}
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-amber-400 to-transparent"></div> onChange={(e) => setEvents((prev) => prev.map((x) => x.id === event.id ? { ...x, year: e.target.value } : x))}
<button onClick={() => setIsModalOpen(false)} className="absolute top-6 right-6 text-[#86868B] hover:text-white transition-colors"><X size={20} /></button> onBlur={(e) => patch(event.id, { year: e.target.value })}
<h3 className="text-2xl font-light text-amber-400"> className="bg-amber-400/10 border border-amber-400/20 text-amber-400 font-mono text-sm rounded-full px-3 py-1 outline-none focus:border-amber-400 w-28"
{editingEvent ? "Edit Milestone" : "Add New Milestone"} placeholder="Year"
</h3> />
</div> <input
type="text"
<form id="timeline-form" onSubmit={handleSave} className="flex-1 overflow-y-auto p-6 md:p-8 [scrollbar-width:none]"> value={event.title}
{editingEvent && <input type="hidden" name="id" value={editingEvent.id} />} onChange={(e) => setEvents((prev) => prev.map((x) => x.id === event.id ? { ...x, title: e.target.value } : x))}
onBlur={(e) => patch(event.id, { title: e.target.value })}
<div className="grid grid-cols-2 gap-4 mb-6"> placeholder="Milestone title"
<div> className="flex-1 bg-transparent text-white text-base font-medium outline-none focus:bg-white/[0.04] rounded px-2 py-1 -mx-2 -my-1"
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Year / Date</label> />
<input name="year" type="text" defaultValue={editingEvent?.year} required placeholder="e.g., 2026" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-amber-400 font-mono text-sm focus:border-amber-400 outline-none" />
</div>
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Timeline Position (1, 2, 3)</label>
<input name="order" type="number" defaultValue={editingEvent?.order} required placeholder="e.g., 5" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-amber-400 outline-none" />
</div>
</div>
<div className="mb-6">
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Milestone Title</label>
<input name="title" type="text" defaultValue={editingEvent?.title} required placeholder="e.g., Next-Gen E-Dryer" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-amber-400 outline-none" />
</div>
<div>
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-2 flex justify-between items-center">
<span>Description (Markdown Supported)</span>
</label>
<textarea name="description" defaultValue={editingEvent?.description} required rows={6} placeholder="Write the history here..." className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-4 text-white font-mono text-sm focus:border-amber-400 outline-none resize-none leading-relaxed mb-3" />
<div className="bg-amber-400/5 border border-amber-400/20 rounded-xl p-4 text-xs text-[#86868B] font-mono leading-relaxed">
<p className="text-amber-400 font-semibold uppercase tracking-widest mb-2 text-[10px]">Markdown Cheat Sheet:</p>
<p><strong>**Bold**</strong> &nbsp;|&nbsp; <strong>*Italic*</strong> &nbsp;|&nbsp; <strong>- List Item</strong></p>
<p className="mt-1"><strong>&gt; Blockquote</strong></p>
</div>
</div>
{/* 🔥 SWITCH DE LA IA 🔥 */} <div className="flex items-center gap-2 text-xs ml-auto">
<div className="bg-gradient-to-r from-amber-400/10 to-transparent border border-amber-400/20 p-4 rounded-xl flex items-center justify-between mt-6"> {isSaving && <Loader2 size={12} className="animate-spin text-amber-400" />}
<div className="flex items-center gap-3"> {justSaved && (
<div className="p-2 bg-amber-400/20 rounded-lg text-amber-400"><Sparkles size={18} /></div> <span className="text-emerald-400 flex items-center gap-1">
<div> <Check size={12} /> Saved
<p className="text-sm text-white font-medium">Flux AI Auto-Translate</p> </span>
<p className="text-[10px] text-[#86868B] uppercase tracking-widest mt-0.5">Translates Markdown to IT, VEC, ES, DE</p> )}
</div>
<button
onClick={() => patch(event.id, { isActive: !event.isActive })}
className={`p-2 rounded-lg transition-colors ${
event.isActive
? "text-emerald-400 hover:bg-emerald-500/10"
: "text-[#86868B] hover:bg-white/5"
}`}
title={event.isActive ? "Hide from timeline" : "Show on timeline"}
>
{event.isActive ? <Eye size={14} /> : <EyeOff size={14} />}
</button>
<button
onClick={() => handleDelete(event.id)}
className="p-2 rounded-lg text-rose-400 hover:bg-rose-500/10"
title="Delete milestone"
>
<Trash2 size={14} />
</button>
</div>
<textarea
value={event.description}
onChange={(e) => setEvents((prev) => prev.map((x) => x.id === event.id ? { ...x, description: e.target.value } : x))}
onBlur={(e) => patch(event.id, { description: e.target.value })}
placeholder="What happened in this milestone? Markdown supported (**bold**, *italic*, > quote, - list)."
rows={3}
className="w-full bg-black/40 border border-white/10 text-[#E5E5EA] text-sm rounded-lg px-3 py-2 outline-none focus:border-amber-400/40 resize-y leading-relaxed"
/>
</div> </div>
</div> </div>
<label className="relative inline-flex items-center cursor-pointer">
<input type="checkbox" name="autoTranslate" defaultChecked className="sr-only peer" />
<div className="w-11 h-6 bg-black/50 border border-white/10 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-amber-400"></div>
</label>
</div> </div>
);
</form> })}
<div className="p-6 border-t border-white/10 bg-[#111] rounded-b-[2rem] flex justify-end gap-3 shrink-0">
<button type="button" onClick={() => setIsModalOpen(false)} className="px-5 py-3 text-sm font-medium text-[#86868B] hover:text-white transition-colors">Cancel</button>
<button onClick={() => (document.getElementById("timeline-form") as HTMLFormElement)?.requestSubmit()} disabled={isSubmitting} className="flex items-center gap-2 bg-amber-400 text-black px-8 py-3 rounded-xl text-sm font-semibold hover:bg-white transition-colors disabled:opacity-50 shadow-[0_0_15px_rgba(251,191,36,0.3)]">
{isSubmitting ? <><Loader2 size={18} className="animate-spin" /> Processing AI...</> : "Publish to Legacy"}
</button>
</div>
</div>
</div> </div>
)} )}
</div> </div>
); );
} }
+14 -4
View File
@@ -5,8 +5,10 @@ import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { ArrowLeft, Users, Plus, Trash2, ShieldCheck, KeyRound, Loader2, X, Settings } from "lucide-react"; import { ArrowLeft, Users, Plus, Trash2, ShieldCheck, KeyRound, Loader2, X, Settings } from "lucide-react";
import { getUsers, createUser, deleteUser, updateUser } from "./actions"; import { getUsers, createUser, deleteUser, updateUser } from "./actions";
import { useHqUi } from "@/components/hq/Toast";
export default function UsersManager() { export default function UsersManager() {
const ui = useHqUi();
const [users, setUsers] = useState<any[]>([]); const [users, setUsers] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -74,10 +76,18 @@ export default function UsersManager() {
}; };
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
if (confirm("Are you sure you want to revoke this architect's access? This cannot be undone.")) { const ok = await ui.confirm({
const res = await deleteUser(id); title: "Revoke architect access",
if (res.error) alert(res.error); message: "Permanently remove this admin account. They will lose access to the Command Center immediately. This cannot be undone.",
else fetchUsers(); confirmLabel: "Revoke access",
destructive: true,
});
if (!ok) return;
const res = await deleteUser(id);
if (res.error) ui.toast(res.error, "error");
else {
ui.toast("Architect access revoked.", "success");
fetchUsers();
} }
}; };
+59
View File
@@ -0,0 +1,59 @@
// src/app/hq-command/icon.tsx
// ─────────────────────────────────────────────────────────────────────────────
// Programmatic favicon for the HQ Command Center.
// Uses Next.js App Router icon convention — generates a square PNG on demand,
// so no external image file is needed and it never looks stretched.
//
// Design: dark rounded square with a cyan "F" glyph, matching the command
// center's dark-mode aesthetic (#050505 bg + #00F0FF accent).
// ─────────────────────────────────────────────────────────────────────────────
import { ImageResponse } from "next/og";
export const size = { width: 32, height: 32 };
export const contentType = "image/png";
export default function Icon() {
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#0A0A0F",
borderRadius: "6px",
}}
>
{/* Subtle accent ring */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "28px",
height: "28px",
borderRadius: "5px",
border: "1.5px solid rgba(0, 240, 255, 0.3)",
background: "linear-gradient(135deg, #0A0A1A 0%, #0D1117 100%)",
}}
>
<span
style={{
color: "#00F0FF",
fontSize: "18px",
fontWeight: 900,
fontFamily: "Inter, system-ui, -apple-system, sans-serif",
lineHeight: 1,
marginTop: "-1px",
}}
>
F
</span>
</div>
</div>
),
{ ...size },
);
}
+10 -7
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import "@/app/globals.css"; import "@/app/globals.css";
import { HqUiProvider } from "@/components/hq/Toast";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "FLUX Command Center", title: "FLUX Command Center",
@@ -13,16 +14,18 @@ export default function HQLayout({ children }: { children: React.ReactNode }) {
<html lang="en" className="dark"> <html lang="en" className="dark">
{/* Mantenemos tu fondo negro absoluto, texto blanco y el color de selección cyan */} {/* Mantenemos tu fondo negro absoluto, texto blanco y el color de selección cyan */}
<body className="min-h-screen bg-[#050505] text-[#F5F5F7] antialiased selection:bg-[#00F0FF] selection:text-black"> <body className="min-h-screen bg-[#050505] text-[#F5F5F7] antialiased selection:bg-[#00F0FF] selection:text-black">
{/* Patrón de puntos sutil en el fondo para dar aspecto técnico */} {/* Patrón de puntos sutil en el fondo para dar aspecto técnico */}
<div <div
className="fixed inset-0 opacity-[0.03] pointer-events-none" className="fixed inset-0 opacity-[0.03] pointer-events-none"
style={{ backgroundImage: 'radial-gradient(circle at 2px 2px, white 1px, transparent 0)', backgroundSize: '32px 32px' }} style={{ backgroundImage: 'radial-gradient(circle at 2px 2px, white 1px, transparent 0)', backgroundSize: '32px 32px' }}
></div> ></div>
<main className="relative z-10"> <HqUiProvider>
{children} <main className="relative z-10">
</main> {children}
</main>
</HqUiProvider>
</body> </body>
</html> </html>
+28
View File
@@ -0,0 +1,28 @@
// src/app/manifest.ts
// ─────────────────────────────────────────────────────────────────────────────
// Web App Manifest. Auto-served by Next.js at /manifest.webmanifest.
// Tells Android / Chrome / Edge "this site can be added to the home screen,
// here are the icons and how to launch it standalone".
// ─────────────────────────────────────────────────────────────────────────────
import type { MetadataRoute } from "next";
import { getBranding } from "@/lib/siteSettings";
export default async function manifest(): Promise<MetadataRoute.Manifest> {
const branding = await getBranding();
return {
name: "FLUX | Energy, Directed.",
short_name: "FLUX",
description: "Advanced Radio Frequency Solutions by Patrizio Grando.",
start_url: "/",
display: "standalone",
background_color: "#F5F5F7",
theme_color: branding.themeColor,
icons: [
{ src: "/branding/favicon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
{ src: "/branding/favicon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
{ src: "/branding/favicon-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
],
};
}
+30
View File
@@ -0,0 +1,30 @@
// src/app/robots.ts
// ─────────────────────────────────────────────────────────────────────────────
// robots.txt — tells search crawlers what to index and where the sitemap lives.
// Auto-served at /robots.txt, no Nginx config needed.
// ─────────────────────────────────────────────────────────────────────────────
import type { MetadataRoute } from "next";
function baseUrl() {
return (process.env.NEXT_PUBLIC_APP_URL || "https://rf-flux.com").replace(/\/$/, "");
}
export default function robots(): MetadataRoute.Robots {
const base = baseUrl();
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: [
"/hq-command/", // Admin CMS — never index
"/api/", // Server endpoints — never index
"/parts", // B2B portal, auth-gated (also has noindex meta)
],
},
],
sitemap: `${base}/sitemap.xml`,
host: base,
};
}
+106
View File
@@ -0,0 +1,106 @@
// src/app/sitemap.ts
// ─────────────────────────────────────────────────────────────────────────────
// Dynamic sitemap generated from Prisma data — emits one entry per locale per
// active page (home, applications, news articles, heritage, news hub).
//
// Auto-discoverable at /sitemap.xml, no Nginx config needed.
// Search engines re-crawl this on each visit; Next.js caches it for `revalidate`.
// ─────────────────────────────────────────────────────────────────────────────
import type { MetadataRoute } from "next";
import { prisma } from "@/lib/prisma";
const LOCALES = ["en", "it", "vec", "es", "de"] as const;
function baseUrl() {
return (process.env.NEXT_PUBLIC_APP_URL || "https://rf-flux.com").replace(/\/$/, "");
}
export const revalidate = 3600; // Re-generate sitemap once per hour
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const base = baseUrl();
const now = new Date();
const entries: MetadataRoute.Sitemap = [];
// ── Static routes ─────────────────────────────────────────────
const staticPaths = [
{ 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) {
for (const { path, priority, changeFrequency } of staticPaths) {
entries.push({
url: `${base}/${locale}${path}`,
lastModified: now,
changeFrequency,
priority,
alternates: {
languages: Object.fromEntries(
LOCALES.map((alt) => [alt, `${base}/${alt}${path}`])
),
},
});
}
}
// ── Application pages ─────────────────────────────────────────
try {
const applications = await prisma.application.findMany({
where: { isActive: true },
select: { slug: true, updatedAt: true },
});
for (const app of applications) {
for (const locale of LOCALES) {
entries.push({
url: `${base}/${locale}/applications/${app.slug}`,
lastModified: app.updatedAt,
changeFrequency: "weekly",
priority: 0.9,
alternates: {
languages: Object.fromEntries(
LOCALES.map((alt) => [alt, `${base}/${alt}/applications/${app.slug}`])
),
},
});
}
}
} catch (error) {
console.error("[sitemap] Failed to load applications:", error);
}
// ── News articles ─────────────────────────────────────────────
try {
const articles = await prisma.newsArticle.findMany({
where: { isActive: true },
select: { slug: true, updatedAt: true, publishedAt: true },
orderBy: { publishedAt: "desc" },
});
for (const article of articles) {
for (const locale of LOCALES) {
entries.push({
url: `${base}/${locale}/news/${article.slug}`,
lastModified: article.updatedAt,
changeFrequency: "monthly",
priority: 0.7,
alternates: {
languages: Object.fromEntries(
LOCALES.map((alt) => [alt, `${base}/${alt}/news/${article.slug}`])
),
},
});
}
}
} catch (error) {
console.error("[sitemap] Failed to load news articles:", error);
}
return entries;
}
+28 -15
View File
@@ -5,6 +5,13 @@ import { MapPin, Factory, Zap, Clock, ChevronDown, ArrowRight, Globe2, Image as
import { useState } from "react"; import { useState } from "react";
import Image from "next/image"; import Image from "next/image";
// Slugify the node title the same way ApplicationClient + assetFolders do —
// keeps image paths consistent with the on-disk layout
// (/cases/<nodeSlug>/<file>).
function nodeToSlug(title: string): string {
return title.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
}
// ── Interface matches GlobalNode shape from Prisma ── // ── Interface matches GlobalNode shape from Prisma ──
interface CaseStudyData { interface CaseStudyData {
found: boolean; found: boolean;
@@ -49,8 +56,14 @@ export default function CaseStudyViewer({ data }: { data: CaseStudyData }) {
if (!data.found) return null; if (!data.found) return null;
// Defensive: ensure datasheet/gallery/videos are always arrays
const datasheet = Array.isArray(data.datasheet) ? data.datasheet : [];
const gallery = Array.isArray(data.gallery) ? data.gallery : [];
const videos = Array.isArray(data.videos) ? data.videos : [];
const accent = ACCENTS[data.industry] || ACCENTS.textile; const accent = ACCENTS[data.industry] || ACCENTS.textile;
const coverSrc = data.mediaFileName ? `/cases/${data.mediaFileName}` : null; const nodeSlug = nodeToSlug(data.title);
const coverSrc = data.mediaFileName ? `/cases/${nodeSlug}/${data.mediaFileName}` : null;
const appLabel = data.application.replace(/-/g, " ").replace(/\b\w/g, c => c.toUpperCase()); const appLabel = data.application.replace(/-/g, " ").replace(/\b\w/g, c => c.toUpperCase());
return ( return (
@@ -91,14 +104,14 @@ export default function CaseStudyViewer({ data }: { data: CaseStudyData }) {
{/* Media indicators */} {/* Media indicators */}
<div className="absolute bottom-3 right-3 flex items-center gap-1.5 z-10"> <div className="absolute bottom-3 right-3 flex items-center gap-1.5 z-10">
{data.gallery.length > 0 && ( {gallery.length > 0 && (
<div className="bg-black/40 backdrop-blur-md text-white text-[9px] px-2 py-0.5 rounded-full flex items-center gap-1"> <div className="bg-black/40 backdrop-blur-md text-white text-[9px] px-2 py-0.5 rounded-full flex items-center gap-1">
<ImageIcon size={9} /> {data.gallery.length} <ImageIcon size={9} /> {gallery.length}
</div> </div>
)} )}
{data.videos.length > 0 && ( {videos.length > 0 && (
<div className="bg-black/40 backdrop-blur-md text-white text-[9px] px-2 py-0.5 rounded-full flex items-center gap-1"> <div className="bg-black/40 backdrop-blur-md text-white text-[9px] px-2 py-0.5 rounded-full flex items-center gap-1">
<Play size={9} /> {data.videos.length} <Play size={9} /> {videos.length}
</div> </div>
)} )}
</div> </div>
@@ -125,8 +138,8 @@ export default function CaseStudyViewer({ data }: { data: CaseStudyData }) {
)} )}
<Metric icon={Clock} label="Performance" value={data.stats} /> <Metric icon={Clock} label="Performance" value={data.stats} />
<Metric icon={MapPin} label="Region" value={data.location.split(",").pop()?.trim() || data.location} /> <Metric icon={MapPin} label="Region" value={data.location.split(",").pop()?.trim() || data.location} />
{data.datasheet.length > 0 && ( {datasheet.length > 0 && (
<Metric icon={FileText} label="Specs" value={`${data.datasheet.length} parameters`} /> <Metric icon={FileText} label="Specs" value={`${datasheet.length} parameters`} />
)} )}
</div> </div>
@@ -158,21 +171,21 @@ export default function CaseStudyViewer({ data }: { data: CaseStudyData }) {
)} )}
{/* Equipment Datasheet (from specificDatasheetJson) */} {/* Equipment Datasheet (from specificDatasheetJson) */}
{data.datasheet.length > 0 && ( {datasheet.length > 0 && (
<div className="bg-white/40 dark:bg-white/[0.03] border border-white/60 dark:border-white/[0.06] rounded-xl p-3 mb-3 transition-colors"> <div className="bg-white/40 dark:bg-white/[0.03] border border-white/60 dark:border-white/[0.06] rounded-xl p-3 mb-3 transition-colors">
<span className="text-[9px] text-[#86868B] dark:text-[#A1A1A6] uppercase tracking-wider font-semibold block mb-2"> <span className="text-[9px] text-[#86868B] dark:text-[#A1A1A6] uppercase tracking-wider font-semibold block mb-2">
Equipment Specifications Equipment Specifications
</span> </span>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{data.datasheet.slice(0, 6).map((spec, i) => ( {datasheet.slice(0, 6).map((spec, i) => (
<div key={i} className="flex items-center justify-between py-1 border-b border-black/[0.03] dark:border-white/[0.04] last:border-0"> <div key={i} className="flex items-center justify-between py-1 border-b border-black/[0.03] dark:border-white/[0.04] last:border-0">
<span className="text-[10px] text-[#86868B] dark:text-[#A1A1A6]">{spec.label}</span> <span className="text-[10px] text-[#86868B] dark:text-[#A1A1A6]">{spec.label}</span>
<span className="text-[10px] font-medium text-[#1D1D1F] dark:text-[#F5F5F7]">{spec.value}</span> <span className="text-[10px] font-medium text-[#1D1D1F] dark:text-[#F5F5F7]">{spec.value}</span>
</div> </div>
))} ))}
{data.datasheet.length > 6 && ( {datasheet.length > 6 && (
<span className="text-[9px] text-[#86868B] dark:text-[#A1A1A6] text-center pt-1"> <span className="text-[9px] text-[#86868B] dark:text-[#A1A1A6] text-center pt-1">
+{data.datasheet.length - 6} more specs in full view +{datasheet.length - 6} more specs in full view
</span> </span>
)} )}
</div> </div>
@@ -180,19 +193,19 @@ export default function CaseStudyViewer({ data }: { data: CaseStudyData }) {
)} )}
{/* Gallery Preview */} {/* Gallery Preview */}
{data.gallery.length > 0 && ( {gallery.length > 0 && (
<div className="mb-3"> <div className="mb-3">
<button <button
onClick={() => setShowGallery(!showGallery)} onClick={() => setShowGallery(!showGallery)}
className="text-[10px] text-[#0066CC] dark:text-[#4DA6FF] font-medium flex items-center gap-1 mb-2" className="text-[10px] text-[#0066CC] dark:text-[#4DA6FF] font-medium flex items-center gap-1 mb-2"
> >
<ImageIcon size={10} /> {showGallery ? 'Hide' : 'Show'} Gallery ({data.gallery.length} images) <ImageIcon size={10} /> {showGallery ? 'Hide' : 'Show'} Gallery ({gallery.length} images)
</button> </button>
{showGallery && ( {showGallery && (
<div className="grid grid-cols-3 gap-1.5 rounded-lg overflow-hidden"> <div className="grid grid-cols-3 gap-1.5 rounded-lg overflow-hidden">
{data.gallery.slice(0, 6).map((img, i) => ( {gallery.slice(0, 6).map((img, i) => (
<div key={i} className="relative aspect-square bg-[#F5F5F7] dark:bg-[#1D1D1F] rounded-md overflow-hidden"> <div key={i} className="relative aspect-square bg-[#F5F5F7] dark:bg-[#1D1D1F] rounded-md overflow-hidden">
<Image src={`/cases/${img}`} alt={`Gallery ${i + 1}`} fill className="object-cover" sizes="120px" /> <Image src={`/cases/${nodeSlug}/${img}`} alt={`Gallery ${i + 1}`} fill className="object-cover" sizes="120px" />
</div> </div>
))} ))}
</div> </div>
+22 -6
View File
@@ -3,6 +3,7 @@
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { Calendar, User, Building2, Mail, Phone, MessageSquare, ArrowRight, CheckCircle2, Sparkles, ChevronDown } from "lucide-react"; import { Calendar, User, Building2, Mail, Phone, MessageSquare, ArrowRight, CheckCircle2, Sparkles, ChevronDown } from "lucide-react";
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { trackEvent } from "@/lib/analytics/gtag";
// ── Data from the AI tool execute ── // ── Data from the AI tool execute ──
interface ConsultationData { interface ConsultationData {
@@ -166,7 +167,7 @@ function SuccessView({ data, ticketId }: { data: ConsultationData; ticketId: str
<div> <div>
<p className="text-[15px] font-medium text-[#1D1D1F] dark:text-[#F5F5F7] mb-1 transition-colors"> <p className="text-[15px] font-medium text-[#1D1D1F] dark:text-[#F5F5F7] mb-1 transition-colors">
Consultation Requested Request Sent
</p> </p>
{ticketId && ( {ticketId && (
<p className="text-[11px] font-mono text-[#0066CC] dark:text-[#4DA6FF] mb-2">{ticketId}</p> <p className="text-[11px] font-mono text-[#0066CC] dark:text-[#4DA6FF] mb-2">{ticketId}</p>
@@ -187,7 +188,7 @@ function SuccessView({ data, ticketId }: { data: ConsultationData; ticketId: str
</span> </span>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{[ {[
"Engineer reviews your AI-prepared brief", "Our team reviews your AI-prepared brief",
`Custom RF analysis for your ${data.process} process`, `Custom RF analysis for your ${data.process} process`,
"Proposal with ROI projections and timeline", "Proposal with ROI projections and timeline",
].map((step, i) => ( ].map((step, i) => (
@@ -251,9 +252,18 @@ export default function ConsultationScheduler({ data }: { data: ConsultationData
}; };
try { try {
// ── Fetch a fresh CSRF token (sets the matching cookie too) ──
const csrfRes = await fetch("/api/csrf", { method: "GET", credentials: "same-origin" });
const { token: csrfToken } = (await csrfRes.json()) as { token?: string };
if (!csrfToken) throw new Error("Could not obtain CSRF token");
const res = await fetch("/api/consultation", { const res = await fetch("/api/consultation", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken,
},
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
@@ -263,6 +273,12 @@ export default function ConsultationScheduler({ data }: { data: ConsultationData
setTicketId(result.ticketId); setTicketId(result.ticketId);
setSubmitted(true); setSubmitted(true);
// GA4 conversion event — the primary funnel goal.
trackEvent({
name: "ai_consultation_submitted",
params: { industry: data.industry, ticketId: result.ticketId },
});
// Also dispatch the event for any external integrations // Also dispatch the event for any external integrations
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("flux:consultation-submitted", { detail: { ...payload, ticketId: result.ticketId } }) new CustomEvent("flux:consultation-submitted", { detail: { ...payload, ticketId: result.ticketId } })
@@ -299,11 +315,11 @@ export default function ConsultationScheduler({ data }: { data: ConsultationData
<Calendar size={13} className="text-[#0066CC] dark:text-[#4DA6FF]" /> <Calendar size={13} className="text-[#0066CC] dark:text-[#4DA6FF]" />
</div> </div>
<span className="text-[9px] font-semibold uppercase tracking-widest text-[#0066CC] dark:text-[#4DA6FF]"> <span className="text-[9px] font-semibold uppercase tracking-widest text-[#0066CC] dark:text-[#4DA6FF]">
Engineering Consultation Get in Touch
</span> </span>
</div> </div>
<p className="text-[12px] text-[#86868B] dark:text-[#A1A1A6] mb-4 leading-relaxed"> <p className="text-[12px] text-[#86868B] dark:text-[#A1A1A6] mb-4 leading-relaxed">
Your conversation details are pre-loaded. Just add your contact info. Your conversation details are pre-loaded. Just add your contact info and our team will get back to you.
</p> </p>
<InsightsCard data={data} /> <InsightsCard data={data} />
@@ -400,7 +416,7 @@ export default function ConsultationScheduler({ data }: { data: ConsultationData
Sending... Sending...
</> </>
) : ( ) : (
<>Request Consultation <ArrowRight size={14} /></> <>Send Request <ArrowRight size={14} /></>
)} )}
</motion.button> </motion.button>
+15 -5
View File
@@ -5,6 +5,12 @@ import { Cpu, ArrowRight, Settings2, ChevronDown, MapPin, Factory, Zap, Box } fr
import { useState } from "react"; import { useState } from "react";
import Image from "next/image"; import Image from "next/image";
// Same slugger as the public-facing pages so cover images resolve to the
// /cases/<nodeSlug>/<file> layout that's actually on disk.
function nodeToSlug(title: string): string {
return title.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
}
// ── Interface matches GlobalNode + specificDatasheetJson from Prisma ── // ── Interface matches GlobalNode + specificDatasheetJson from Prisma ──
interface EquipmentData { interface EquipmentData {
found: boolean; found: boolean;
@@ -39,12 +45,16 @@ export default function EquipmentConfigurator({ data }: { data: EquipmentData })
if (!data.found) return null; if (!data.found) return null;
const coverSrc = data.mediaFileName ? `/cases/${data.mediaFileName}` : null; const nodeSlug = nodeToSlug(data.title);
const coverSrc = data.mediaFileName ? `/cases/${nodeSlug}/${data.mediaFileName}` : null;
const appLabel = data.application.replace(/-/g, " ").replace(/\b\w/g, c => c.toUpperCase()); const appLabel = data.application.replace(/-/g, " ").replace(/\b\w/g, c => c.toUpperCase());
// Defensive: ensure datasheet is always an array (DB may store malformed JSON)
const datasheet = Array.isArray(data.datasheet) ? data.datasheet : [];
// Find key specs for header pills (power, frequency, model — from datasheet) // Find key specs for header pills (power, frequency, model — from datasheet)
const findSpec = (keywords: string[]) => { const findSpec = (keywords: string[]) => {
return data.datasheet.find(s => return datasheet.find(s =>
keywords.some(kw => s.label.toLowerCase().includes(kw)) keywords.some(kw => s.label.toLowerCase().includes(kw))
)?.value; )?.value;
}; };
@@ -54,8 +64,8 @@ export default function EquipmentConfigurator({ data }: { data: EquipmentData })
const modelSpec = findSpec(['model', 'modelo', 'type', 'tipo', 'series']); const modelSpec = findSpec(['model', 'modelo', 'type', 'tipo', 'series']);
// Split datasheet into primary (first 4) and extended // Split datasheet into primary (first 4) and extended
const primarySpecs = data.datasheet.slice(0, 4); const primarySpecs = datasheet.slice(0, 4);
const extendedSpecs = data.datasheet.slice(4); const extendedSpecs = datasheet.slice(4);
return ( return (
<motion.div <motion.div
@@ -168,7 +178,7 @@ export default function EquipmentConfigurator({ data }: { data: EquipmentData })
> >
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<Settings2 size={12} /> <Settings2 size={12} />
All Specifications ({data.datasheet.length}) All Specifications ({datasheet.length})
</span> </span>
<ChevronDown size={14} className={`transition-transform duration-300 ${showAllSpecs ? "rotate-180" : ""}`} /> <ChevronDown size={14} className={`transition-transform duration-300 ${showAllSpecs ? "rotate-180" : ""}`} />
</button> </button>
+120 -18
View File
@@ -5,6 +5,7 @@ import { Sparkles, ArrowRight, X, Minus, Database, Maximize2, Minimize2 } from "
import { useChat } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls } from "ai"; import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls } from "ai";
import { useUIStore } from "@/lib/store/uiStore"; import { useUIStore } from "@/lib/store/uiStore";
import { useRouter, usePathname } from "next/navigation";
import { useState, useEffect, useRef, useMemo } from "react"; import { useState, useEffect, useRef, useMemo } from "react";
// ── Renderers ── // ── Renderers ──
@@ -17,6 +18,9 @@ import CaseStudyViewer from "./CaseStudyViewer";
import EquipmentConfigurator from "./EquipmentConfigurator"; import EquipmentConfigurator from "./EquipmentConfigurator";
import EfficiencyCard from "./EfficiencyCard"; import EfficiencyCard from "./EfficiencyCard";
import { getAiSessionId } from "@/lib/aiSessionId";
import { trackEvent } from "@/lib/analytics/gtag";
export default function SilentObserver() { export default function SilentObserver() {
const { const {
isAiExpanded, toggleAi, setAiExpanded, isAiExpanded, toggleAi, setAiExpanded,
@@ -24,6 +28,10 @@ export default function SilentObserver() {
setHighlightedMapNode, setSelectedMarkerId, setHighlightedMapNode, setSelectedMarkerId,
} = useUIStore(); } = useUIStore();
const router = useRouter();
const pathname = usePathname();
const locale = pathname?.split('/')[1] || 'en';
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [isDark, setIsDark] = useState(false); const [isDark, setIsDark] = useState(false);
const [isWideMode, setIsWideMode] = useState(false); const [isWideMode, setIsWideMode] = useState(false);
@@ -49,15 +57,20 @@ export default function SilentObserver() {
}; };
// ═══ AI SDK 6: Transport with dynamic body ═══ // ═══ AI SDK 6: Transport with dynamic body ═══
// sessionId is stable per visitor (localStorage UUID) so the chat route can
// stitch all messages into the same AiConversation row for analytics.
const transport = useMemo(() => new DefaultChatTransport({ const transport = useMemo(() => new DefaultChatTransport({
api: "/api/chat", api: "/api/chat",
body: () => ({ body: () => ({
sessionId: getAiSessionId(),
locale,
pageUrl: typeof window !== "undefined" ? window.location.href : null,
context: { context: {
section: sectionRef.current, section: sectionRef.current,
activeTab: tabRef.current, activeTab: tabRef.current,
}, },
}), }),
}), []); }), [locale]);
// ═══ AI SDK 6: useChat ═══ // ═══ AI SDK 6: useChat ═══
const { messages, sendMessage, addToolOutput, status } = useChat({ const { messages, sendMessage, addToolOutput, status } = useChat({
@@ -68,24 +81,55 @@ export default function SilentObserver() {
if (toolCall.dynamic) return; if (toolCall.dynamic) return;
if (toolCall.toolName === "navigate_to_section") { if (toolCall.toolName === "navigate_to_section") {
const { section, subAction, tabId, nodeId } = toolCall.input as { const { section, url, subAction, tabId, nodeId } = toolCall.input as {
section: string; subAction?: string; tabId?: string; nodeId?: string; section?: string; url?: string; subAction?: string; tabId?: string; nodeId?: string;
}; };
handleClose(); handleClose();
setTimeout(() => {
const el = document.getElementById(section); // Valid homepage DOM IDs — anything else is a page route
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" }); const HOMEPAGE_IDS = new Set([
if (subAction === "activate-tab" && tabId) setActiveApplicationTab(tabId); "technology", "applications-dashboard", "applications-deep",
if (subAction === "highlight-node" && nodeId) { "global", "our-story", "legacy",
setHighlightedMapNode(nodeId); ]);
setTimeout(() => setHighlightedMapNode(null), 5000);
} // Fallback map: if the AI sends a section name that's actually a page
}, 400); const SECTION_TO_PAGE: Record<string, string> = {
addToolOutput({ news: "/news", heritage: "/heritage", parts: "/parts",
tool: "navigate_to_section" as any, "parts-catalog": "/parts", contact: "/parts",
toolCallId: toolCall.toolCallId, "inside-flux": "/news", "spare-parts": "/parts",
output: `Navigated to "${section}" section${tabId ? `, activated tab "${tabId}"` : ""}${nodeId ? `, highlighted node "${nodeId}"` : ""}`, };
});
// Resolve: explicit url > section-to-page fallback > homepage scroll
const resolvedUrl = url
|| (section && !HOMEPAGE_IDS.has(section) ? SECTION_TO_PAGE[section] || null : null);
if (resolvedUrl) {
// Cross-page navigation
setTimeout(() => {
router.push(`/${locale}${resolvedUrl}`);
}, 400);
addToolOutput({
tool: "navigate_to_section" as any,
toolCallId: toolCall.toolCallId,
output: `Navigated to page "${resolvedUrl}"`,
});
} else if (section && HOMEPAGE_IDS.has(section)) {
// Same-page scroll — only for confirmed homepage DOM IDs
setTimeout(() => {
const el = document.getElementById(section);
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
if (subAction === "activate-tab" && tabId) setActiveApplicationTab(tabId);
if (subAction === "highlight-node" && nodeId) {
setHighlightedMapNode(nodeId);
setTimeout(() => setHighlightedMapNode(null), 5000);
}
}, 400);
addToolOutput({
tool: "navigate_to_section" as any,
toolCallId: toolCall.toolCallId,
output: `Navigated to "${section}" section${tabId ? `, activated tab "${tabId}"` : ""}${nodeId ? `, highlighted node "${nodeId}"` : ""}`,
});
}
} }
}, },
}); });
@@ -168,6 +212,11 @@ export default function SilentObserver() {
const DataToolWorking = ({ label }: { label: string }) => (<div key={key} className="self-start flex items-center gap-2 py-1"><Database size={10} className="text-[#0066CC]/40 dark:text-[#4DA6FF]/40 animate-pulse" /><span className="text-[10px] text-[#86868B]/60 dark:text-[#A1A1A6]/40 italic">{label}</span></div>); const DataToolWorking = ({ label }: { label: string }) => (<div key={key} className="self-start flex items-center gap-2 py-1"><Database size={10} className="text-[#0066CC]/40 dark:text-[#4DA6FF]/40 animate-pulse" /><span className="text-[10px] text-[#86868B]/60 dark:text-[#A1A1A6]/40 italic">{label}</span></div>);
const ToolError = ({ msg }: { msg?: string }) => (<div key={key} className="text-[11px] text-red-400 dark:text-red-300/80 self-start py-1">Analysis unavailable. {msg}</div>); const ToolError = ({ msg }: { msg?: string }) => (<div key={key} className="text-[11px] text-red-400 dark:text-red-300/80 self-start py-1">Analysis unavailable. {msg}</div>);
if (part.type === "tool-recommend_application") {
if (part.state === "input-streaming" || part.state === "input-available") return <DataToolWorking key={key} label="Analyzing your needs..." />;
if (part.state === "output-available") return null;
if (part.state === "output-error") return <ToolError key={key} msg={(part as any).errorText} />;
}
if (part.type === "tool-search_installations") { if (part.type === "tool-search_installations") {
if (part.state === "input-streaming" || part.state === "input-available") return <DataToolWorking key={key} label="Searching installation database..." />; if (part.state === "input-streaming" || part.state === "input-available") return <DataToolWorking key={key} label="Searching installation database..." />;
if (part.state === "output-available") return null; if (part.state === "output-available") return null;
@@ -218,6 +267,41 @@ export default function SilentObserver() {
return null; return null;
} }
// ═══ Contextual Quick-Replies based on last assistant message ═══
function getContextualSuggestions(): string[] {
if (isLoading || messages.length === 0) return [];
const lastAssistant = [...messages].reverse().find(m => m.role === "assistant");
if (!lastAssistant?.parts) return [];
const toolTypes = new Set(
lastAssistant.parts
.filter((p: any) => p.type?.startsWith("tool-") && p.state === "output-available")
.map((p: any) => p.type)
);
// Priority order: suggest the next logical funnel step
if (toolTypes.has("tool-schedule_consultation")) return []; // End of funnel
if (toolTypes.has("tool-show_equipment_specs"))
return ["Schedule a consultation", "Compare with traditional methods"];
if (toolTypes.has("tool-show_case_study"))
return ["Show me equipment specs", "Calculate savings for my operation", "Schedule a consultation"];
if (toolTypes.has("tool-energy_savings_calculator"))
return ["Show me a real installation", "See equipment specs", "How does RF heating work?"];
if (toolTypes.has("tool-process_comparison_table"))
return ["Calculate savings for my operation", "Show me proven installations"];
if (toolTypes.has("tool-rf_technology_explainer"))
return ["What would I save in energy costs?", "Show me real installations"];
if (toolTypes.has("tool-recommend_application") || toolTypes.has("tool-get_application_knowledge"))
return ["Calculate energy savings", "Show me case studies", "Compare RF vs my current method"];
if (toolTypes.has("tool-navigate_to_section"))
return ["Tell me more about this", "How much energy can I save?"];
// Default: if assistant responded with text only (Stage 1 qualification)
return [];
}
const suggestions = getContextualSuggestions();
return ( return (
<> <>
<AnimatePresence> <AnimatePresence>
@@ -230,7 +314,7 @@ export default function SilentObserver() {
<div className={`fixed inset-0 z-50 flex pointer-events-none px-3 pb-3 md:p-6 transition-all duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] ${isWideMode ? "items-center justify-center" : "items-end justify-center md:justify-end"}`} style={{ paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))' }}> <div className={`fixed inset-0 z-50 flex pointer-events-none px-3 pb-3 md:p-6 transition-all duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] ${isWideMode ? "items-center justify-center" : "items-end justify-center md:justify-end"}`} style={{ paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))' }}>
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{!isAiExpanded ? ( {!isAiExpanded ? (
<motion.button key="pill" layoutId="flux-ai-shell" onClick={toggleAi} <motion.button key="pill" layoutId="flux-ai-shell" onClick={() => { trackEvent({ name: "ai_chat_opened", params: { section: currentSection } }); toggleAi(); }}
initial={{ opacity: 0, scale: 0.8, filter: "blur(10px)" }} animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }} exit={{ opacity: 0, scale: 0.9, filter: "blur(10px)" }} initial={{ opacity: 0, scale: 0.8, filter: "blur(10px)" }} animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }} exit={{ opacity: 0, scale: 0.9, filter: "blur(10px)" }}
transition={{ type: "spring", stiffness: 400, damping: 30 }} transition={{ type: "spring", stiffness: 400, damping: 30 }}
className="pointer-events-auto flex items-center gap-3 px-5 py-3.5 rounded-full cursor-pointer select-none bg-white/70 dark:bg-[#1D1D1F]/80 backdrop-blur-2xl backdrop-saturate-150 border border-white/60 dark:border-white/10 shadow-[0_8px_32px_rgba(0,0,0,0.08)] dark:shadow-[0_8px_32px_rgba(0,0,0,0.5)] hover:shadow-[0_12px_40px_rgba(0,0,0,0.12)] dark:hover:shadow-[0_12px_40px_rgba(0,0,0,0.6)] hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 group"> className="pointer-events-auto flex items-center gap-3 px-5 py-3.5 rounded-full cursor-pointer select-none bg-white/70 dark:bg-[#1D1D1F]/80 backdrop-blur-2xl backdrop-saturate-150 border border-white/60 dark:border-white/10 shadow-[0_8px_32px_rgba(0,0,0,0.08)] dark:shadow-[0_8px_32px_rgba(0,0,0,0.5)] hover:shadow-[0_12px_40px_rgba(0,0,0,0.12)] dark:hover:shadow-[0_12px_40px_rgba(0,0,0,0.6)] hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 group">
@@ -304,6 +388,24 @@ export default function SilentObserver() {
{[0, 1, 2].map((i) => (<motion.div key={i} className="w-1.5 h-1.5 rounded-full bg-[#0066CC] dark:bg-[#4DA6FF]" animate={{ opacity: [0.2, 1, 0.2], scale: [0.8, 1, 0.8] }} transition={{ duration: 1, repeat: Infinity, delay: i * 0.2 }} />))} {[0, 1, 2].map((i) => (<motion.div key={i} className="w-1.5 h-1.5 rounded-full bg-[#0066CC] dark:bg-[#4DA6FF]" animate={{ opacity: [0.2, 1, 0.2], scale: [0.8, 1, 0.8] }} transition={{ duration: 1, repeat: Infinity, delay: i * 0.2 }} />))}
</div> </div>
)} )}
{suggestions.length > 0 && !isLoading && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2 }}
className="flex flex-wrap gap-1.5 pt-1 pb-2"
>
{suggestions.map((s) => (
<button
key={s}
onClick={() => { sendMessage({ text: s }); }}
className="px-3 py-1.5 rounded-full text-[11px] font-medium bg-[#0066CC]/[0.06] dark:bg-[#4DA6FF]/[0.08] border border-[#0066CC]/10 dark:border-[#4DA6FF]/10 text-[#0066CC] dark:text-[#4DA6FF] hover:bg-[#0066CC]/10 dark:hover:bg-[#4DA6FF]/15 hover:border-[#0066CC]/20 dark:hover:border-[#4DA6FF]/20 active:scale-95 transition-all duration-200"
>
{s}
</button>
))}
</motion.div>
)}
</div> </div>
<form onSubmit={onSubmit} className="relative z-10 px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] md:pb-3 border-t border-black/[0.04] dark:border-white/[0.06] bg-white/40 dark:bg-white/[0.02] flex items-center gap-2.5 transition-colors duration-300"> <form onSubmit={onSubmit} className="relative z-10 px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] md:pb-3 border-t border-black/[0.04] dark:border-white/[0.06] bg-white/40 dark:bg-white/[0.02] flex items-center gap-2.5 transition-colors duration-300">
@@ -0,0 +1,88 @@
"use client";
// src/components/analytics/ConsentBanner.tsx
// -----------------------------------------------------------------------------
// GDPR / ePrivacy cookie consent banner. On-brand (FLUX cyan), minimal, and
// localized through next-intl. Shows only when:
// - analytics is configured (NEXT_PUBLIC_GA_ID present), AND
// - the visitor has not yet made a choice (no consent cookie).
//
// Accept -> consent granted, GA starts tracking, first page_view fires.
// Decline -> consent denied, GA stays cookieless.
// The choice is remembered for one year.
// -----------------------------------------------------------------------------
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Link } from "@/i18n/routing";
import {
analyticsEnabled,
readStoredConsent,
storeConsent,
updateConsent,
pageview,
} from "@/lib/analytics/gtag";
export default function ConsentBanner() {
const t = useTranslations("Consent");
const [visible, setVisible] = useState(false);
useEffect(() => {
if (!analyticsEnabled()) return;
if (readStoredConsent() === null) setVisible(true);
else if (readStoredConsent() === "granted") {
// Returning visitor who already consented — re-grant for this session.
updateConsent("granted");
}
}, []);
if (!visible) return null;
const choose = (granted: boolean) => {
const choice = granted ? "granted" : "denied";
storeConsent(choice);
updateConsent(choice);
if (granted && typeof window !== "undefined") {
pageview(window.location.pathname + window.location.search, document.title);
}
setVisible(false);
};
return (
<div
role="dialog"
aria-live="polite"
aria-label={t("title")}
className="fixed bottom-4 left-4 right-4 z-[300] mx-auto max-w-2xl rounded-2xl border border-black/10 bg-white/95 p-5 shadow-2xl backdrop-blur-xl md:left-6 md:right-auto md:bottom-6"
>
<div className="flex flex-col gap-4 md:flex-row md:items-center">
<div className="flex-1">
<p className="text-sm font-medium text-[#1D1D1F]">{t("title")}</p>
<p className="mt-1 text-xs leading-relaxed text-[#6E6E73]">
{t("body")}{" "}
<Link
href="/privacy"
className="underline decoration-[#00B8CC] underline-offset-2 hover:text-[#1D1D1F]"
>
{t("learnMore")}
</Link>
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<button
onClick={() => choose(false)}
className="rounded-full border border-black/15 px-4 py-2 text-xs font-medium text-[#1D1D1F] transition-colors hover:bg-black/5"
>
{t("decline")}
</button>
<button
onClick={() => choose(true)}
className="rounded-full bg-[#1D1D1F] px-5 py-2 text-xs font-medium text-white transition-colors hover:bg-[#000]"
>
{t("accept")}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,53 @@
"use client";
// src/components/analytics/GoogleAnalytics.tsx
// -----------------------------------------------------------------------------
// Loads gtag.js with Consent Mode v2. Renders NOTHING and loads NOTHING when
// NEXT_PUBLIC_GA_ID is unset, so the site is unaffected until the client
// provides their Measurement ID.
//
// Consent defaults to "denied" for all storage. The ConsentBanner flips
// analytics_storage to "granted" once the visitor accepts. This is the
// Google-recommended GDPR pattern: the tag loads but stores no cookies and
// no personal data until consent is given.
// -----------------------------------------------------------------------------
import Script from "next/script";
import { GA_MEASUREMENT_ID, analyticsEnabled } from "@/lib/analytics/gtag";
export default function GoogleAnalytics() {
if (!analyticsEnabled()) return null;
return (
<>
{/* 1. Consent Mode v2 defaults + gtag bootstrap. Must run BEFORE the
gtag.js library so the default consent state is set first. */}
<Script id="ga-consent-default" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
window.gtag = gtag;
gtag('consent', 'default', {
'analytics_storage': 'denied',
'ad_storage': 'denied',
'ad_user_data': 'denied',
'ad_personalization': 'denied',
'wait_for_update': 500
});
gtag('js', new Date());
gtag('config', '${GA_MEASUREMENT_ID}', {
'anonymize_ip': true,
'send_page_view': false
});
`}
</Script>
{/* 2. The actual GA4 library. */}
<Script
id="ga-lib"
strategy="afterInteractive"
src={`https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`}
/>
</>
);
}
@@ -0,0 +1,29 @@
"use client";
// src/components/analytics/PageViewTracker.tsx
// -----------------------------------------------------------------------------
// GA4's gtag does not auto-track client-side route changes in the Next.js App
// Router (we set send_page_view:false in the config). This component fires a
// page_view on every pathname/search change so SPA navigation is measured.
//
// Safe no-op when analytics is disabled. Must live inside a Suspense boundary
// because it reads useSearchParams (a requirement under ISR).
// -----------------------------------------------------------------------------
import { useEffect } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { pageview, analyticsEnabled } from "@/lib/analytics/gtag";
export default function PageViewTracker() {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
if (!analyticsEnabled()) return;
const qs = searchParams?.toString();
const url = qs ? `${pathname}?${qs}` : pathname;
pageview(url, typeof document !== "undefined" ? document.title : undefined);
}, [pathname, searchParams]);
return null;
}
+874
View File
@@ -0,0 +1,874 @@
"use client";
// ─────────────────────────────────────────────────────────────────────────────
// AssetBucketBrowser — single picker used everywhere in HQ Command.
//
// What's in the box:
// • Bucket tabs (Media / Videos / Renders / etc.) per scope. Each tab
// maps to an on-disk subfolder. Public-facing layout unchanged.
// • Upload via drag-drop, file picker or paste-URL.
// • Bulk select with click + Shift-click + Cmd/Ctrl-click. Bulk delete
// and bulk move-to-bucket from the toolbar.
// • Drag a file onto another bucket tab to MOVE it (PATCH /api/assets).
// • Double-click a filename to rename in place.
// • Search, grid/list views, copy URL per file.
// • Toast + confirm via the global HqUiProvider — no browser popups.
// ─────────────────────────────────────────────────────────────────────────────
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import {
X, FolderOpen, Upload, File, Grid3X3, LayoutList, Copy, Check,
Image as ImageIcon, Video, Box, Search, Loader2, Trash2, Info,
ArrowRightLeft, Pencil, CheckSquare, Square,
} from "lucide-react";
import { useHqUi } from "@/components/hq/Toast";
export interface AssetItem {
name: string;
type: "file" | "folder";
mediaType?: string;
extension?: string;
path: string;
publicUrl?: string;
size?: string;
childCount?: number;
}
export interface SelectedAsset {
name: string;
publicUrl: string;
mediaType: string;
path: string;
}
type Scope = "cases" | "applications" | "news" | "parts" | "footage" | "branding";
export interface BucketDef {
id: string;
/** Folder under /{scope}/{slug}/. Empty string means the entity root. */
path: string;
label: string;
description: string;
icon: typeof ImageIcon;
/** Suggested file types. Used for the <input accept=...> hint, not enforced. */
accept: string;
accentColor: string;
}
const BUCKETS_BY_SCOPE: Record<Scope, BucketDef[]> = {
cases: [
{ id: "media", path: "", label: "Media", description: "Cover and gallery images at the case root", icon: ImageIcon, accept: "image/*", accentColor: "#00F0FF" },
{ id: "videos", path: "videos", label: "Videos", description: "MP4 clips of the installation in operation", icon: Video, accept: "video/*", accentColor: "#4DA6FF" },
{ id: "models", path: "models", label: "3D Models", description: "GLB/USDZ for AR viewer and 3D display", icon: Box, accept: ".glb,.gltf,.usdz", accentColor: "#A855F7" },
{ id: "renders", path: "renders", label: "Renders", description: "3D rendered images of the equipment", icon: Box, accept: "image/*", accentColor: "#FF6B9D" },
],
applications: [
{ id: "media", path: "", label: "Media", description: "Hero, blueprints, machines — files at the application root", icon: ImageIcon, accept: "image/*,video/*", accentColor: "#9333EA" },
{ id: "videos", path: "videos", label: "Videos", description: "Demo videos for this application", icon: Video, accept: "video/*", accentColor: "#4DA6FF" },
{ id: "renders", path: "renders", label: "Renders", description: "3D rendered images", icon: Box, accept: "image/*", accentColor: "#FF6B9D" },
],
news: [
{ id: "media", path: "", label: "Media", description: "Cover image and gallery photos", icon: ImageIcon, accept: "image/*", accentColor: "#0A66C2" },
],
parts: [
{ id: "media", path: "", label: "Media", description: "Part photos and product shots", icon: ImageIcon, accept: "image/*", accentColor: "#F59E0B" },
{ id: "renders", path: "renders", label: "Renders", description: "3D renders of the component", icon: Box, accept: "image/*", accentColor: "#FF6B9D" },
],
footage: [
{ id: "media", path: "", label: "Hero Reel", description: "Hero carousel images and videos", icon: ImageIcon, accept: "image/*,video/*", accentColor: "#FF6B9D" },
],
branding: [
{ id: "media", path: "", label: "Brand Assets", description: "Favicons, logos, OG images", icon: ImageIcon, accept: "image/*", accentColor: "#FF6B9D" },
],
};
function bucketHint(bucket: BucketDef, fileName: string): string | null {
const ext = (fileName.split(".").pop() || "").toLowerCase();
const isImage = ["jpg", "jpeg", "png", "webp", "gif", "svg", "avif"].includes(ext);
const isVideo = ["mp4", "webm", "mov"].includes(ext);
const isModel = ["glb", "gltf", "usdz"].includes(ext);
if (bucket.id === "videos" && !isVideo) return "Videos bucket usually holds .mp4 — this file may not display correctly.";
if (bucket.id === "renders" && !isImage) return "Renders bucket expects images (PNG/JPG/WebP).";
if (bucket.id === "models" && !isModel) return "Models bucket expects 3D files (.glb, .gltf, .usdz).";
if (bucket.id === "media" && !isImage && !isVideo) return "Media bucket expects images or videos here.";
return null;
}
export interface AssetBucketBrowserProps {
slug: string;
scope?: Scope;
isOpen: boolean;
onClose: () => void;
onSelect: (item: SelectedAsset) => void;
accentColor?: string;
initialPath?: string;
title?: string;
}
// Shape used internally to track drag state across the modal.
interface DragPayload {
paths: string[];
fromBucketId: string;
}
export default function AssetBucketBrowser({
slug, scope = "cases", isOpen, onClose, onSelect,
accentColor = "#00F0FF", initialPath = "", title,
}: AssetBucketBrowserProps) {
const ui = useHqUi();
const buckets = BUCKETS_BY_SCOPE[scope] || BUCKETS_BY_SCOPE.cases;
const defaultBucketId = useMemo(() => {
const match = buckets.find((b) => b.path === initialPath);
return match ? match.id : buckets[0].id;
}, [buckets, initialPath]);
const [activeBucketId, setActiveBucketId] = useState(defaultBucketId);
const activeBucket = buckets.find((b) => b.id === activeBucketId) || buckets[0];
const [items, setItems] = useState<AssetItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState("");
const [isDragOverDropZone, setIsDragOverDropZone] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const [copiedPath, setCopiedPath] = useState<string | null>(null);
// Selection + drag + rename state
const [selected, setSelected] = useState<Set<string>>(new Set());
const [lastSelected, setLastSelected] = useState<string | null>(null);
const [renaming, setRenaming] = useState<{ path: string; value: string } | null>(null);
const [moveBusy, setMoveBusy] = useState(false);
const [overBucketId, setOverBucketId] = useState<string | null>(null);
const dragPayloadRef = useRef<DragPayload | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isOpen) {
setActiveBucketId(defaultBucketId);
setSearchQuery("");
setError(null);
setSelected(new Set());
setLastSelected(null);
setRenaming(null);
}
}, [isOpen, defaultBucketId]);
// Reset selection when switching buckets
useEffect(() => {
setSelected(new Set());
setLastSelected(null);
setRenaming(null);
}, [activeBucketId]);
const fetchItems = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const params = new URLSearchParams({ scope, slug, path: activeBucket.path });
const res = await fetch(`/api/assets?${params}`);
const data = await res.json();
if (data.success) setItems(data.items.filter((i: AssetItem) => i.type === "file"));
else setError(data.error || "Failed to load");
} catch {
setError("Connection error");
}
setIsLoading(false);
}, [scope, slug, activeBucket.path]);
useEffect(() => {
if (isOpen && slug) fetchItems();
}, [isOpen, fetchItems, slug]);
// ─── Upload ─────────────────────────────────────────────────────
const uploadFile = async (file: File) => {
setIsUploading(true);
setUploadProgress(`Uploading ${file.name}`);
try {
const fd = new FormData();
fd.append("scope", scope);
fd.append("slug", slug);
fd.append("path", activeBucket.path);
fd.append("file", file);
fd.append("optimize", "1");
const res = await fetch("/api/assets", { method: "POST", body: fd });
const data = await res.json();
if (data.success) {
const f = data.file;
if (f.optimized && f.savedBytes > 0) {
const pct = Math.round((f.savedBytes / f.originalBytes) * 100);
setUploadProgress(`${f.name} (${pct}% optimized)`);
} else {
setUploadProgress(`${f.name}`);
}
await fetchItems();
setTimeout(() => setUploadProgress(""), 1500);
} else {
setUploadProgress(`${data.error}`);
setTimeout(() => setUploadProgress(""), 4000);
}
} catch (err: any) {
setUploadProgress(`${err.message || "Upload failed"}`);
setTimeout(() => setUploadProgress(""), 4000);
}
setIsUploading(false);
};
const handleFiles = (files: FileList | null) => {
if (!files) return;
Array.from(files).forEach(uploadFile);
};
// ─── Bulk delete ────────────────────────────────────────────────
const deleteFiles = async (paths: string[]) => {
if (paths.length === 0) return;
const ok = await ui.confirm({
title: paths.length === 1 ? "Delete file" : `Delete ${paths.length} files`,
message: paths.length === 1
? `Permanently delete "${paths[0].split("/").pop()}"? This cannot be undone.`
: `Permanently delete ${paths.length} files? This cannot be undone.`,
confirmLabel: "Delete",
destructive: true,
});
if (!ok) return;
try {
const res = await fetch("/api/assets", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scope, slug, filePaths: paths }),
});
const data = await res.json();
if (data.success) {
ui.toast(
data.deleted.length === 1 ? "File deleted." : `${data.deleted.length} files deleted.`,
"success"
);
if (data.failed?.length > 0) ui.toast(`${data.failed.length} files failed: ${data.failed.map((f: any) => f.reason).join(", ")}`, "error");
} else {
ui.toast(data.error || "Delete failed", "error");
}
setSelected(new Set());
await fetchItems();
} catch (err: any) {
ui.toast(err.message || "Delete failed", "error");
}
};
// ─── Move (single file or bulk) ─────────────────────────────────
const moveFiles = async (paths: string[], toBucketId: string) => {
if (paths.length === 0) return;
const target = buckets.find((b) => b.id === toBucketId);
if (!target) return;
setMoveBusy(true);
let okCount = 0;
let failCount = 0;
for (const fromPath of paths) {
const filename = fromPath.split("/").pop()!;
const toPath = target.path ? `${target.path}/${filename}` : filename;
if (fromPath === toPath) continue;
try {
const res = await fetch("/api/assets", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scope, slug, fromPath, toPath }),
});
const data = await res.json();
if (data.success) okCount++; else failCount++;
} catch {
failCount++;
}
}
setMoveBusy(false);
setSelected(new Set());
if (okCount > 0) ui.toast(`Moved ${okCount} file${okCount > 1 ? "s" : ""} to ${target.label}.`, "success");
if (failCount > 0) ui.toast(`${failCount} file${failCount > 1 ? "s" : ""} could not be moved (name conflict?).`, "error");
await fetchItems();
};
// ─── Rename ─────────────────────────────────────────────────────
const submitRename = async () => {
if (!renaming) return;
const newName = renaming.value.trim();
const oldName = renaming.path.split("/").pop()!;
if (!newName || newName === oldName) {
setRenaming(null);
return;
}
const cleanName = newName.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9._-]/g, "");
if (!cleanName) {
ui.toast("Invalid filename.", "error");
return;
}
const dir = renaming.path.includes("/") ? renaming.path.slice(0, renaming.path.lastIndexOf("/")) : "";
const toPath = dir ? `${dir}/${cleanName}` : cleanName;
try {
const res = await fetch("/api/assets", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scope, slug, fromPath: renaming.path, toPath }),
});
const data = await res.json();
if (data.success) {
ui.toast("Renamed.", "success");
setRenaming(null);
await fetchItems();
} else {
ui.toast(data.error || "Rename failed", "error");
}
} catch (err: any) {
ui.toast(err.message || "Rename failed", "error");
}
};
// ─── Selection helpers ──────────────────────────────────────────
const toggleSelect = (path: string, e?: React.MouseEvent) => {
const next = new Set(selected);
if (e?.shiftKey && lastSelected) {
// Range select
const ids = filtered.map((i) => i.path);
const a = ids.indexOf(lastSelected);
const b = ids.indexOf(path);
if (a >= 0 && b >= 0) {
const [start, end] = a < b ? [a, b] : [b, a];
for (let i = start; i <= end; i++) next.add(ids[i]);
}
} else if (e?.metaKey || e?.ctrlKey) {
if (next.has(path)) next.delete(path); else next.add(path);
} else {
// Plain click toggles, leaves others alone
if (next.has(path)) next.delete(path); else next.add(path);
}
setSelected(next);
setLastSelected(path);
};
const selectAll = () => {
if (selected.size === filtered.length) setSelected(new Set());
else setSelected(new Set(filtered.map((i) => i.path)));
};
// ─── Drag-between-buckets ───────────────────────────────────────
const onItemDragStart = (e: React.DragEvent, item: AssetItem) => {
// If the dragged item isn't in selection, drag just that one. If it is,
// drag the whole selection.
let paths: string[];
if (selected.has(item.path)) paths = Array.from(selected);
else { paths = [item.path]; setSelected(new Set([item.path])); }
dragPayloadRef.current = { paths, fromBucketId: activeBucketId };
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", paths.join(","));
};
const onTabDragOver = (e: React.DragEvent, bucketId: string) => {
if (!dragPayloadRef.current) return;
if (dragPayloadRef.current.fromBucketId === bucketId) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setOverBucketId(bucketId);
};
const onTabDragLeave = () => setOverBucketId(null);
const onTabDrop = async (e: React.DragEvent, bucketId: string) => {
e.preventDefault();
setOverBucketId(null);
const payload = dragPayloadRef.current;
dragPayloadRef.current = null;
if (!payload || payload.fromBucketId === bucketId) return;
await moveFiles(payload.paths, bucketId);
};
// ─── Misc ────────────────────────────────────────────────────────
const handlePick = (item: AssetItem) => {
onSelect({
name: item.name,
publicUrl: item.publicUrl || `/${scope}/${slug}/${item.path}`,
mediaType: item.mediaType || "unknown",
path: item.path,
});
};
const copyPath = (item: AssetItem) => {
const url = item.publicUrl || `/${scope}/${slug}/${item.path}`;
navigator.clipboard.writeText(url);
setCopiedPath(item.path);
setTimeout(() => setCopiedPath(null), 1500);
};
const filtered = searchQuery
? items.filter((i) => i.name.toLowerCase().includes(searchQuery.toLowerCase()))
: items;
const otherBuckets = buckets.filter((b) => b.id !== activeBucketId);
const hasSelection = selected.size > 0;
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" onClick={onClose}>
<div
className="relative w-full max-w-6xl h-[80vh] bg-[#111] border border-white/10 rounded-3xl shadow-2xl flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
style={{ ["--accent" as any]: accentColor }}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/5 flex-shrink-0">
<div className="flex items-center gap-3">
<FolderOpen size={18} style={{ color: accentColor }} />
<div>
<div className="text-sm font-medium text-white">{title || "Assets"}</div>
<div className="text-[10px] text-[#86868B] font-mono">/{scope}/{slug}{activeBucket.path ? `/${activeBucket.path}` : ""}</div>
</div>
</div>
<button onClick={onClose} className="p-2 rounded-lg hover:bg-white/5 text-[#86868B] hover:text-white transition-colors">
<X size={18} />
</button>
</div>
{/* Bucket tabs (drop targets while dragging) */}
<div className="flex gap-1 px-6 pt-4 border-b border-white/5">
{buckets.map((b) => {
const Icon = b.icon;
const isActive = activeBucketId === b.id;
const isDropTarget = overBucketId === b.id;
return (
<button
key={b.id}
onClick={() => setActiveBucketId(b.id)}
onDragOver={(e) => onTabDragOver(e, b.id)}
onDragLeave={onTabDragLeave}
onDrop={(e) => onTabDrop(e, b.id)}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-all -mb-px ${
isDropTarget ? "border-emerald-400 bg-emerald-500/10 text-emerald-300"
: isActive ? "border-[var(--accent)] text-white"
: "border-transparent text-[#86868B] hover:text-white hover:bg-white/[0.02]"
}`}
title={b.description}
>
<Icon size={14} style={{ color: isDropTarget ? "#34D399" : isActive ? b.accentColor : undefined }} />
{b.label}
{isDropTarget && <span className="text-[9px] uppercase tracking-widest">drop to move</span>}
</button>
);
})}
</div>
{/* Bucket helper */}
<div className="flex items-center gap-3 px-6 py-3 bg-white/[0.02] border-b border-white/5 text-xs text-[#86868B]">
<Info size={12} className="flex-shrink-0" style={{ color: activeBucket.accentColor }} />
<span className="leading-relaxed">{activeBucket.description}</span>
{moveBusy && <span className="ml-auto flex items-center gap-1 text-[#00F0FF]"><Loader2 size={12} className="animate-spin" /> Moving</span>}
</div>
{/* Toolbar — switches to bulk-action mode when items are selected */}
<div className="flex items-center gap-2 px-6 py-3 border-b border-white/5 flex-shrink-0">
{hasSelection ? (
<>
<button
onClick={() => setSelected(new Set())}
className="flex items-center gap-1.5 text-xs text-[#86868B] hover:text-white px-2 py-1.5"
>
<X size={13} /> {selected.size} selected
</button>
<div className="h-4 w-px bg-white/10" />
<button
onClick={() => deleteFiles(Array.from(selected))}
className="flex items-center gap-1.5 bg-rose-500/10 text-rose-400 hover:bg-rose-500/20 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
>
<Trash2 size={12} /> Delete
</button>
{otherBuckets.length > 0 && (
<div className="flex items-center gap-1.5 text-xs text-[#86868B]">
<ArrowRightLeft size={12} /> Move to:
{otherBuckets.map((b) => (
<button
key={b.id}
onClick={() => moveFiles(Array.from(selected), b.id)}
disabled={moveBusy}
className="px-2 py-1 rounded text-[11px] font-medium hover:bg-white/5 disabled:opacity-50"
style={{ color: b.accentColor }}
>
{b.label}
</button>
))}
</div>
)}
</>
) : (
<>
<button
onClick={selectAll}
className="flex items-center gap-1.5 text-xs text-[#86868B] hover:text-white px-2 py-1.5"
title="Select all (filtered)"
>
{selected.size === filtered.length && filtered.length > 0 ? <CheckSquare size={13} /> : <Square size={13} />}
Select all
</button>
<div className="relative flex-1 max-w-md">
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#86868B]" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search files…"
className="w-full bg-black/40 border border-white/10 text-white text-xs rounded-lg pl-9 pr-3 py-2 outline-none focus:border-white/30"
/>
</div>
<div className="flex items-center gap-1 ml-auto">
<button onClick={() => setViewMode("grid")} className={`p-2 rounded-lg ${viewMode === "grid" ? "bg-white/10 text-white" : "text-[#86868B] hover:bg-white/5"}`} title="Grid view">
<Grid3X3 size={14} />
</button>
<button onClick={() => setViewMode("list")} className={`p-2 rounded-lg ${viewMode === "list" ? "bg-white/10 text-white" : "text-[#86868B] hover:bg-white/5"}`} title="List view">
<LayoutList size={14} />
</button>
</div>
<input
ref={fileInputRef}
type="file"
accept={activeBucket.accept}
multiple
className="hidden"
onChange={(e) => { handleFiles(e.target.files); e.target.value = ""; }}
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="inline-flex items-center gap-2 bg-[var(--accent)]/15 text-[var(--accent)] hover:bg-[var(--accent)]/25 px-3 py-2 rounded-lg text-xs font-medium transition-colors disabled:opacity-50"
>
{isUploading ? <Loader2 size={12} className="animate-spin" /> : <Upload size={12} />}
Upload
</button>
</>
)}
</div>
{/* Items area */}
<div
onDragEnter={(e) => { e.preventDefault(); if (!dragPayloadRef.current) setIsDragOverDropZone(true); }}
onDragOver={(e) => e.preventDefault()}
onDragLeave={() => setIsDragOverDropZone(false)}
onDrop={(e) => {
e.preventDefault();
setIsDragOverDropZone(false);
if (e.dataTransfer.files.length > 0) handleFiles(e.dataTransfer.files);
}}
className={`flex-1 overflow-y-auto px-6 py-4 transition-colors ${
isDragOverDropZone ? "bg-[var(--accent)]/5 border-2 border-dashed border-[var(--accent)]/30" : ""
}`}
>
{uploadProgress && (
<div className="mb-3 px-4 py-2.5 rounded-lg bg-white/[0.04] text-xs text-white border border-white/10 inline-block">
{uploadProgress}
</div>
)}
{isLoading ? (
<div className="flex items-center justify-center py-12 text-[#86868B] text-sm">
<Loader2 size={14} className="animate-spin mr-2" /> Loading
</div>
) : error ? (
<div className="text-center py-12 text-rose-400 text-sm">{error}</div>
) : filtered.length === 0 ? (
<div className="text-center py-12 text-[#86868B] text-sm">
{searchQuery ? <>No files match &quot;{searchQuery}&quot;.</> : (
<div className="space-y-2">
<activeBucket.icon size={32} className="mx-auto opacity-40" />
<p>No files in {activeBucket.label} yet.</p>
<p className="text-xs">Drop a file here or click Upload. Drag a file from another tab to move it in.</p>
</div>
)}
</div>
) : viewMode === "grid" ? (
<GridView
items={filtered}
selected={selected}
renaming={renaming}
copiedPath={copiedPath}
onClickItem={(item, e) => {
if (hasSelection || e.shiftKey || e.metaKey || e.ctrlKey) toggleSelect(item.path, e);
else handlePick(item);
}}
onToggleSelect={(item, e) => toggleSelect(item.path, e)}
onStartRename={(item) => setRenaming({ path: item.path, value: item.name })}
onChangeRename={(value) => setRenaming((r) => r ? { ...r, value } : r)}
onSubmitRename={submitRename}
onCancelRename={() => setRenaming(null)}
onCopy={copyPath}
onDelete={(item) => deleteFiles([item.path])}
onDragStart={onItemDragStart}
hint={(name: string) => bucketHint(activeBucket, name)}
/>
) : (
<ListView
items={filtered}
selected={selected}
renaming={renaming}
copiedPath={copiedPath}
onClickItem={(item, e) => {
if (hasSelection || e.shiftKey || e.metaKey || e.ctrlKey) toggleSelect(item.path, e);
else handlePick(item);
}}
onToggleSelect={(item, e) => toggleSelect(item.path, e)}
onStartRename={(item) => setRenaming({ path: item.path, value: item.name })}
onChangeRename={(value) => setRenaming((r) => r ? { ...r, value } : r)}
onSubmitRename={submitRename}
onCancelRename={() => setRenaming(null)}
onCopy={copyPath}
onDelete={(item) => deleteFiles([item.path])}
onDragStart={onItemDragStart}
hint={(name: string) => bucketHint(activeBucket, name)}
/>
)}
</div>
{/* Footer hint */}
<div className="px-6 py-2.5 border-t border-white/5 text-[10px] text-[#86868B] flex items-center gap-4">
<span><kbd className="bg-white/5 border border-white/10 rounded px-1.5 py-0.5 font-mono">Click</kbd> open</span>
<span><kbd className="bg-white/5 border border-white/10 rounded px-1.5 py-0.5 font-mono">Click</kbd> select range</span>
<span><kbd className="bg-white/5 border border-white/10 rounded px-1.5 py-0.5 font-mono">Click</kbd> add to selection</span>
<span><kbd className="bg-white/5 border border-white/10 rounded px-1.5 py-0.5 font-mono">2× click</kbd> rename</span>
<span>Drag onto another tab to move</span>
</div>
</div>
</div>
);
}
// ─── Grid view ────────────────────────────────────────────────────────────
interface ViewProps {
items: AssetItem[];
selected: Set<string>;
renaming: { path: string; value: string } | null;
copiedPath: string | null;
onClickItem: (item: AssetItem, e: React.MouseEvent) => void;
onToggleSelect: (item: AssetItem, e: React.MouseEvent) => void;
onStartRename: (item: AssetItem) => void;
onChangeRename: (value: string) => void;
onSubmitRename: () => void;
onCancelRename: () => void;
onCopy: (item: AssetItem) => void;
onDelete: (item: AssetItem) => void;
onDragStart: (e: React.DragEvent, item: AssetItem) => void;
hint: (name: string) => string | null;
}
function GridView(props: ViewProps) {
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
{props.items.map((item) => {
const isSel = props.selected.has(item.path);
const isRen = props.renaming?.path === item.path;
const warning = props.hint(item.name);
return (
<div
key={item.path}
draggable
onDragStart={(e) => props.onDragStart(e, item)}
className={`group bg-white/[0.02] border rounded-xl overflow-hidden transition-all ${
isSel ? "border-[#00F0FF] ring-2 ring-[#00F0FF]/30" : "border-white/5 hover:border-white/15"
}`}
>
{/* Selection checkbox overlay */}
<button
onClick={(e) => { e.stopPropagation(); props.onToggleSelect(item, e); }}
className={`absolute m-2 z-10 w-5 h-5 rounded flex items-center justify-center transition-all ${
isSel ? "bg-[#00F0FF] text-black opacity-100"
: "bg-black/60 text-white/40 opacity-0 group-hover:opacity-100 hover:bg-black/80"
}`}
title="Select"
>
{isSel && <Check size={12} />}
</button>
<button
onClick={(e) => props.onClickItem(item, e)}
onDoubleClick={(e) => { e.stopPropagation(); props.onStartRename(item); }}
className="block w-full aspect-video bg-black overflow-hidden cursor-pointer relative"
>
{item.mediaType === "image" && item.publicUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={item.publicUrl} alt={item.name} className="w-full h-full object-cover group-hover:scale-105 transition-transform" loading="lazy" />
) : item.mediaType === "video" ? (
<div className="w-full h-full flex items-center justify-center bg-blue-500/5">
<Video size={28} className="text-blue-400/60" />
</div>
) : item.mediaType === "model" ? (
<div className="w-full h-full flex items-center justify-center bg-purple-500/5">
<Box size={28} className="text-purple-400/60" />
</div>
) : (
<div className="w-full h-full flex items-center justify-center">
<File size={28} className="text-[#86868B]/50" />
</div>
)}
</button>
<div className="p-2.5">
{isRen ? (
<input
autoFocus
type="text"
value={props.renaming!.value}
onChange={(e) => props.onChangeRename(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") props.onSubmitRename();
else if (e.key === "Escape") props.onCancelRename();
}}
onBlur={props.onSubmitRename}
className="w-full bg-black/60 border border-[#00F0FF]/40 text-white text-[11px] font-mono rounded px-2 py-1 outline-none"
/>
) : (
<div
onDoubleClick={() => props.onStartRename(item)}
className="text-[11px] text-white truncate font-medium cursor-text"
title="Double-click to rename"
>
{item.name}
</div>
)}
<div className="flex items-center justify-between mt-1.5">
<span className="text-[9px] text-[#86868B]">{item.size}</span>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => { e.stopPropagation(); props.onStartRename(item); }}
className="p-1 rounded text-[#86868B] hover:text-white hover:bg-white/5"
title="Rename"
>
<Pencil size={11} />
</button>
<button
onClick={(e) => { e.stopPropagation(); props.onCopy(item); }}
className="p-1 rounded text-[#86868B] hover:text-white hover:bg-white/5"
title="Copy URL"
>
{props.copiedPath === item.path ? <Check size={11} className="text-emerald-400" /> : <Copy size={11} />}
</button>
<button
onClick={(e) => { e.stopPropagation(); props.onDelete(item); }}
className="p-1 rounded text-rose-400 hover:bg-rose-500/10"
title="Delete"
>
<Trash2 size={11} />
</button>
</div>
</div>
{warning && (
<div className="text-[9px] text-amber-400/80 mt-1 leading-tight">{warning}</div>
)}
</div>
</div>
);
})}
</div>
);
}
// ─── List view ────────────────────────────────────────────────────────────
function ListView(props: ViewProps) {
return (
<div className="space-y-1">
{props.items.map((item) => {
const isSel = props.selected.has(item.path);
const isRen = props.renaming?.path === item.path;
return (
<div
key={item.path}
draggable
onDragStart={(e) => props.onDragStart(e, item)}
className={`flex items-center gap-3 px-3 py-2 rounded-lg group ${
isSel ? "bg-[#00F0FF]/10 border border-[#00F0FF]/30" : "hover:bg-white/[0.02] border border-transparent"
}`}
>
<button
onClick={(e) => { e.stopPropagation(); props.onToggleSelect(item, e); }}
className={`w-4 h-4 rounded flex items-center justify-center flex-shrink-0 ${
isSel ? "bg-[#00F0FF] text-black"
: "border border-white/20 text-transparent hover:border-white/40"
}`}
>
{isSel && <Check size={10} />}
</button>
<button
onClick={(e) => props.onClickItem(item, e)}
onDoubleClick={() => props.onStartRename(item)}
className="flex-shrink-0 w-12 h-12 rounded bg-black overflow-hidden"
>
{item.mediaType === "image" && item.publicUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={item.publicUrl} alt={item.name} className="w-full h-full object-cover" loading="lazy" />
) : item.mediaType === "video" ? (
<div className="w-full h-full flex items-center justify-center bg-blue-500/5"><Video size={16} className="text-blue-400/60" /></div>
) : item.mediaType === "model" ? (
<div className="w-full h-full flex items-center justify-center bg-purple-500/5"><Box size={16} className="text-purple-400/60" /></div>
) : (
<div className="w-full h-full flex items-center justify-center"><File size={16} className="text-[#86868B]/50" /></div>
)}
</button>
<div className="flex-1 min-w-0">
{isRen ? (
<input
autoFocus
type="text"
value={props.renaming!.value}
onChange={(e) => props.onChangeRename(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") props.onSubmitRename();
else if (e.key === "Escape") props.onCancelRename();
}}
onBlur={props.onSubmitRename}
className="w-full bg-black/60 border border-[#00F0FF]/40 text-white text-xs font-mono rounded px-2 py-1 outline-none"
/>
) : (
<button
onClick={(e) => props.onClickItem(item, e)}
onDoubleClick={() => props.onStartRename(item)}
className="block text-left w-full"
>
<div className="text-xs text-white font-medium truncate">{item.name}</div>
<div className="text-[10px] text-[#86868B]">{item.size}</div>
</button>
)}
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => props.onStartRename(item)}
className="p-1.5 rounded text-[#86868B] hover:text-white hover:bg-white/5"
title="Rename"
>
<Pencil size={12} />
</button>
<button
onClick={() => props.onCopy(item)}
className="p-1.5 rounded text-[#86868B] hover:text-white hover:bg-white/5"
title="Copy URL"
>
{props.copiedPath === item.path ? <Check size={12} className="text-emerald-400" /> : <Copy size={12} />}
</button>
<button
onClick={() => props.onDelete(item)}
className="p-1.5 rounded text-rose-400 hover:bg-rose-500/10"
title="Delete"
>
<Trash2 size={12} />
</button>
</div>
</div>
);
})}
</div>
);
}
+166
View File
@@ -0,0 +1,166 @@
"use client";
// ─────────────────────────────────────────────────────────────────────────────
// Tiny toast + confirm dialog primitives for HQ Command panels.
// Replaces the browser-native alert()/confirm() that were jumping users
// out of the dark CMS aesthetic. Zero deps, ~80 lines of state.
// ─────────────────────────────────────────────────────────────────────────────
import { createContext, useCallback, useContext, useState } from "react";
import { CheckCircle2, AlertCircle, Info, X, AlertTriangle } from "lucide-react";
type ToastTone = "success" | "error" | "info";
interface Toast {
id: number;
tone: ToastTone;
message: string;
}
interface ConfirmRequest {
id: number;
title: string;
message: string;
confirmLabel: string;
cancelLabel: string;
destructive: boolean;
resolve: (ok: boolean) => void;
}
interface HqUiCtx {
toast: (message: string, tone?: ToastTone) => void;
confirm: (opts: {
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
destructive?: boolean;
}) => Promise<boolean>;
}
const Ctx = createContext<HqUiCtx | null>(null);
let nextToastId = 1;
let nextConfirmId = 1;
export function HqUiProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const [pending, setPending] = useState<ConfirmRequest | null>(null);
const toast = useCallback((message: string, tone: ToastTone = "info") => {
const id = nextToastId++;
setToasts((prev) => [...prev, { id, tone, message }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, tone === "error" ? 5000 : 3000);
}, []);
const confirm = useCallback(
(opts: {
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
destructive?: boolean;
}) =>
new Promise<boolean>((resolve) => {
const id = nextConfirmId++;
setPending({
id,
title: opts.title,
message: opts.message,
confirmLabel: opts.confirmLabel || "Confirm",
cancelLabel: opts.cancelLabel || "Cancel",
destructive: !!opts.destructive,
resolve,
});
}),
[]
);
const respond = (ok: boolean) => {
if (!pending) return;
pending.resolve(ok);
setPending(null);
};
return (
<Ctx.Provider value={{ toast, confirm }}>
{children}
{/* Toast stack — bottom right */}
<div className="fixed bottom-6 right-6 z-[200] flex flex-col gap-2 pointer-events-none">
{toasts.map((t) => {
const Icon = t.tone === "success" ? CheckCircle2 : t.tone === "error" ? AlertCircle : Info;
const accent =
t.tone === "success" ? "text-emerald-400 border-emerald-500/30 bg-emerald-500/10"
: t.tone === "error" ? "text-rose-400 border-rose-500/30 bg-rose-500/10"
: "text-[#00F0FF] border-[#00F0FF]/30 bg-[#00F0FF]/10";
return (
<div
key={t.id}
className={`pointer-events-auto flex items-start gap-3 ${accent} backdrop-blur-md border rounded-xl px-4 py-3 max-w-sm shadow-2xl animate-in slide-in-from-right-4 duration-200`}
>
<Icon size={16} className="mt-0.5 flex-shrink-0" />
<div className="text-sm leading-relaxed flex-1">{t.message}</div>
<button
onClick={() => setToasts((prev) => prev.filter((x) => x.id !== t.id))}
className="text-current opacity-50 hover:opacity-100"
>
<X size={14} />
</button>
</div>
);
})}
</div>
{/* Confirm dialog — centered modal */}
{pending && (
<div className="fixed inset-0 z-[150] bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-[#111] border border-white/10 rounded-3xl p-6 max-w-md w-full shadow-2xl">
<div className="flex items-start gap-3 mb-4">
<div
className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ${
pending.destructive
? "bg-rose-500/10 text-rose-400"
: "bg-[#00F0FF]/10 text-[#00F0FF]"
}`}
>
{pending.destructive ? <AlertTriangle size={18} /> : <Info size={18} />}
</div>
<div className="flex-1">
<h3 className="text-base font-medium text-white mb-1">{pending.title}</h3>
<p className="text-sm text-[#86868B] leading-relaxed">{pending.message}</p>
</div>
</div>
<div className="flex gap-2 justify-end mt-6">
<button
onClick={() => respond(false)}
className="px-4 py-2 text-sm font-medium text-[#86868B] hover:text-white transition-colors"
>
{pending.cancelLabel}
</button>
<button
onClick={() => respond(true)}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
pending.destructive
? "bg-rose-500 text-white hover:bg-rose-400"
: "bg-[#00F0FF] text-black hover:bg-[#00F0FF]/80"
}`}
>
{pending.confirmLabel}
</button>
</div>
</div>
</div>
)}
</Ctx.Provider>
);
}
export function useHqUi(): HqUiCtx {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useHqUi must be used inside <HqUiProvider>");
return ctx;
}
+31 -6
View File
@@ -1,15 +1,17 @@
import { Link } from "@/i18n/routing"; import { Link } from "@/i18n/routing";
import { Linkedin, Instagram, Youtube, Mail } from "lucide-react"; import { Linkedin, Instagram, Youtube, Mail, Phone } from "lucide-react";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getLocalizedData } from "@/lib/i18nHelper"; import { getLocalizedData } from "@/lib/i18nHelper";
import { getTranslations, getLocale } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { getFooterSettings, getSocialLinks } from "@/lib/siteSettings"; import { getFooterSettings, getSocialLinks } from "@/lib/siteSettings";
import { AiContactButton, AiFooterLink } from "@/components/ui/AiTriggerButton"; import { AiContactButton, AiFooterLink } from "@/components/ui/AiTriggerButton";
export default async function Footer() { // `locale` comes from the parent layout. Reading it via getLocale() here
const locale = await getLocale(); // would call cookies()/headers() under the hood, breaking ISR with
const t = await getTranslations("Footer"); // DYNAMIC_SERVER_USAGE — passing it explicitly keeps everything static.
export default async function Footer({ locale }: { locale: string }) {
const t = await getTranslations({ locale, namespace: "Footer" });
const [footer, social] = await Promise.all([ const [footer, social] = await Promise.all([
getFooterSettings(locale), getFooterSettings(locale),
@@ -20,7 +22,7 @@ export default async function Footer() {
try { try {
const rawApps = await prisma.application.findMany({ const rawApps = await prisma.application.findMany({
where: { isActive: true }, where: { isActive: true },
orderBy: { createdAt: "asc" }, orderBy: [{ order: "asc" }, { createdAt: "asc" }],
take: 4, take: 4,
}); });
activeApps = rawApps.map((app: any) => getLocalizedData(app, locale)); activeApps = rawApps.map((app: any) => getLocalizedData(app, locale));
@@ -97,6 +99,29 @@ export default async function Footer() {
{footer.hqRegion}, {footer.hqCountry} {footer.hqRegion}, {footer.hqCountry}
</p> </p>
{(footer.hqEmail || footer.hqPhone) && (
<div className="flex flex-col gap-2 text-[#86868B] font-light">
{footer.hqEmail && (
<a
href={`mailto:${footer.hqEmail}`}
className="inline-flex items-center gap-2 hover:text-[#0066CC] dark:hover:text-[#00F0FF] transition-colors group"
>
<Mail size={14} className="text-[#86868B] group-hover:text-[#0066CC] dark:group-hover:text-[#00F0FF]" />
<span className="break-all">{footer.hqEmail}</span>
</a>
)}
{footer.hqPhone && (
<a
href={`tel:${footer.hqPhone.replace(/\s+/g, "")}`}
className="inline-flex items-center gap-2 hover:text-[#0066CC] dark:hover:text-[#00F0FF] transition-colors group"
>
<Phone size={14} className="text-[#86868B] group-hover:text-[#0066CC] dark:group-hover:text-[#00F0FF]" />
<span>{footer.hqPhone}</span>
</a>
)}
</div>
)}
{socialLinks.length > 0 && ( {socialLinks.length > 0 && (
<div className="flex items-center gap-3 mt-2"> <div className="flex items-center gap-3 mt-2">
{socialLinks.map(({ url, icon: Icon, label }) => ( {socialLinks.map(({ url, icon: Icon, label }) => (
+18 -6
View File
@@ -13,6 +13,7 @@ const NAV_KEYS = [
{ key: "globalMap", href: "/#global" }, { key: "globalMap", href: "/#global" },
{ key: "ourStory", href: "/#our-story" }, { key: "ourStory", href: "/#our-story" },
{ key: "insideFlux", href: "/news" }, { key: "insideFlux", href: "/news" },
{ key: "team", href: "/team" },
{ key: "parts", href: "/parts" }, { key: "parts", href: "/parts" },
]; ];
@@ -62,20 +63,31 @@ export default function NavBar() {
}; };
document.addEventListener("mousedown", handleClickOutside); document.addEventListener("mousedown", handleClickOutside);
// Verificar si existe la cookie "flux_b2b_session" // Cookie check is now event-driven (no setInterval polling).
// Triggers:
// - Initial mount
// - "flux:session-changed" CustomEvent dispatched by AuthModal on login/logout
// - visibilitychange (catches logout-in-another-tab)
// - storage events (multi-tab logout via shared cookie)
const checkSession = () => { const checkSession = () => {
const cookies = document.cookie.split("; "); const cookies = document.cookie.split("; ");
const sessionExists = cookies.some(c => c.startsWith("flux_b2b_session=")); const sessionExists = cookies.some((c) => c.startsWith("flux_b2b_session="));
setHasSession(sessionExists); setHasSession(sessionExists);
}; };
checkSession(); checkSession();
// Re-chequear cuando el modal dispare un refresh
const interval = setInterval(checkSession, 2000); const handleVisibility = () => {
if (document.visibilityState === "visible") checkSession();
};
window.addEventListener("flux:session-changed", checkSession);
document.addEventListener("visibilitychange", handleVisibility);
return () => { return () => {
window.removeEventListener("scroll", handleScroll); window.removeEventListener("scroll", handleScroll);
document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener("mousedown", handleClickOutside);
clearInterval(interval); window.removeEventListener("flux:session-changed", checkSession);
document.removeEventListener("visibilitychange", handleVisibility);
}; };
}, []); }, []);
@@ -6,18 +6,19 @@ import { ArrowRight, Zap, Scale, Cpu } from "lucide-react";
import { Link } from "@/i18n/routing"; import { Link } from "@/i18n/routing";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { getIconForSlug } from "@/lib/applicationIcons"; import { getIconForSlug } from "@/lib/applicationIcons";
import type { AppCard, DashboardMetric } from "@/types/cms";
import { parseJsonField } from "@/types/cms";
export default function ApplicationsDashboard({ dbApps = [] }: { dbApps?: any[] }) { export default function ApplicationsDashboard({ dbApps = [] }: { dbApps?: AppCard[] }) {
const activeApps = dbApps.filter(app => app.isActive); const activeApps = dbApps.filter((app) => app.isActive);
if (!activeApps || activeApps.length === 0) return null; if (!activeApps || activeApps.length === 0) return null;
const [activeSlug, setActiveSlug] = useState(activeApps[0]?.slug); const [activeSlug, setActiveSlug] = useState<string | undefined>(activeApps[0]?.slug);
const activeApp = activeApps.find(app => app.slug === activeSlug) || activeApps[0]; const activeApp = activeApps.find((app) => app.slug === activeSlug) || activeApps[0];
const t = useTranslations("AppsDashboard"); // 🔥 LLAMAMOS AL DICCIONARIO const t = useTranslations("AppsDashboard");
let metrics = []; const metrics = parseJsonField<DashboardMetric[]>(activeApp?.dashboardMetricsJson, []);
try { metrics = JSON.parse(activeApp.dashboardMetricsJson || "[]"); } catch (e) {}
const triggerFluxAI = (prompt: string) => { const triggerFluxAI = (prompt: string) => {
window.dispatchEvent(new CustomEvent("flux:trigger-ai", { detail: { prompt } })); window.dispatchEvent(new CustomEvent("flux:trigger-ai", { detail: { prompt } }));
+2 -1
View File
@@ -9,6 +9,7 @@ import { MapPin, Calendar, X, ArrowUpRight, Globe, Package, Building2, Layers }
import Image from "next/image"; import Image from "next/image";
import CaseStudyModal, { CaseStudyData } from "@/components/ui/CaseStudyModal"; import CaseStudyModal, { CaseStudyData } from "@/components/ui/CaseStudyModal";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import type { AppCard, NodeMarker } from "@/types/cms";
const RADIUS = 2; const RADIUS = 2;
const CAM_FOV = 50; const CAM_FOV = 50;
@@ -536,7 +537,7 @@ function NodeCard({ node, isDark, onClose, onViewCase }: {
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// MAIN COMPONENT // MAIN COMPONENT
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
export default function GlobalOperations({ dbNodes = [], dbApps = [] }: { dbNodes?: any[]; dbApps?: any[] }) { export default function GlobalOperations({ dbNodes = [], dbApps = [] }: { dbNodes?: NodeMarker[]; dbApps?: AppCard[] }) {
const [filter, setFilter] = useState("all"); const [filter, setFilter] = useState("all");
const [subFilter, setSubFilter] = useState<string | null>(null); const [subFilter, setSubFilter] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
@@ -1,310 +0,0 @@
"use client";
import { useState, useRef, Suspense, useEffect } from "react";
import { Canvas, useFrame, useLoader, useThree } from "@react-three/fiber";
import { OrbitControls, QuadraticBezierLine } from "@react-three/drei";
import * as THREE from "three";
import { motion, AnimatePresence } from "framer-motion";
import { MapPin, Calendar, History, X, ArrowUpRight } from "lucide-react";
import CaseStudyModal, { CaseStudyData } from "@/components/ui/CaseStudyModal";
import { useLocale, useTranslations } from "next-intl";
const RADIUS = 2;
function latLongToVector3(lat: number, lon: number, radius: number) {
const phi = (90 - lat) * (Math.PI / 180);
const theta = (lon + 180) * (Math.PI / 180);
const x = -(radius * Math.sin(phi) * Math.cos(theta));
const z = (radius * Math.sin(phi) * Math.sin(theta));
const y = (radius * Math.cos(phi));
return new THREE.Vector3(x, y, z);
}
// ── COMPONENTE OPTIMIZADO 1: TEXTURA DE LA TIERRA CON ALTA RESOLUCIÓN ──
function EarthMesh({ isDark }: { isDark: boolean }) {
const earthTexture = useLoader(THREE.TextureLoader, "https://unpkg.com/three-globe/example/img/earth-water.png");
const { gl } = useThree();
// 🔥 Filtro de hardware para forzar nitidez al hacer Zoom
useEffect(() => {
if (earthTexture) {
earthTexture.anisotropy = gl.capabilities.getMaxAnisotropy(); // Máximo detalle en ángulos inclinados
earthTexture.minFilter = THREE.LinearMipmapLinearFilter;
earthTexture.magFilter = THREE.LinearFilter;
earthTexture.colorSpace = THREE.SRGBColorSpace; // Colores más vibrantes y definidos
earthTexture.generateMipmaps = true;
earthTexture.needsUpdate = true;
}
}, [earthTexture, gl]);
return (
<mesh>
{/* Regresamos a 64 segmentos para optimizar rendimiento (la nitidez ahora viene de la textura) */}
<sphereGeometry args={[RADIUS * 0.99, 64, 64]} />
<meshBasicMaterial
map={earthTexture}
color={isDark ? "#06F5E1" : "#86868B"}
transparent
opacity={isDark ? 0.4 : 0.3}
blending={isDark ? THREE.AdditiveBlending : THREE.NormalBlending}
/>
</mesh>
);
}
// ── COMPONENTE OPTIMIZADO 2: EL NODO INTELIGENTE QUE RESPONDE AL ZOOM ──
function MapNode({ marker, isSelected, hqPosition, onSelectMarker, isDark }: any) {
const meshRef = useRef<THREE.Group>(null);
const pos = latLongToVector3(marker.lat, marker.lon, RADIUS);
const isHQ = marker.nodeType === "hq";
const isEvent = marker.nodeType === "event";
const nodeColor = isHQ ? (isDark ? "#FFFFFF" : "#1D1D1F") : isEvent ? "#A855F7" : "#0066CC";
const baseSize = isHQ ? 0.04 : isEvent ? 0.035 : 0.025;
useFrame(({ camera }) => {
if (!meshRef.current) return;
const dist = camera.position.length();
const scaleFactor = Math.max(0.2, dist / 12);
const finalScale = isSelected ? scaleFactor * 1.8 : scaleFactor;
meshRef.current.scale.set(finalScale, finalScale, finalScale);
});
const distance = hqPosition.distanceTo(pos);
const arcElevation = RADIUS + (distance * 0.25) + 0.1;
const midPoint = hqPosition.clone().lerp(pos, 0.5).normalize().multiplyScalar(arcElevation);
return (
<group>
<group ref={meshRef} position={pos}>
<mesh>
<sphereGeometry args={[baseSize, 32, 32]} />
<meshBasicMaterial color={nodeColor} />
</mesh>
{/* CAJA DE COLISIÓN AMPLIADA */}
<mesh
visible={false}
onClick={(e) => {
e.stopPropagation();
onSelectMarker(isSelected ? null : marker.id);
}}
onPointerOver={(e) => { e.stopPropagation(); document.body.style.cursor = 'pointer'; }}
onPointerOut={(e) => { e.stopPropagation(); document.body.style.cursor = 'auto'; }}
>
<sphereGeometry args={[baseSize * 4, 16, 16]} />
<meshBasicMaterial transparent opacity={0} />
</mesh>
</group>
{!isHQ && (
<QuadraticBezierLine
start={hqPosition}
end={pos}
mid={midPoint}
color={nodeColor}
lineWidth={isSelected ? 2.5 : 1.5}
transparent
opacity={isSelected ? 0.9 : 0.25}
/>
)}
</group>
);
}
// ── COMPONENTE DE ENSAMBLAJE DE LA ESFERA ──
function HologramSphere({ activeFilter, activeSubFilter, selectedMarker, onSelectMarker, isDark, dbNodes, hqPosition }: any) {
const globeRef = useRef<THREE.Group>(null);
// 🔥 MAGIA DE ROTACIÓN INTELIGENTE 🔥
useFrame(({ camera }) => {
// La cámara inicia en Z=7. Si el usuario hace zoom in (distancia < 6.5), detenemos la rotación.
// Si vuelve a alejar el mapa a su estado normal (distancia > 6.5), retoma la rotación.
const distance = camera.position.length();
if (globeRef.current && !selectedMarker && distance > 6.5) {
globeRef.current.rotation.y += 0.0005;
}
});
return (
<group ref={globeRef}>
<mesh><sphereGeometry args={[RADIUS * 1.04, 64, 64]} /><meshBasicMaterial color={isDark ? "#00F0FF" : "#0066CC"} transparent opacity={isDark ? 0.1 : 0.05} side={THREE.BackSide} blending={THREE.AdditiveBlending} depthWrite={false} /></mesh>
<mesh><sphereGeometry args={[RADIUS * 0.98, 64, 64]} /><meshBasicMaterial color={isDark ? "#050505" : "#E5E5EA"} /></mesh>
{/* Esfera Terrestre mejorada con texturas nítidas */}
<EarthMesh isDark={isDark} />
<mesh><sphereGeometry args={[RADIUS, 64, 64]} /><meshBasicMaterial color={isDark ? "#0066CC" : "#86868B"} wireframe transparent opacity={0.06} /></mesh>
{dbNodes.map((marker: any) => {
const isHQ = marker.nodeType === "hq";
const isEvent = marker.nodeType === "event";
const matchesMain = activeFilter === "all" || (activeFilter === "installation" && !isEvent && !isHQ) || (activeFilter === "event" && isEvent) || (activeFilter === "legacy" && isHQ);
const matchesSub = !activeSubFilter || marker.application === activeSubFilter || isHQ;
const isVisible = matchesMain && matchesSub;
if (!isVisible) return null;
return (
<MapNode
key={marker.id}
marker={marker}
isSelected={selectedMarker === marker.id}
hqPosition={hqPosition}
onSelectMarker={onSelectMarker}
isDark={isDark}
/>
);
})}
</group>
);
}
// ── INTERFAZ GRÁFICA PRINCIPAL ──
export default function GlobalOperations({ dbNodes = [], dbApps = [] }: { dbNodes?: any[], dbApps?: any[] }) {
const [activeFilter, setActiveFilter] = useState("all");
const [activeSubFilter, setActiveSubFilter] = useState<string | null>(null);
const [selectedMarkerId, setSelectedMarkerId] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDark, setIsDark] = useState(false);
const t = useTranslations("GlobalOperations");
const dynamicSubFilters = dbApps
.filter(app => app.isActive)
.map(app => ({ id: app.slug, label: app.title }));
useEffect(() => {
const checkTheme = () => setIsDark(document.documentElement.classList.contains("dark"));
checkTheme();
const observer = new MutationObserver(checkTheme);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}, []);
const filters = [
{ id: "all", label: t("filterAll"), icon: MapPin },
{ id: "installation", label: t("filterInstallations"), icon: MapPin },
{ id: "event", label: t("filterEvents"), icon: Calendar },
{ id: "legacy", label: t("filterHQ"), icon: History }
];
const selectedData = dbNodes.find(d => d.id === selectedMarkerId);
const hqNode = dbNodes.find(d => d.application === "hq");
const hqLat = hqNode ? hqNode.lat : 45.78;
const hqLon = hqNode ? hqNode.lon : 11.76;
const hqPosition = latLongToVector3(hqLat, hqLon, RADIUS);
const handleMainFilter = (id: string) => {
setActiveFilter(id);
setActiveSubFilter(null);
setSelectedMarkerId(null);
};
return (
<>
<section id="global" className="relative w-full max-w-7xl mx-auto px-6 py-32 z-10">
<div className="flex flex-col lg:flex-row gap-12 items-center h-auto lg:h-[700px]">
<div className="w-full lg:w-1/3 flex flex-col h-full justify-center mb-12 lg:mb-0">
<div className="p-6 md:p-8 -ml-6 md:-ml-8 rounded-3xl bg-white/40 dark:bg-[#0A0A0C]/50 backdrop-blur-md border border-white/50 dark:border-white/5 shadow-sm transition-colors">
<h2 className="text-sm font-semibold tracking-widest text-[#0066CC] dark:text-[#4DA6FF] uppercase mb-4">{t("subtitle")}</h2>
<h3 className="text-4xl md:text-5xl font-light text-[#1D1D1F] dark:text-[#F5F5F7] tracking-tight mb-8 leading-[1.1] transition-colors">
{t("title1")} <br /><span className="font-medium">{t("title2")}</span>
</h3>
<div className="flex flex-wrap gap-2 mb-4">
{filters.map((f) => (
<button key={f.id} onClick={() => handleMainFilter(f.id)} className={`px-4 py-2 rounded-full text-sm font-medium transition-all duration-300 border ${ activeFilter === f.id ? "bg-[#1D1D1F] dark:bg-white text-white dark:text-[#1D1D1F] border-[#1D1D1F] dark:border-white shadow-md" : "bg-white/60 dark:bg-white/5 text-[#86868B] dark:text-[#A1A1A6] border-white/60 dark:border-white/10 hover:text-[#1D1D1F] dark:hover:text-white" } backdrop-blur-md`}>
{f.label}
</button>
))}
</div>
<AnimatePresence>
{activeFilter === "installation" && (
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }} className="flex flex-wrap gap-2 mb-4 overflow-hidden">
<span className="w-full text-xs font-semibold uppercase tracking-widest text-[#86868B] mb-1">{t("filterByApp")}</span>
{dynamicSubFilters.map((sub) => (
<button key={sub.id} onClick={() => { setActiveSubFilter(activeSubFilter === sub.id ? null : sub.id); setSelectedMarkerId(null); }} className={`px-3 py-1.5 rounded-full text-xs transition-all duration-200 border ${ activeSubFilter === sub.id ? "bg-[#0066CC]/10 dark:bg-[#0066CC]/20 text-[#0066CC] dark:text-[#4DA6FF] border-[#0066CC]/30 font-medium" : "bg-white/40 dark:bg-transparent text-[#86868B] border-transparent hover:text-[#1D1D1F] dark:hover:text-white" }`}>
{sub.label}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
<AnimatePresence mode="wait">
{!selectedMarkerId && (
<motion.div key={activeFilter + (activeSubFilter || "")} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} className="bg-white/60 dark:bg-[#1D1D1F]/60 backdrop-blur-2xl border border-white/40 dark:border-white/10 shadow-[0_8px_32px_rgba(0,0,0,0.04)] dark:shadow-[0_8px_32px_rgba(0,0,0,0.4)] rounded-3xl p-8 mt-4 transition-colors">
<h4 className="text-[#86868B] text-xs font-semibold uppercase tracking-wider mb-2 flex items-center gap-2"><span className="w-2 h-2 rounded-full bg-[#0066CC] animate-pulse"></span>{t("networkStatus")}</h4>
<p className="text-xl font-light text-[#1D1D1F] dark:text-[#F5F5F7] mb-6 leading-relaxed transition-colors">
{activeSubFilter
? t("statusShowing", { app: activeSubFilter.replace("-", " ") })
: t("statusTracking", { count: dbNodes.filter(n =>
(activeFilter === "all") ||
(activeFilter === "installation" && n.nodeType === "installation") ||
(activeFilter === "event" && n.nodeType === "event") ||
(activeFilter === "legacy" && n.nodeType === "hq")
).length })}
</p>
</motion.div>
)}
</AnimatePresence>
</div>
<div className="w-full lg:w-2/3 h-[500px] lg:h-full relative rounded-[2.5rem] overflow-hidden bg-white/30 dark:bg-transparent backdrop-blur-sm dark:backdrop-blur-none border border-white/40 dark:border-transparent transition-all duration-700">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[150%] h-[150%] bg-[radial-gradient(circle_at_center,rgba(0,102,204,0.15)_0%,transparent_60%)] hidden dark:block pointer-events-none blur-3xl" />
<div className="absolute top-4 right-4 z-10 text-[10px] font-mono text-[#86868B] dark:text-[#A1A1A6] tracking-widest uppercase bg-white/50 dark:bg-white/5 px-3 py-1 rounded-full backdrop-blur-md border border-white/50 dark:border-white/10 transition-colors pointer-events-none">
{t("helpText")}
</div>
<AnimatePresence>
{selectedData && (
<motion.div initial={{ opacity: 0, x: 20, y: 20 }} animate={{ opacity: 1, x: 0, y: 0 }} exit={{ opacity: 0, x: 20, y: 20 }} className="absolute bottom-4 right-4 z-20 w-[calc(100%-2rem)] md:w-80 bg-white/80 dark:bg-[#1D1D1F]/80 backdrop-blur-2xl border border-white/60 dark:border-white/10 shadow-lg dark:shadow-[0_10px_40px_rgba(0,0,0,0.5)] rounded-2xl p-6 transition-colors">
<div className="flex justify-between items-start mb-4">
<div className="flex items-center gap-2 text-[#0066CC] dark:text-[#4DA6FF]">
<MapPin size={14} />
<span className="text-[10px] font-semibold uppercase tracking-wider">
{selectedData.nodeType === 'event' ? t("typeEvent") : selectedData.nodeType === 'hq' ? t("typeHQ") : t("typeInstall")}
</span>
</div>
<button onClick={() => setSelectedMarkerId(null)} className="text-[#86868B] hover:text-[#1D1D1F] dark:hover:text-white transition-colors">
<X size={16} />
</button>
</div>
<h4 className="text-xl font-medium text-[#1D1D1F] dark:text-[#F5F5F7] mb-1">{selectedData.title}</h4>
<p className="text-sm text-[#86868B] dark:text-[#A1A1A6] mb-4">{selectedData.location}</p>
<div className="bg-[#F5F5F7] dark:bg-white/5 rounded-lg p-3 mb-4 border border-transparent dark:border-white/5">
<span className="text-[10px] uppercase tracking-wider text-[#86868B] dark:text-[#A1A1A6] block mb-1">{t("statusDetails")}</span>
<span className="text-sm font-medium text-[#1D1D1F] dark:text-[#F5F5F7]">{selectedData.stats}</span>
</div>
<button onClick={() => setIsModalOpen(true)} className="w-full flex justify-center items-center gap-2 bg-[#1D1D1F] dark:bg-[#0066CC] text-white py-2.5 rounded-xl text-sm font-medium hover:bg-[#0066CC] dark:hover:bg-[#4DA6FF] transition-colors">
{t("viewCaseStudy")} <ArrowUpRight size={14} />
</button>
</motion.div>
)}
</AnimatePresence>
<Canvas camera={{ position: [0, 0, 7], fov: 55 }} dpr={[1, 2]} style={{ touchAction: "none" }}>
<ambientLight intensity={1.5} />
<directionalLight position={[10, 10, 5]} intensity={2} />
<OrbitControls enableZoom={true} minDistance={3} maxDistance={12} enablePan={false} autoRotate={false} />
<Suspense fallback={null}>
<HologramSphere activeFilter={activeFilter} activeSubFilter={activeSubFilter} selectedMarker={selectedMarkerId} onSelectMarker={setSelectedMarkerId} isDark={isDark} dbNodes={dbNodes} hqPosition={hqPosition} />
</Suspense>
</Canvas>
</div>
</div>
</section>
<CaseStudyModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} data={selectedData as CaseStudyData || null} />
</>
);
}
+7 -5
View File
@@ -1,10 +1,12 @@
import { ArrowRight } from "lucide-react"; import { ArrowRight } from "lucide-react";
import { Link } from "@/i18n/routing"; import { Link } from "@/i18n/routing";
// 🔥 IMPORTAMOS LA VERSIÓN DE SERVIDOR import { getTranslations } from "next-intl/server";
import { getTranslations } from "next-intl/server";
export default async function PatrizioLegacy() { // `locale` is passed in from the parent server component (the page).
const t = await getTranslations("PatrizioLegacy"); // Calling getTranslations without an explicit locale forces next-intl to
// read cookies/headers, which trips DYNAMIC_SERVER_USAGE under ISR.
export default async function PatrizioLegacy({ locale }: { locale: string }) {
const t = await getTranslations({ locale, namespace: "PatrizioLegacy" });
return ( return (
<section id="legacy" className="relative w-full max-w-7xl mx-auto px-6 py-32 md:py-48 z-10"> <section id="legacy" className="relative w-full max-w-7xl mx-auto px-6 py-32 md:py-48 z-10">
+52
View File
@@ -0,0 +1,52 @@
// src/components/seo/Breadcrumbs.tsx
// ─────────────────────────────────────────────────────────────────────────────
// Visible breadcrumb navigation trail — complements the JSON-LD BreadcrumbList
// already rendered by individual pages.
//
// Design: Apple-clean, muted, small text — blends with the hero overlays
// on article and application detail pages.
// ─────────────────────────────────────────────────────────────────────────────
import Link from "next/link";
import { ChevronRight } from "lucide-react";
export interface BreadcrumbItem {
name: string;
url: string;
}
export default function Breadcrumbs({ items }: { items: BreadcrumbItem[] }) {
if (items.length < 2) return null;
return (
<nav aria-label="Breadcrumb" className="mb-4">
<ol className="flex items-center gap-1 flex-wrap text-xs">
{items.map((item, idx) => {
const isLast = idx === items.length - 1;
return (
<li key={item.url} className="flex items-center gap-1">
{idx > 0 && (
<ChevronRight size={11} className="text-[#86868B]/40 shrink-0" />
)}
{isLast ? (
<span
aria-current="page"
className="text-[#1D1D1F] dark:text-white/90 font-medium truncate max-w-[220px]"
>
{item.name}
</span>
) : (
<Link
href={item.url}
className="text-[#86868B] hover:text-[#0066CC] dark:hover:text-[#4DA6FF] transition-colors"
>
{item.name}
</Link>
)}
</li>
);
})}
</ol>
</nav>
);
}
+22
View File
@@ -0,0 +1,22 @@
// src/components/seo/JsonLd.tsx
// ─────────────────────────────────────────────────────────────────────────────
// Server component that emits a single JSON-LD <script> tag.
// Pass either one schema or an array — they're merged into one script.
// ─────────────────────────────────────────────────────────────────────────────
interface JsonLdProps {
data: object | object[];
}
export default function JsonLd({ data }: JsonLdProps) {
const payload = Array.isArray(data) ? data : [data];
return (
<script
type="application/ld+json"
// We trust our own server-side payload — no user content reaches here.
dangerouslySetInnerHTML={{
__html: JSON.stringify(payload.length === 1 ? payload[0] : payload),
}}
/>
);
}
+9 -6
View File
@@ -2,19 +2,22 @@
import { ArrowUpRight } from "lucide-react"; import { ArrowUpRight } from "lucide-react";
// 1. El botón principal (Contact Engineering) // 1. El botón principal (Contact FLUX Team)
// Broadened from "Contact FLUX Engineering" so it reads as an invitation for
// ANY enquiry — pricing, general questions, a demo, partnerships — not only
// technical/engineering consultations.
export function AiContactButton() { export function AiContactButton() {
const handleContactEngineering = () => { const handleContact = () => {
const prompt = "I am ready to optimize my production. I would like to schedule a technical consultation with FLUX Engineering to explore custom RF solutions and calculate my ROI."; const prompt = "I'd like to get in touch with the FLUX team. I have a few questions and would like to explore how FLUX can help — whether that's energy savings, a custom solution, pricing, availability, or just learning more.";
window.dispatchEvent(new CustomEvent("flux:trigger-ai", { detail: { prompt } })); window.dispatchEvent(new CustomEvent("flux:trigger-ai", { detail: { prompt } }));
}; };
return ( return (
<button <button
onClick={handleContactEngineering} onClick={handleContact}
className="flex items-center gap-3 bg-white dark:bg-[#00F0FF] text-[#1D1D1F] px-8 py-4 rounded-full font-semibold hover:scale-105 hover:shadow-[0_0_30px_rgba(0,102,204,0.4)] dark:hover:shadow-[0_0_30px_rgba(0,240,255,0.4)] transition-all duration-300" className="flex items-center gap-3 bg-white dark:bg-[#00F0FF] text-[#1D1D1F] px-8 py-4 rounded-full font-semibold hover:scale-105 hover:shadow-[0_0_30px_rgba(0,102,204,0.4)] dark:hover:shadow-[0_0_30px_rgba(0,240,255,0.4)] transition-all duration-300"
> >
Contact FLUX Engineering <ArrowUpRight size={18} /> Contact FLUX Team <ArrowUpRight size={18} />
</button> </button>
); );
} }
+27 -3
View File
@@ -1,10 +1,12 @@
"use client"; "use client";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { X, MapPin, Calendar, Leaf, CheckCircle2, Factory, Presentation, Image as ImageIcon } from "lucide-react"; import { X, MapPin, Calendar, Leaf, CheckCircle2, Factory, Presentation, Image as ImageIcon, ArrowRight } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Link } from "@/i18n/routing";
import { trackEvent } from "@/lib/analytics/gtag";
export interface CaseStudyData { export interface CaseStudyData {
id: string; id: string;
@@ -298,13 +300,35 @@ export default function CaseStudyModal({ isOpen, onClose, data }: ModalProps) {
<div className="col-span-2 md:col-span-1 bg-[#F5F5F7] dark:bg-[#1D1D1F] p-5 rounded-2xl border border-transparent dark:border-white/5 flex flex-col justify-center"> <div className="col-span-2 md:col-span-1 bg-[#F5F5F7] dark:bg-[#1D1D1F] p-5 rounded-2xl border border-transparent dark:border-white/5 flex flex-col justify-center">
<span className="text-[10px] uppercase tracking-widest text-[#86868B] block mb-1">{t("systemStatus")}</span> <span className="text-[10px] uppercase tracking-widest text-[#86868B] block mb-1">{t("systemStatus")}</span>
<span className="flex items-center gap-2 text-sm font-medium text-emerald-600 dark:text-emerald-400"> <span className="flex items-center gap-2 text-sm font-medium text-emerald-600 dark:text-emerald-400">
<CheckCircle2 size={16} /> <CheckCircle2 size={16} />
{/* 🔥 AQUÍ ESTABA EL ERROR: Simplificamos la lógica 🔥 */} {/* 🔥 AQUÍ ESTABA EL ERROR: Simplificamos la lógica 🔥 */}
{isEvent ? (isUpcoming ? t("scheduled") : t("concluded")) : t("operational")} {isEvent ? (isUpcoming ? t("scheduled") : t("concluded")) : t("operational")}
</span> </span>
</div> </div>
</div> </div>
{/* Bridge to the full case study inside its application page.
Only for real installations whose `application` maps to an
Application slug (not events or the HQ node). */}
{!isEvent && !isHQ && data.application && data.application !== "hq" && data.application !== "event" && (
<Link
href={`/applications/${data.application}#case-${data.id}`}
onClick={() => {
trackEvent({ name: "case_study_viewed", params: { nodeId: data.id, application: data.application } });
onClose();
}}
className="group mb-10 flex items-center justify-between gap-4 w-full bg-[#0066CC] dark:bg-[#00F0FF] text-white dark:text-black px-6 py-4 rounded-2xl font-medium hover:bg-[#0052a3] dark:hover:bg-[#00F0FF]/80 transition-colors shadow-lg"
>
<span className="flex flex-col text-left">
<span className="text-[10px] uppercase tracking-widest opacity-70">
{data.application.replace(/-/g, " ")}
</span>
<span className="text-base">{t("viewFullCase")}</span>
</span>
<ArrowRight size={20} className="group-hover:translate-x-1 transition-transform shrink-0" />
</Link>
)}
{data.projectOverview ? ( {data.projectOverview ? (
<div className="max-w-none mb-12"> <div className="max-w-none mb-12">
<h3 className="text-2xl font-light mb-6 text-[#1D1D1F] dark:text-white"> <h3 className="text-2xl font-light mb-6 text-[#1D1D1F] dark:text-white">
+64
View File
@@ -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 {}
}
+43 -12
View File
@@ -1,18 +1,35 @@
import { generateText } from 'ai'; import { generateText } from 'ai';
import { openai } from '@ai-sdk/openai'; import { openai } from '@ai-sdk/openai';
import {
maskProtectedTerms,
unmaskProtectedTerms,
glossaryForPrompt,
} from '@/lib/translationGlossary';
/** /**
* Motor de traducción impulsado por Vercel AI SDK y OpenAI. * Motor de traducción impulsado por Vercel AI SDK y OpenAI.
* Usa generateText para evitar bugs de compatibilidad con Zod. *
* English is the master language. Protected technical/brand terms (e.g.
* "Radio Frequency", "solid-state", "FLUX") are MASKED with placeholders
* before translation and RESTORED to their English form afterwards, so they
* are preserved deterministically across every locale not left to the
* model's discretion. See src/lib/translationGlossary.ts.
*
* @param content Objeto con los textos a traducir. Ej: { title: "...", content: "..." } * @param content Objeto con los textos a traducir. Ej: { title: "...", content: "..." }
* @returns Objeto con los idiomas y sus traducciones * @returns Objeto con los idiomas y sus traducciones, o null on failure.
*/ */
export async function translateContentForCMS(content: Record<string, string>) { export async function translateContentForCMS(content: Record<string, string>) {
try { try {
// 1. Mask protected terms in every field before sending to the model.
const maskedContent: Record<string, string> = {};
for (const [key, value] of Object.entries(content)) {
maskedContent[key] = maskProtectedTerms(value ?? '');
}
const { text } = await generateText({ const { text } = await generateText({
model: openai('gpt-4o'), model: openai('gpt-4o'),
system: `You are an elite technical translator for FLUX, a premium brand of Radio Frequency (RF) industrial machinery. system: `You are an elite technical translator for FLUX, a premium brand of solid-state Radio Frequency (RF) industrial machinery.
Your task is to translate the user's JSON content into 4 specific locales: Your task is to translate the user's JSON content into 4 specific locales:
1. 'it': Standard Professional Italian. 1. 'it': Standard Professional Italian.
2. 'vec': Venetian dialect (from Bassano del Grappa). Maintain a proud, industrial, and authentic tone. 2. 'vec': Venetian dialect (from Bassano del Grappa). Maintain a proud, industrial, and authentic tone.
@@ -22,9 +39,11 @@ export async function translateContentForCMS(content: Record<string, string>) {
CRITICAL RULES: CRITICAL RULES:
- NEVER translate Markdown syntax (#, **, *, >, |---|). - NEVER translate Markdown syntax (#, **, *, >, |---|).
- NEVER translate URLs, file paths (like /cases/img.jpg), or code blocks. - NEVER translate URLs, file paths (like /cases/img.jpg), or code blocks.
- NEVER translate technical acronyms like "RF", "kW", "MHz", "FLUX". - NEVER translate technical acronyms or units like "RF", "kW", "MHz", "FLUX".
- English is the master language. Keep this protected glossary in ENGLISH, untranslated, in every locale: ${glossaryForPrompt()}.
- CRITICAL: The text contains placeholder tokens of the form __FLUXTERM_0__, __FLUXTERM_1__, etc. These stand in for protected English terms. Keep every such token EXACTLY as-is, byte for byte. Do not translate, space, reorder the underscores, or alter them in any way. Position them naturally in the translated sentence.
- Keep the exact same JSON key names as the input. - Keep the exact same JSON key names as the input.
OUTPUT FORMAT: OUTPUT FORMAT:
You MUST return ONLY a raw, valid JSON object. Do not wrap it in \`\`\`json blocks. No pleasantries. You MUST return ONLY a raw, valid JSON object. Do not wrap it in \`\`\`json blocks. No pleasantries.
The output must strictly follow this structure: The output must strictly follow this structure:
@@ -34,20 +53,32 @@ export async function translateContentForCMS(content: Record<string, string>) {
"es": { "key1": "translated text..." }, "es": { "key1": "translated text..." },
"de": { "key1": "translated text..." } "de": { "key1": "translated text..." }
}`, }`,
prompt: JSON.stringify(content), prompt: JSON.stringify(maskedContent),
}); });
// Limpiamos el texto por si GPT-4o decide ponerle "```json" alrededor // Limpiamos el texto por si GPT-4o decide ponerle "```json" alrededor
const cleanedText = text.replace(/```json/g, '').replace(/```/g, '').trim(); const cleanedText = text.replace(/```json/g, '').replace(/```/g, '').trim();
// Convertimos la respuesta de la IA en un objeto real de Javascript // Convertimos la respuesta de la IA en un objeto real de Javascript
const parsedObject = JSON.parse(cleanedText); const parsedObject = JSON.parse(cleanedText);
// 2. Restore protected terms in every translated field of every locale.
for (const locale of Object.keys(parsedObject)) {
const fields = parsedObject[locale];
if (fields && typeof fields === 'object') {
for (const key of Object.keys(fields)) {
if (typeof fields[key] === 'string') {
fields[key] = unmaskProtectedTerms(fields[key]);
}
}
}
}
return parsedObject; return parsedObject;
} catch (error) { } catch (error) {
console.error("Error in AI Translation:", error); console.error("Error in AI Translation:", error);
return null; return null;
} }
} }
+101
View File
@@ -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);
}
+72
View File
@@ -0,0 +1,72 @@
// src/lib/assetFolders.ts
// ─────────────────────────────────────────────────────────────────────────────
// Server-side helpers that ensure every asset bucket exists when an editor
// creates a new entity in HQ Command. Without this, the editor's first
// upload error-paths because the target subfolder doesn't exist yet.
//
// Each scope has a known set of "buckets" — the well-known subfolder layout
// the front-end expects (e.g. cases use videos/, renders/, gallery/). We
// pre-create them all so editors can drop files anywhere without needing
// to think about server-side folder structure.
// ─────────────────────────────────────────────────────────────────────────────
import "server-only";
import fs from "fs";
import path from "path";
export type AssetScope = "cases" | "applications" | "news" | "parts";
const SCOPE_ROOT: Record<AssetScope, string> = {
cases: path.join(process.cwd(), "public", "cases"),
applications: path.join(process.cwd(), "public", "applications"),
news: path.join(process.cwd(), "public", "news"),
parts: path.join(process.cwd(), "public", "parts"),
};
// Buckets per scope — the subfolders the front-end reads from.
// Adding a new bucket here is the only place in the codebase you need to
// touch to introduce a new asset type.
const SCOPE_BUCKETS: Record<AssetScope, string[]> = {
cases: ["videos", "renders", "gallery", "datasheet", "models"],
applications: ["videos", "renders", "gallery", "datasheet"],
news: ["gallery"],
parts: ["renders", "gallery"],
};
/**
* Create the slug folder + every bucket subfolder if they don't exist.
* Idempotent safe to call on every entity create or edit.
*/
export function ensureAssetFolders(scope: AssetScope, slug: string): void {
if (!slug) return;
try {
const safeSlug = slug.toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
if (!safeSlug) return;
const root = SCOPE_ROOT[scope];
const baseDir = path.join(root, safeSlug);
fs.mkdirSync(baseDir, { recursive: true });
for (const bucket of SCOPE_BUCKETS[scope]) {
fs.mkdirSync(path.join(baseDir, bucket), { recursive: true });
}
} catch (error) {
console.error(`[assetFolders] Failed to ensure ${scope}/${slug}:`, error);
}
}
/**
* Convert a free-text title into the same slug the front-end uses.
* Mirrors `nodeToSlug` in ApplicationClient.tsx so the folder name matches
* the path the page renders for that node's assets.
*/
export function titleToSlug(title: string): string {
return title
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_-]+/g, "-")
.replace(/^-+|-+$/g, "");
}
+76
View File
@@ -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,
};
+49
View File
@@ -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> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
"/": "&#x2F;",
"`": "&#x60;",
"=": "&#x3D;",
};
/**
* 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}`);
}

Some files were not shown because too many files have changed in this diff Show More