Compare commits
2 Commits
f931ae281c
...
a199891a3c
| Author | SHA1 | Date | |
|---|---|---|---|
| a199891a3c | |||
| 09e6d0c7cf |
+15
-2
@@ -5,11 +5,15 @@
|
|||||||
|
|
||||||
# ── Stage 1: Install dependencies ──
|
# ── Stage 1: Install dependencies ──
|
||||||
FROM node:22-alpine AS deps
|
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
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
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 ──
|
# ── Stage 2: Build the application ──
|
||||||
FROM node:22-alpine AS builder
|
FROM node:22-alpine AS builder
|
||||||
@@ -36,6 +40,9 @@ WORKDIR /app
|
|||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
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
|
# Security: run as non-root user
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
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 --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 i18n message files (required by next-intl at runtime)
|
||||||
COPY --from=builder /app/messages ./messages
|
COPY --from=builder /app/messages ./messages
|
||||||
|
|
||||||
|
|||||||
Generated
+1
-13
@@ -29,6 +29,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",
|
||||||
@@ -754,7 +755,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"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": {
|
"node_modules/next/node_modules/postcss": {
|
||||||
"version": "8.4.31",
|
"version": "8.4.31",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||||
@@ -8164,7 +8154,6 @@
|
|||||||
"version": "7.7.4",
|
"version": "7.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
@@ -8240,7 +8229,6 @@
|
|||||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@img/colour": "^1.0.0",
|
"@img/colour": "^1.0.0",
|
||||||
"detect-libc": "^2.1.2",
|
"detect-libc": "^2.1.2",
|
||||||
|
|||||||
@@ -30,6 +30,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",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads.
|
// ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads.
|
||||||
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,8 +9,14 @@ 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 { 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) {
|
||||||
@@ -29,6 +36,39 @@ function getApplicationImages(slug: string) {
|
|||||||
return { heroImage, blueprints, machines };
|
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
|
// GENERACIÓN DE RUTAS ESTÁTICAS DESDE LA BD
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
// In production Docker build, DB is not available.
|
// 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
|
// 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)
|
const url = `${baseUrl()}/${locale}/applications/${slug}`;
|
||||||
return <ApplicationClient data={data} realCases={realCases} images={images} />;
|
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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads.
|
// ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads.
|
||||||
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";
|
||||||
@@ -11,6 +12,22 @@ 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 } 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) => {
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import { NextIntlClientProvider } from 'next-intl';
|
|||||||
import { getMessages } from 'next-intl/server';
|
import { getMessages } 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';
|
import { getBranding, getSocialLinks } from '@/lib/siteSettings';
|
||||||
|
import { organizationSchema, websiteSchema } from '@/lib/seo';
|
||||||
|
import JsonLd from '@/components/seo/JsonLd';
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] });
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
@@ -69,7 +71,13 @@ export default async function RootLayout({
|
|||||||
notFound();
|
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 (
|
return (
|
||||||
<html lang={locale} className="scroll-smooth bg-[#F5F5F7]">
|
<html lang={locale} className="scroll-smooth bg-[#F5F5F7]">
|
||||||
@@ -82,23 +90,33 @@ 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,
|
||||||
|
}),
|
||||||
|
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í */}
|
|
||||||
<NavigationManager />
|
<NavigationManager />
|
||||||
|
|
||||||
<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 />
|
||||||
<SilentObserver />
|
<SilentObserver />
|
||||||
|
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,14 +1,51 @@
|
|||||||
// ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads.
|
// ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads.
|
||||||
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 { getLocalizedData } from "@/lib/i18nHelper";
|
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() {
|
export async function generateStaticParams() {
|
||||||
// In production Docker build, DB is not available.
|
// In production Docker build, DB is not available.
|
||||||
@@ -210,8 +247,27 @@ export default async function ArticlePage({ params }: { params: Promise<{ slug:
|
|||||||
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 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 (
|
return (
|
||||||
<main className="relative min-h-screen pb-24">
|
<main className="relative min-h-screen pb-24">
|
||||||
|
<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">
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -6,10 +7,26 @@ import BreathingField from "@/components/visuals/BreathingField";
|
|||||||
|
|
||||||
import { getLocalizedData } from "@/lib/i18nHelper";
|
import { getLocalizedData } from "@/lib/i18nHelper";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { buildPageMetadata } from "@/lib/seo";
|
||||||
|
|
||||||
// ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads.
|
// ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads.
|
||||||
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;
|
||||||
|
|||||||
@@ -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,10 +15,46 @@ 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";
|
||||||
|
|
||||||
|
import { buildPageMetadata } from "@/lib/seo";
|
||||||
|
import { getBranding } from "@/lib/siteSettings";
|
||||||
|
|
||||||
// ISR: page is statically generated, but revalidates on demand via
|
// ISR: page is statically generated, but revalidates on demand via
|
||||||
// revalidatePath() after CMS uploads, plus a 60s safety window.
|
// revalidatePath() after CMS uploads, plus a 60s safety window.
|
||||||
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;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ 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";
|
||||||
|
|
||||||
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"),
|
||||||
@@ -186,6 +187,13 @@ 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) {
|
||||||
try {
|
try {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
@@ -194,6 +202,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 +225,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 +252,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) {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
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 { prisma } from '@/lib/prisma';
|
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) ──────
|
// ─── 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,
|
||||||
@@ -150,6 +151,25 @@ function industryFromSlug(slug: string): string {
|
|||||||
// ─── ROUTE HANDLER ──────────────────────────────────────────────
|
// ─── ROUTE HANDLER ──────────────────────────────────────────────
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
// ─── Rate limit (per-IP token bucket, 30 req/min) ──────────────
|
||||||
|
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 }: {
|
const { messages, context }: {
|
||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
context?: { section?: string; activeTab?: string };
|
context?: { section?: string; activeTab?: string };
|
||||||
@@ -168,7 +188,11 @@ 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
|
// 🔥 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: {
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -66,6 +66,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();
|
||||||
|
|||||||
@@ -389,6 +389,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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 60–80% 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user