feat(analytics): activate GA4 (G-KQ1JRV3KN7) + GDPR privacy page + GSC support
Client provided the GA4 Measurement ID and approved the standard policy. - Activate analytics: NEXT_PUBLIC_GA_ID set to the FLUX property G-KQ1JRV3KN7 in the env template, with the same value as the docker-compose build-arg fallback so it works out of the box on deploy. (GA Measurement IDs are public — they ship in page HTML — safe to commit.) - New GDPR-compliant Privacy & Cookie Policy page at /[locale]/privacy (all 5 locales), linked from the consent banner. Includes a clearly marked template disclaimer for legal review and a TODO on the contact email. Added to sitemap. - Consent banner now links via the locale-aware next-intl Link. - Google Search Console: optional NEXT_PUBLIC_GSC_VERIFICATION env var emits the google-site-verification meta tag (Dockerfile arg + docker-compose wired). Empty by default. Verified: build inlines G-KQ1JRV3KN7 into the client bundle; the 5 /privacy routes render; TypeScript clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -51,6 +51,8 @@ ENV DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy"
|
|||||||
# docker-compose build.args -> .env. Empty by default = analytics disabled.
|
# docker-compose build.args -> .env. Empty by default = analytics disabled.
|
||||||
ARG NEXT_PUBLIC_GA_ID=""
|
ARG NEXT_PUBLIC_GA_ID=""
|
||||||
ENV NEXT_PUBLIC_GA_ID=$NEXT_PUBLIC_GA_ID
|
ENV NEXT_PUBLIC_GA_ID=$NEXT_PUBLIC_GA_ID
|
||||||
|
ARG NEXT_PUBLIC_GSC_VERIFICATION=""
|
||||||
|
ENV NEXT_PUBLIC_GSC_VERIFICATION=$NEXT_PUBLIC_GSC_VERIFICATION
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
+5
-3
@@ -43,9 +43,11 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
# NEXT_PUBLIC_GA_ID must be available at build time (Next.js inlines
|
# NEXT_PUBLIC_* are inlined into the client bundle at build time.
|
||||||
# NEXT_PUBLIC_* into the client bundle). Sourced from .env on the host.
|
# Sourced from .env on the host; the fallback is the FLUX GA4 ID so
|
||||||
NEXT_PUBLIC_GA_ID: ${NEXT_PUBLIC_GA_ID:-}
|
# analytics works out of the box even if .env doesn't override it.
|
||||||
|
NEXT_PUBLIC_GA_ID: ${NEXT_PUBLIC_GA_ID:-G-KQ1JRV3KN7}
|
||||||
|
NEXT_PUBLIC_GSC_VERIFICATION: ${NEXT_PUBLIC_GSC_VERIFICATION:-}
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ SESSION_SECRET="CHANGE_ME_openssl_rand_base64_48_min_32_chars"
|
|||||||
# Google Analytics 4 Measurement ID (format: G-XXXXXXXXXX).
|
# Google Analytics 4 Measurement ID (format: G-XXXXXXXXXX).
|
||||||
# Leave empty to disable analytics entirely — the site loads no Google
|
# Leave empty to disable analytics entirely — the site loads no Google
|
||||||
# scripts and the consent banner stays hidden until this is set.
|
# scripts and the consent banner stays hidden until this is set.
|
||||||
NEXT_PUBLIC_GA_ID=""
|
# This is a PUBLIC value (it ships in the page HTML), safe to commit.
|
||||||
|
NEXT_PUBLIC_GA_ID="G-KQ1JRV3KN7"
|
||||||
|
|
||||||
|
# Google Search Console verification token (the content="" value from the
|
||||||
|
# HTML-tag verification method). Leave empty if you verify via DNS or GA.
|
||||||
|
NEXT_PUBLIC_GSC_VERIFICATION=""
|
||||||
|
|
||||||
# OPEN AI KEY
|
# OPEN AI KEY
|
||||||
OPENAI_API_KEY=sk-proj-dsdsdsds-kaDAHhZXPYsK6-4uw0UWJZ0YuLnQfVoEA
|
OPENAI_API_KEY=sk-proj-dsdsdsds-kaDAHhZXPYsK6-4uw0UWJZ0YuLnQfVoEA
|
||||||
|
|||||||
@@ -79,12 +79,17 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
apple: branding.appleTouchIconUrl,
|
apple: branding.appleTouchIconUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Google Search Console verification (HTML-tag method). Emits
|
||||||
|
// <meta name="google-site-verification" content="..."> when set.
|
||||||
|
const gscToken = process.env.NEXT_PUBLIC_GSC_VERIFICATION;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
metadataBase: new URL(APP_BASE_URL),
|
metadataBase: new URL(APP_BASE_URL),
|
||||||
title: "FLUX | Energy, Directed.",
|
title: "FLUX | Energy, Directed.",
|
||||||
description: "Advanced Radio Frequency Solutions by Patrizio Grando.",
|
description: "Advanced Radio Frequency Solutions by Patrizio Grando.",
|
||||||
icons,
|
icons,
|
||||||
manifest: "/manifest.webmanifest",
|
manifest: "/manifest.webmanifest",
|
||||||
|
...(gscToken ? { verification: { google: gscToken } } : {}),
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "FLUX | Energy, Directed.",
|
title: "FLUX | Energy, Directed.",
|
||||||
description: "Advanced Radio Frequency Solutions by Patrizio Grando.",
|
description: "Advanced Radio Frequency Solutions by Patrizio Grando.",
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { setRequestLocale } from "next-intl/server";
|
||||||
|
import { buildPageMetadata } from "@/lib/seo";
|
||||||
|
import Breadcrumbs from "@/components/seo/Breadcrumbs";
|
||||||
|
|
||||||
|
// Static legal page. Revalidate rarely.
|
||||||
|
export const revalidate = 86400;
|
||||||
|
|
||||||
|
const LAST_UPDATED = "June 2026";
|
||||||
|
const COMPANY = "FLUX Srl";
|
||||||
|
const ADDRESS = "Romano d'Ezzelino, Vicenza, Italy";
|
||||||
|
const CONTACT_EMAIL = "privacy@rf-flux.com"; // TODO: confirm with FLUX legal
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { locale } = await params;
|
||||||
|
return buildPageMetadata({
|
||||||
|
locale,
|
||||||
|
pathWithoutLocale: "privacy",
|
||||||
|
title: "Privacy & Cookie Policy | FLUX",
|
||||||
|
description:
|
||||||
|
"How FLUX Srl collects, uses and protects personal data on rf-flux.com, in compliance with the EU GDPR.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PrivacyPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
|
||||||
|
const crumbs = [
|
||||||
|
{ name: "Home", url: `/${locale}` },
|
||||||
|
{ name: "Privacy & Cookie Policy", url: `/${locale}/privacy` },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="relative w-full min-h-screen bg-[#F5F5F7]">
|
||||||
|
<div className="max-w-3xl mx-auto px-6 pt-28 md:pt-36 pb-24">
|
||||||
|
<Breadcrumbs items={crumbs} />
|
||||||
|
|
||||||
|
<header className="mt-6 mb-10">
|
||||||
|
<h1 className="text-3xl md:text-5xl font-light text-[#1D1D1F] tracking-tight">
|
||||||
|
Privacy & Cookie <span className="font-medium">Policy</span>
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 text-sm text-[#86868B]">Last updated: {LAST_UPDATED}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Template disclaimer — remove once reviewed by legal counsel */}
|
||||||
|
<div className="mb-10 rounded-2xl border border-amber-300/50 bg-amber-50 p-4 text-sm text-amber-900">
|
||||||
|
<strong>Template notice:</strong> this is a standard GDPR-compliant
|
||||||
|
template provided as a starting point. Please have it reviewed and
|
||||||
|
adapted by your legal counsel before relying on it, and confirm the
|
||||||
|
contact details below.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-8 text-[#1D1D1F]">
|
||||||
|
<Section title="1. Who we are">
|
||||||
|
<P>
|
||||||
|
{COMPANY} (“we”, “us”, “our”)
|
||||||
|
is the data controller responsible for your personal data
|
||||||
|
collected through this website, {SITE}. Our registered office is
|
||||||
|
in {ADDRESS}.
|
||||||
|
</P>
|
||||||
|
<P>
|
||||||
|
For any privacy-related request you can contact us at{" "}
|
||||||
|
<a href={`mailto:${CONTACT_EMAIL}`} className="text-[#0066CC] underline underline-offset-2">
|
||||||
|
{CONTACT_EMAIL}
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</P>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="2. What data we collect">
|
||||||
|
<P>We collect personal data only when you actively provide it, or through privacy-respecting analytics:</P>
|
||||||
|
<ul className="list-disc pl-5 space-y-1.5 text-[#3A3A3C]">
|
||||||
|
<li>
|
||||||
|
<strong>Contact & consultation requests:</strong> name, company,
|
||||||
|
email, phone (optional) and any message you send through our
|
||||||
|
forms or the FLUX AI assistant.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>AI assistant conversations:</strong> the messages you
|
||||||
|
exchange with the on-site assistant, used to answer your
|
||||||
|
questions and improve the service. Your IP address is stored
|
||||||
|
only in pseudonymised (hashed) form.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Analytics:</strong> aggregated, anonymised usage data
|
||||||
|
via Google Analytics 4 — but only after you accept analytics
|
||||||
|
cookies (see section 4).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Technical logs:</strong> standard server logs (IP,
|
||||||
|
browser, timestamps) kept for security and troubleshooting.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="3. How and why we use it">
|
||||||
|
<P>We process your data on the following legal bases (GDPR Art. 6):</P>
|
||||||
|
<ul className="list-disc pl-5 space-y-1.5 text-[#3A3A3C]">
|
||||||
|
<li><strong>Consent</strong> — analytics cookies; you can withdraw it at any time.</li>
|
||||||
|
<li><strong>Pre-contractual / legitimate interest</strong> — responding to your consultation and quote requests.</li>
|
||||||
|
<li><strong>Legitimate interest</strong> — keeping the site secure and improving our products and content.</li>
|
||||||
|
</ul>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="4. Cookies & consent">
|
||||||
|
<P>
|
||||||
|
We use a strictly necessary set of cookies to run the site and,
|
||||||
|
optionally, analytics cookies. When you first visit, a banner lets
|
||||||
|
you accept or decline analytics. We use Google Consent Mode v2:
|
||||||
|
until you accept, no analytics cookies are set and no personal
|
||||||
|
data is sent to Google. You can change your choice at any time by
|
||||||
|
clearing the site cookies in your browser.
|
||||||
|
</P>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="5. Who we share data with">
|
||||||
|
<P>We never sell your data. We share it only with trusted processors strictly to operate the site:</P>
|
||||||
|
<ul className="list-disc pl-5 space-y-1.5 text-[#3A3A3C]">
|
||||||
|
<li><strong>Google (Analytics)</strong> — anonymised usage statistics, only with your consent.</li>
|
||||||
|
<li><strong>Email / hosting providers</strong> — to deliver your requests to our team and host the site.</li>
|
||||||
|
</ul>
|
||||||
|
<P>
|
||||||
|
Some providers may process data outside the EU/EEA; where that
|
||||||
|
happens, transfers are covered by appropriate safeguards such as
|
||||||
|
the EU Standard Contractual Clauses.
|
||||||
|
</P>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="6. How long we keep it">
|
||||||
|
<P>
|
||||||
|
We keep consultation and contact data for as long as needed to
|
||||||
|
handle your request and to comply with legal obligations, then
|
||||||
|
delete or anonymise it. Analytics data is retained according to
|
||||||
|
Google Analytics’ configured retention period.
|
||||||
|
</P>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="7. Your rights">
|
||||||
|
<P>Under the GDPR you have the right to:</P>
|
||||||
|
<ul className="list-disc pl-5 space-y-1.5 text-[#3A3A3C]">
|
||||||
|
<li>access, rectify or erase your personal data;</li>
|
||||||
|
<li>restrict or object to processing;</li>
|
||||||
|
<li>data portability;</li>
|
||||||
|
<li>withdraw consent at any time;</li>
|
||||||
|
<li>lodge a complaint with your data protection authority (in Italy, the Garante per la protezione dei dati personali).</li>
|
||||||
|
</ul>
|
||||||
|
<P>
|
||||||
|
To exercise any of these rights, contact us at{" "}
|
||||||
|
<a href={`mailto:${CONTACT_EMAIL}`} className="text-[#0066CC] underline underline-offset-2">
|
||||||
|
{CONTACT_EMAIL}
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</P>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="8. Data security">
|
||||||
|
<P>
|
||||||
|
We apply appropriate technical and organisational measures
|
||||||
|
(encryption in transit, access controls, pseudonymisation) to
|
||||||
|
protect your data against unauthorised access, loss or misuse.
|
||||||
|
</P>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="9. Changes to this policy">
|
||||||
|
<P>
|
||||||
|
We may update this policy from time to time. The “last
|
||||||
|
updated” date at the top reflects the latest revision.
|
||||||
|
</P>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SITE = "rf-flux.com";
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg md:text-xl font-semibold text-[#1D1D1F] mb-3">{title}</h2>
|
||||||
|
<div className="space-y-3 text-[15px] leading-relaxed">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function P({ children }: { children: React.ReactNode }) {
|
||||||
|
return <p className="text-[#3A3A3C]">{children}</p>;
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
{ path: "/news", priority: 0.7, changeFrequency: "daily" as const },
|
{ path: "/news", priority: 0.7, changeFrequency: "daily" as const },
|
||||||
{ path: "/heritage", priority: 0.6, changeFrequency: "monthly" as const },
|
{ path: "/heritage", priority: 0.6, changeFrequency: "monthly" as const },
|
||||||
{ path: "/team", priority: 0.6, changeFrequency: "monthly" as const },
|
{ path: "/team", priority: 0.6, changeFrequency: "monthly" as const },
|
||||||
|
{ path: "/privacy", priority: 0.3, changeFrequency: "yearly" as const },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const locale of LOCALES) {
|
for (const locale of LOCALES) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Link } from "@/i18n/routing";
|
||||||
import {
|
import {
|
||||||
analyticsEnabled,
|
analyticsEnabled,
|
||||||
readStoredConsent,
|
readStoredConsent,
|
||||||
@@ -59,12 +60,12 @@ export default function ConsentBanner() {
|
|||||||
<p className="text-sm font-medium text-[#1D1D1F]">{t("title")}</p>
|
<p className="text-sm font-medium text-[#1D1D1F]">{t("title")}</p>
|
||||||
<p className="mt-1 text-xs leading-relaxed text-[#6E6E73]">
|
<p className="mt-1 text-xs leading-relaxed text-[#6E6E73]">
|
||||||
{t("body")}{" "}
|
{t("body")}{" "}
|
||||||
<a
|
<Link
|
||||||
href="/privacy"
|
href="/privacy"
|
||||||
className="underline decoration-[#00B8CC] underline-offset-2 hover:text-[#1D1D1F]"
|
className="underline decoration-[#00B8CC] underline-offset-2 hover:text-[#1D1D1F]"
|
||||||
>
|
>
|
||||||
{t("learnMore")}
|
{t("learnMore")}
|
||||||
</a>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user