Compare commits

...

2 Commits

Author SHA1 Message Date
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
19 changed files with 877 additions and 38 deletions
+15 -2
View File
@@ -5,11 +5,15 @@
# ── Stage 1: Install dependencies ──
FROM node:22-alpine AS deps
RUN apk add --no-cache libc6-compat
# libc6-compat: glibc shim for prebuilt native binaries (Prisma engines)
# vips-dev: required for sharp on Alpine — image processing native lib
RUN apk add --no-cache libc6-compat vips-dev
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# --include=optional ensures @img/sharp-linuxmusl-x64 (the Alpine sharp
# prebuilt binary) is downloaded; otherwise sharp errors at runtime.
RUN npm ci --include=optional
# ── Stage 2: Build the application ──
FROM node:22-alpine AS builder
@@ -36,6 +40,9 @@ WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# vips runtime — required for sharp at runtime, not just build
RUN apk add --no-cache vips
# Security: run as non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
@@ -52,6 +59,12 @@ COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
# Copy sharp binary explicitly — Next.js standalone trace usually picks it
# up, but the @img/sharp-linuxmusl-x64 prebuilt is platform-conditional and
# can be missed. Copying both directories guarantees runtime availability.
COPY --from=builder /app/node_modules/sharp ./node_modules/sharp
COPY --from=builder /app/node_modules/@img ./node_modules/@img
# Copy i18n message files (required by next-intl at runtime)
COPY --from=builder /app/messages ./messages
+1 -13
View File
@@ -29,6 +29,7 @@
"react": "19.2.4",
"react-dom": "19.2.4",
"resend": "^6.9.3",
"sharp": "^0.34.5",
"speakeasy": "^2.0.0",
"tailwind-merge": "^3.5.0",
"three": "^0.183.2",
@@ -754,7 +755,6 @@
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
}
@@ -7056,16 +7056,6 @@
}
}
},
"node_modules/next-intl/node_modules/@swc/helpers": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz",
"integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@@ -8164,7 +8154,6 @@
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"devOptional": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -8240,7 +8229,6 @@
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
+1
View File
@@ -30,6 +30,7 @@
"react": "19.2.4",
"react-dom": "19.2.4",
"resend": "^6.9.3",
"sharp": "^0.34.5",
"speakeasy": "^2.0.0",
"tailwind-merge": "^3.5.0",
"three": "^0.183.2",
+63 -3
View File
@@ -1,6 +1,7 @@
// ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads.
export const revalidate = 60;
import type { Metadata } from "next";
import Link from "next/link";
import fs from "fs";
import path from "path";
@@ -8,8 +9,14 @@ import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import ApplicationClient from "./ApplicationClient";
// 🔥 IMPORTAMOS LA TUBERÍA MÁGICA
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 ---
function getApplicationImages(slug: string) {
@@ -29,6 +36,39 @@ function getApplicationImages(slug: string) {
return { heroImage, blueprints, machines };
}
// ── Per-page metadata (Open Graph, Twitter, hreflang, canonical) ───────────
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string; locale: string }>;
}): Promise<Metadata> {
const { slug, locale } = await params;
try {
const raw = await prisma.application.findUnique({ where: { slug } });
if (!raw) {
return {
title: "Application not found | FLUX",
robots: { index: false, follow: false },
};
}
const data = getLocalizedData(raw, locale);
const heroImage = getApplicationImages(slug).heroImage;
return buildPageMetadata({
locale,
pathWithoutLocale: `applications/${slug}`,
title: `${data.title} — RF Industrial Solutions | FLUX`,
description: data.shortDescription || data.subtitle,
ogImageUrl: heroImage || undefined,
type: "product",
});
} catch {
return { title: "FLUX | Energy, Directed." };
}
}
// GENERACIÓN DE RUTAS ESTÁTICAS DESDE LA BD
export async function generateStaticParams() {
// In production Docker build, DB is not available.
@@ -85,6 +125,26 @@ export default async function ApplicationPage({ params }: { params: Promise<{ sl
// 3. Leemos las imágenes de la carpeta original
const images = getApplicationImages(slug);
// Pasamos TODO al componente cliente interactivo (que ya viene traducido)
return <ApplicationClient data={data} realCases={realCases} images={images} />;
const url = `${baseUrl()}/${locale}/applications/${slug}`;
const jsonLd = [
productSchema({
name: data.title,
description: data.shortDescription || data.subtitle,
imageUrl: images.heroImage || undefined,
category: data.category,
url,
}),
breadcrumbSchema([
{ name: "Home", url: `${baseUrl()}/${locale}` },
{ name: "Applications", url: `${baseUrl()}/${locale}#applications-deep` },
{ name: data.title, url },
]),
];
return (
<>
<JsonLd data={jsonLd} />
<ApplicationClient data={data} realCases={realCases} images={images} />
</>
);
}
+17
View File
@@ -1,6 +1,7 @@
// ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads.
export const revalidate = 60;
import type { Metadata } from "next";
import Link from "next/link";
import Image from "next/image";
import { prisma } from "@/lib/prisma";
@@ -11,6 +12,22 @@ import AutoPlayVideo from "@/components/AutoPlayVideo";
// 🔥 IMPORTACIONES DE IDIOMAS
import { getLocalizedData } from "@/lib/i18nHelper";
import { getTranslations } 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) ──
const renderMarkdown = (text: string) => {
+26 -8
View File
@@ -12,7 +12,9 @@ import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { routing } from '@/i18n/routing';
import { getBranding } from '@/lib/siteSettings';
import { getBranding, getSocialLinks } from '@/lib/siteSettings';
import { organizationSchema, websiteSchema } from '@/lib/seo';
import JsonLd from '@/components/seo/JsonLd';
const inter = Inter({ subsets: ["latin"] });
@@ -69,7 +71,13 @@ export default async function RootLayout({
notFound();
}
const messages = await getMessages();
const [messages, branding, social] = await Promise.all([
getMessages(),
getBranding(),
getSocialLinks(),
]);
const sameAs = [social.linkedin, social.instagram, social.youtube].filter(Boolean);
return (
<html lang={locale} className="scroll-smooth bg-[#F5F5F7]">
@@ -82,23 +90,33 @@ export default async function RootLayout({
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,
}),
websiteSchema(),
]}
/>
<NextIntlClientProvider messages={messages}>
<NavBar />
{/* 🔥 Panel del Carrito de Repuestos y Soporte Técnico 🔥 */}
<CartDrawer />
{/* Inyectamos el manejador de transiciones aquí */}
<NavigationManager />
<div className="flex-grow w-full flex flex-col relative">
{children}
</div>
<Footer />
<SilentObserver />
</NextIntlClientProvider>
</body>
+57 -1
View File
@@ -1,14 +1,51 @@
// ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads.
export const revalidate = 60;
import type { Metadata } from "next";
import Link from "next/link";
import Image from "next/image";
import { prisma } from "@/lib/prisma";
import { ArrowLeft, Calendar, Tag, Linkedin } from "lucide-react";
import BreathingField from "@/components/visuals/BreathingField";
// 🔥 IMPORTACIÓN DEL MOTOR DE TRADUCCIÓN 🔥
import { getLocalizedData } from "@/lib/i18nHelper";
import {
buildPageMetadata,
articleSchema,
breadcrumbSchema,
baseUrl,
} from "@/lib/seo";
import JsonLd from "@/components/seo/JsonLd";
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string; locale: string }>;
}): 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." };
}
}
export async function generateStaticParams() {
// In production Docker build, DB is not available.
@@ -210,8 +247,27 @@ export default async function ArticlePage({ params }: { params: Promise<{ slug:
let gallery: string[] = [];
try { gallery = JSON.parse(article.galleryJson || "[]"); } catch (e) {}
const articleUrl = `${baseUrl()}/${locale}/news/${slug}`;
const cover = article.coverImage ? `/news/${slug}/${article.coverImage}` : undefined;
const jsonLd = [
articleSchema({
headline: article.title,
description: article.excerpt,
imageUrl: cover,
url: articleUrl,
publishedAt: article.publishedAt,
updatedAt: article.updatedAt,
}),
breadcrumbSchema([
{ name: "Home", url: `${baseUrl()}/${locale}` },
{ name: "News", url: `${baseUrl()}/${locale}/news` },
{ name: article.title, url: articleUrl },
]),
];
return (
<main className="relative min-h-screen pb-24">
<JsonLd data={jsonLd} />
<BreathingField />
<div className="fixed top-24 left-6 z-50 hidden md:block">
+17
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import Link from "next/link";
import Image from "next/image";
import { prisma } from "@/lib/prisma";
@@ -6,10 +7,26 @@ import BreathingField from "@/components/visuals/BreathingField";
import { getLocalizedData } from "@/lib/i18nHelper";
import { getTranslations } from "next-intl/server";
import { buildPageMetadata } from "@/lib/seo";
// ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads.
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 }> }) {
const resolvedParams = await params;
const locale = resolvedParams.locale;
+37 -1
View File
@@ -1,6 +1,6 @@
// 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 path from "path";
import { prisma } from "@/lib/prisma";
@@ -15,10 +15,46 @@ import ApplicationsDeep from "@/components/sections/ApplicationsDeep";
import HeroReel from "@/components/sections/HeroReel";
import WhatWeDo from "@/components/sections/WhatWeDo";
import { buildPageMetadata } from "@/lib/seo";
import { getBranding } from "@/lib/siteSettings";
// ISR: page is statically generated, but revalidates on demand via
// revalidatePath() after CMS uploads, plus a 60s safety window.
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
export default async function Home({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
+43 -7
View File
@@ -31,6 +31,7 @@ import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { revalidateContent, type RevalidateScope } from "@/lib/revalidate";
import { optimizeImage, isOptimizable } from "@/lib/imageOptimizer";
const SCOPE_ROOTS: Record<string, string> = {
applications: path.join(process.cwd(), "public", "applications"),
@@ -186,6 +187,13 @@ export async function GET(request: NextRequest) {
}
// 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) {
try {
const formData = await request.formData();
@@ -194,6 +202,12 @@ export async function POST(request: NextRequest) {
const subPath = formData.get("path") as string || "";
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 (!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 });
@@ -211,13 +225,26 @@ export async function POST(request: NextRequest) {
fs.mkdirSync(dirPath, { recursive: true });
const safeName = file.name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9._-]/g, "");
const filePath = path.join(dirPath, safeName);
const inputBuffer: Buffer = Buffer.from(await file.arrayBuffer());
// 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);
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
revalidateContent({ scope: scope as RevalidateScope, slug });
@@ -225,12 +252,21 @@ export async function POST(request: NextRequest) {
return NextResponse.json({
success: true,
file: {
name: safeName,
name: saveName,
publicUrl: buildPublicUrl(scope, slug, rel),
path: rel,
mediaType: getFileType(safeName),
size: getFileSize(file.size),
mediaType: getFileType(saveName),
size: getFileSize(outputBuffer.byteLength),
overwritten: existed,
optimized: optimizedMeta !== null,
...(optimizedMeta
? {
width: optimizedMeta.width,
height: optimizedMeta.height,
originalBytes: file.size,
savedBytes: file.size - optimizedMeta.bytes,
}
: {}),
}
});
} catch (error) {
+27 -3
View File
@@ -1,9 +1,10 @@
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 { prisma } from '@/lib/prisma';
import { checkChatRateLimit } from '@/lib/rateLimit';
export const maxDuration = 30;
export const maxDuration = 60;
// ─── PHYSICS CONSTANTS (NOT from DB — these are engineering benchmarks) ──────
// These stay hardcoded because they are physical/scientific constants,
@@ -150,6 +151,25 @@ function industryFromSlug(slug: string): string {
// ─── ROUTE HANDLER ──────────────────────────────────────────────
export async function POST(req: Request) {
// ─── Rate limit (per-IP token bucket, 30 req/min) ──────────────
const rate = 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),
},
}
);
}
const { messages, context }: {
messages: UIMessage[];
context?: { section?: string; activeTab?: string };
@@ -168,7 +188,11 @@ export async function POST(req: Request) {
model: openai('gpt-4o'),
system: systemPrompt + contextNote,
messages: coreMessages,
// maxSteps has been temporarily removed to ensure compatibility with the installed AI SDK version
// 🔥 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: {
// ══════════════════════════════════════════════════════════════
@@ -66,6 +66,7 @@ export default function HeroDashboard() {
try {
const fd = new FormData();
fd.append("scope", "footage");
fd.append("optimize", "1");
fd.append("file", file);
const res = await fetch("/api/assets", { method: "POST", body: fd });
const data = await res.json();
@@ -389,6 +389,7 @@ function ImageField({
try {
const fd = new FormData();
fd.append("scope", "branding");
fd.append("optimize", "1");
fd.append("file", file);
const res = await fetch("/api/assets", { method: "POST", body: fd });
const data = await res.json();
+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,
};
}
+104
View File
@@ -0,0 +1,104 @@
// 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 },
];
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;
}
+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),
}}
/>
);
}
+128
View File
@@ -0,0 +1,128 @@
// src/lib/imageOptimizer.ts
// ─────────────────────────────────────────────────────────────────────────────
// Server-side image optimization for CMS uploads.
//
// What it does:
// - Auto-orients (respects EXIF rotation from phone cameras)
// - Caps very large images at 2560px on the long side (no point storing
// phone megapixels — Next.js Image Optimizer will downsize on the fly)
// - Re-encodes to WebP at quality 85 (typically 6080% smaller than JPEG)
// - Computes a content-hash filename so the same image can never collide
// with itself across re-uploads, AND new versions get a new URL (perfect
// cache invalidation on the browser side)
//
// What it does NOT do:
// - Generate responsive variants — next/image handles that automatically
// - Touch GIFs or videos — those pass through unchanged
// - Remove the original — the optimized buffer fully replaces it
// ─────────────────────────────────────────────────────────────────────────────
import "server-only";
import sharp from "sharp";
import crypto from "crypto";
import path from "path";
const MAX_LONG_SIDE = 2560;
const WEBP_QUALITY = 85;
// File extensions sharp can decode and we want to optimize.
const OPTIMIZABLE = new Set([".jpg", ".jpeg", ".png", ".webp", ".tiff", ".heic", ".heif"]);
export interface OptimizedImage {
buffer: Buffer;
ext: string; // ".webp" for optimized, original ext otherwise
filename: string; // sanitized name with content hash, e.g. "hero-9f3a2c.webp"
width: number | null;
height: number | null;
bytes: number;
}
export function isOptimizable(filename: string): boolean {
return OPTIMIZABLE.has(path.extname(filename).toLowerCase());
}
function sanitizeBaseName(name: string): string {
const withoutExt = name.replace(/\.[^.]+$/, "");
return (
withoutExt
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9._-]/g, "")
.replace(/-+/g, "-")
.replace(/^[-.]+|[-.]+$/g, "")
.slice(0, 60) || "image"
);
}
function shortHash(buffer: Buffer, length = 8): string {
return crypto.createHash("sha256").update(buffer).digest("hex").slice(0, length);
}
/**
* Optimize an uploaded image buffer.
* Falls back to a no-op (returns the original) if sharp can't decode the file
* or the extension isn't in OPTIMIZABLE this keeps SVGs / GIFs / videos /
* unsupported formats working transparently.
*/
export async function optimizeImage(
inputBuffer: Buffer,
originalFilename: string
): Promise<OptimizedImage> {
const ext = path.extname(originalFilename).toLowerCase();
const baseName = sanitizeBaseName(originalFilename);
if (!OPTIMIZABLE.has(ext)) {
const hash = shortHash(inputBuffer);
return {
buffer: inputBuffer,
ext,
filename: `${baseName}-${hash}${ext}`,
width: null,
height: null,
bytes: inputBuffer.byteLength,
};
}
try {
const pipeline = sharp(inputBuffer, { failOn: "none" }).rotate(); // honour EXIF
const meta = await pipeline.metadata();
const longSide = Math.max(meta.width || 0, meta.height || 0);
let processed = pipeline;
if (longSide > MAX_LONG_SIDE) {
processed = processed.resize({
width: meta.width && meta.width >= meta.height! ? MAX_LONG_SIDE : undefined,
height: meta.height && meta.height > meta.width! ? MAX_LONG_SIDE : undefined,
withoutEnlargement: true,
fit: "inside",
});
}
const out = await processed
.webp({ quality: WEBP_QUALITY, effort: 4 })
.toBuffer({ resolveWithObject: true });
const hash = shortHash(out.data);
return {
buffer: out.data,
ext: ".webp",
filename: `${baseName}-${hash}.webp`,
width: out.info.width,
height: out.info.height,
bytes: out.data.byteLength,
};
} catch (error) {
console.warn(`[imageOptimizer] Failed to optimize "${originalFilename}", keeping original:`, error);
const hash = shortHash(inputBuffer);
return {
buffer: inputBuffer,
ext,
filename: `${baseName}-${hash}${ext}`,
width: null,
height: null,
bytes: inputBuffer.byteLength,
};
}
}
+86
View File
@@ -0,0 +1,86 @@
// src/lib/rateLimit.ts
// ─────────────────────────────────────────────────────────────────────────────
// Lightweight in-memory rate limiter (token bucket per IP).
// Single Node process, no Redis dep — protects /api/chat from quota burning.
// Scales to one container; if you add replicas, swap the Map for Upstash Redis.
// ─────────────────────────────────────────────────────────────────────────────
interface Bucket {
tokens: number;
updatedAt: number;
}
interface RateLimitConfig {
capacity: number; // Max tokens in the bucket
refillPerSec: number; // Tokens added each second
}
const buckets = new Map<string, Bucket>();
// Garbage-collect stale buckets every 10 min so memory doesn't grow unbounded
let lastGc = Date.now();
const GC_INTERVAL = 10 * 60 * 1000;
const STALE_THRESHOLD = 30 * 60 * 1000;
function gc(now: number) {
if (now - lastGc < GC_INTERVAL) return;
for (const [key, bucket] of buckets) {
if (now - bucket.updatedAt > STALE_THRESHOLD) buckets.delete(key);
}
lastGc = now;
}
export interface RateLimitResult {
ok: boolean;
remaining: number;
retryAfterSec: number;
}
export function rateLimit(key: string, config: RateLimitConfig): RateLimitResult {
const now = Date.now();
gc(now);
const existing = buckets.get(key);
let bucket: Bucket;
if (!existing) {
bucket = { tokens: config.capacity - 1, updatedAt: now };
buckets.set(key, bucket);
return { ok: true, remaining: bucket.tokens, retryAfterSec: 0 };
}
const elapsedSec = (now - existing.updatedAt) / 1000;
const refilled = Math.min(config.capacity, existing.tokens + elapsedSec * config.refillPerSec);
if (refilled < 1) {
const retryAfterSec = Math.ceil((1 - refilled) / config.refillPerSec);
existing.tokens = refilled;
existing.updatedAt = now;
return { ok: false, remaining: 0, retryAfterSec };
}
existing.tokens = refilled - 1;
existing.updatedAt = now;
return { ok: true, remaining: Math.floor(existing.tokens), retryAfterSec: 0 };
}
// ── Helpers ──────────────────────────────────────────────────────────────────
export function getClientIp(req: Request): string {
// Nginx sets x-forwarded-for; first value is the real client.
const xff = req.headers.get("x-forwarded-for");
if (xff) return xff.split(",")[0].trim();
const real = req.headers.get("x-real-ip");
if (real) return real;
return "unknown";
}
const CHAT_LIMIT: RateLimitConfig = {
capacity: 30, // Burst of 30 messages
refillPerSec: 0.5, // = 30/min sustained
};
export function checkChatRateLimit(req: Request): RateLimitResult {
const ip = getClientIp(req);
return rateLimit(`chat:${ip}`, CHAT_LIMIT);
}
+201
View File
@@ -0,0 +1,201 @@
// src/lib/seo.ts
// ─────────────────────────────────────────────────────────────────────────────
// SEO helpers — base URL, alternate-language tags, JSON-LD structured data
// schemas (Organization, Article, Product, BreadcrumbList).
//
// Used by per-page generateMetadata functions and rendered as <script
// type="application/ld+json"> tags so Google can parse the structured data.
// ─────────────────────────────────────────────────────────────────────────────
import type { Metadata } from "next";
export const LOCALES = ["en", "it", "vec", "es", "de"] as const;
export type Locale = (typeof LOCALES)[number];
export function baseUrl() {
return (process.env.NEXT_PUBLIC_APP_URL || "https://rf-flux.com").replace(/\/$/, "");
}
export function absoluteUrl(path: string) {
if (path.startsWith("http")) return path;
return `${baseUrl()}${path.startsWith("/") ? "" : "/"}${path}`;
}
// ── Build hreflang alternates for a given path (without locale prefix) ──────
export function alternateLanguages(pathWithoutLocale: string) {
const base = baseUrl();
const clean = pathWithoutLocale.replace(/^\/+/, "");
return Object.fromEntries(
LOCALES.map((locale) => [locale, `${base}/${locale}${clean ? `/${clean}` : ""}`])
);
}
export interface PageSeoInput {
locale: Locale | string;
pathWithoutLocale: string; // e.g. "" for home, "applications/textile-drying" for an app
title: string;
description: string;
ogImageUrl?: string;
type?: "website" | "article" | "product";
publishedAt?: Date | string | null;
updatedAt?: Date | string | null;
}
export function buildPageMetadata(input: PageSeoInput): Metadata {
const base = baseUrl();
const path = input.pathWithoutLocale.replace(/^\/+/, "");
const canonical = `${base}/${input.locale}${path ? `/${path}` : ""}`;
return {
title: input.title,
description: input.description,
metadataBase: new URL(base),
alternates: {
canonical,
languages: alternateLanguages(input.pathWithoutLocale),
},
openGraph: {
title: input.title,
description: input.description,
url: canonical,
siteName: "FLUX Srl",
locale: String(input.locale),
type: input.type === "article" ? "article" : "website",
images: input.ogImageUrl ? [{ url: absoluteUrl(input.ogImageUrl) }] : undefined,
...(input.publishedAt
? { publishedTime: new Date(input.publishedAt).toISOString() }
: {}),
...(input.updatedAt
? { modifiedTime: new Date(input.updatedAt).toISOString() }
: {}),
},
twitter: {
card: "summary_large_image",
title: input.title,
description: input.description,
images: input.ogImageUrl ? [absoluteUrl(input.ogImageUrl)] : undefined,
},
};
}
// ── JSON-LD schemas ─────────────────────────────────────────────────────────
export function organizationSchema(opts?: { logoUrl?: string; sameAs?: string[] }) {
const base = baseUrl();
return {
"@context": "https://schema.org",
"@type": "Organization",
name: "FLUX Srl",
legalName: "FLUX Srl",
url: base,
logo: opts?.logoUrl ? absoluteUrl(opts.logoUrl) : `${base}/flux-logo.png`,
description:
"Leading manufacturer of solid-state Radio Frequency (RF), Microwave, and Infrared industrial equipment. Founded by Patrizio Grando — 40+ years of legacy.",
foundingDate: "1978",
founder: {
"@type": "Person",
name: "Patrizio Grando",
},
address: {
"@type": "PostalAddress",
streetAddress: "Via Benedetto Marcello 32",
addressLocality: "Romano d'Ezzelino",
addressRegion: "Vicenza",
postalCode: "36060",
addressCountry: "IT",
},
contactPoint: {
"@type": "ContactPoint",
contactType: "Sales",
email: "info@rf-flux.com",
availableLanguage: ["English", "Italian", "German", "Spanish"],
},
...(opts?.sameAs ? { sameAs: opts.sameAs } : {}),
};
}
export function websiteSchema() {
const base = baseUrl();
return {
"@context": "https://schema.org",
"@type": "WebSite",
name: "FLUX Srl",
url: base,
inLanguage: ["en", "it", "vec", "es", "de"],
};
}
export function articleSchema(opts: {
headline: string;
description: string;
imageUrl?: string;
url: string;
publishedAt: Date | string;
updatedAt: Date | string;
author?: string;
}) {
return {
"@context": "https://schema.org",
"@type": "Article",
headline: opts.headline,
description: opts.description,
image: opts.imageUrl ? absoluteUrl(opts.imageUrl) : undefined,
datePublished: new Date(opts.publishedAt).toISOString(),
dateModified: new Date(opts.updatedAt).toISOString(),
author: {
"@type": "Organization",
name: opts.author || "FLUX Srl",
},
publisher: {
"@type": "Organization",
name: "FLUX Srl",
logo: {
"@type": "ImageObject",
url: `${baseUrl()}/flux-logo.png`,
},
},
mainEntityOfPage: {
"@type": "WebPage",
"@id": opts.url,
},
};
}
export function productSchema(opts: {
name: string;
description: string;
imageUrl?: string;
category: string;
url: string;
}) {
return {
"@context": "https://schema.org",
"@type": "Product",
name: opts.name,
description: opts.description,
image: opts.imageUrl ? absoluteUrl(opts.imageUrl) : undefined,
category: opts.category,
brand: {
"@type": "Brand",
name: "FLUX",
},
manufacturer: {
"@type": "Organization",
name: "FLUX Srl",
},
url: opts.url,
};
}
export function breadcrumbSchema(items: { name: string; url: string }[]) {
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: items.map((item, idx) => ({
"@type": "ListItem",
position: idx + 1,
name: item.name,
item: item.url,
})),
};
}