diff --git a/.env.example b/.env.example index 9829c8a..78c90a2 100755 --- a/.env.example +++ b/.env.example @@ -45,6 +45,8 @@ BERRYBRAIN_DONATION_URL= NEXT_PUBLIC_BERRYBRAIN_API_URL=/berrybrain NEXT_PUBLIC_BERRYBRAIN_BASE_PATH=/berrybrain NEXT_PUBLIC_BERRYBRAIN_ASSET_PREFIX=/berrybrain +# Optional. Leave empty on self-hosted instances to disable Google Analytics. +NEXT_PUBLIC_GOOGLE_ANALYTICS_ID= BERRYBRAIN_AUTH_RATE_LIMIT_WINDOW_SECONDS=900 BERRYBRAIN_AUTH_RATE_LIMIT_MAX_ATTEMPTS=8 BERRYBRAIN_AUTH_LOCKOUT_MINUTES=15 diff --git a/README.md b/README.md index b59b3ad..afb6a97 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ There is no central BerryBrain account, SaaS tenant, billing gate, demo mode, or ![Source Available](https://img.shields.io/badge/source--available-yes-3C8F5A) ![License](https://img.shields.io/badge/license-non--commercial-lightgrey) +[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/T5D823778G) + --- ## Table of Contents @@ -622,6 +624,7 @@ Edit `.env` and set at minimum: | `BERRYBRAIN_INTERNAL_API_URL` | Server-side API origin used by the web proxy. Defaults to `http://api:8000`; use `http://127.0.0.1:8000` when running Web outside Docker. | | `BERRYBRAIN_ENV_FILE` | Optional Compose environment file path. Defaults to `.env`; useful for isolated smoke tests or multiple self-hosted instances. | | `BERRYBRAIN_DONATION_URL` | Optional donation link shown/documented by the operator; no payment processing is built in. | +| `NEXT_PUBLIC_GOOGLE_ANALYTICS_ID` | Optional analytics property for public pages. Empty by default on self-hosted instances; tracking still requires visitor consent. Never send note or account data as analytics events. | | `BERRYBRAIN_PUBLIC_APP_URL` | Public base URL of the web app (used in emails/links). | | `BERRYBRAIN_CORS_ORIGINS` | Comma-separated allowed web origins. | | `SMTP_*` | Optional legacy email delivery settings. Not required for default self-hosted setup. | diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index f35eeed..fbb2f44 100755 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata, Viewport } from "next"; import { PwaRegister } from "@/components/pwa-register"; +import { ThirdPartyIntegrations } from "@/components/third-party-integrations"; import berrylogo from "../../public/berrylogo.png"; import "./globals.css"; @@ -50,6 +51,7 @@ export default function RootLayout({ {children} + ); diff --git a/apps/web/src/components/public-site/public-pages.tsx b/apps/web/src/components/public-site/public-pages.tsx index d57031c..dd4f612 100644 --- a/apps/web/src/components/public-site/public-pages.tsx +++ b/apps/web/src/components/public-site/public-pages.tsx @@ -26,6 +26,8 @@ const legalContent: Record = { title: "Privacy", body: [ "BerryBrain is local-first. User notes remain in the configured vault unless the user enables external providers.", + "Google Analytics is disabled by default on self-hosted instances and requires explicit visitor consent on the official website. Analytics is configured without advertising signals or ad personalization.", + "The Ko-fi donation widget is provided by an external service. Opening or using it is subject to Ko-fi's own privacy practices.", "When cloud AI, email, or external enrichment is configured, BerryBrain records provider, model, purpose, status, and evidence so the user can understand what left the local system.", "Account data is separated from note content. Security events may include timestamps, IP-derived request metadata, session state, and administrative actions needed to protect the service.", "Knowledge data is processed to build notes, concepts, graph edges, insights, and retrieval indexes. The product should never hide whether a result came from local processing or a configured external provider.", @@ -36,6 +38,7 @@ const legalContent: Record = { title: "GDPR and LGPD", body: [ "BerryBrain is designed around data minimization, transparency, and user-controlled processing. Notes and graph data are treated as personal knowledge data.", + "Optional website analytics remains disabled until consent is granted. Consent can be declined without losing access to BerryBrain.", "Self-hosted operators control access, correction, export, and deletion of their local instance data. Local vault files remain under the operator's storage control.", "Processing purposes include local authentication, instance security, note indexing, graph construction, retrieval, insight generation, and optional provider integrations configured by the local owner.", "For LGPD and GDPR requests, include enough context to verify ownership. Do not include passwords, API keys, tokens, or private notes in email.", @@ -81,6 +84,7 @@ const footerGroups = [ ["Docs", "/docs"], ["Open BerryBrain", "/brain"], ["GitHub", GITHUB_URL], + ["Donate", "https://ko-fi.com/T5D823778G"], ], }, { @@ -88,6 +92,7 @@ const footerGroups = [ links: [ ["Security", "legal:security"], ["Privacy", "legal:privacy"], + ["Privacy choices", "consent:analytics"], ["GDPR/LGPD", "legal:gdpr-lgpd"], ], }, @@ -152,6 +157,7 @@ const mobileNavLinks = [ ...nav, ["Security", "legal:security"], ["Privacy", "legal:privacy"], + ["Privacy choices", "consent:analytics"], ["GDPR/LGPD", "legal:gdpr-lgpd"], ["Terms", "legal:terms"], ["Contact", "legal:contact"], @@ -307,7 +313,17 @@ export function PublicShell({
    {mobileNavLinks.map(([label, href]) => (
  • - {href.startsWith("legal:") ? ( + {href === "consent:analytics" ? ( + + ) : href.startsWith("legal:") ? ( + ) : href.startsWith("legal:") ? ( diff --git a/apps/web/src/components/third-party-integrations.tsx b/apps/web/src/components/third-party-integrations.tsx new file mode 100644 index 0000000..938d10a --- /dev/null +++ b/apps/web/src/components/third-party-integrations.tsx @@ -0,0 +1,162 @@ +"use client"; + +import Script from "next/script"; +import { usePathname } from "next/navigation"; +import { useCallback, useEffect, useRef, useState } from "react"; + +const OFFICIAL_GOOGLE_ANALYTICS_ID = "G-36YL9QLC5K"; +const ANALYTICS_CONSENT_KEY = "bb_analytics_consent"; + +declare global { + interface Window { + dataLayer?: unknown[]; + gtag?: (...args: unknown[]) => void; + kofiWidgetOverlay?: { + draw: (account: string, options: Record) => void; + }; + } +} + +function analyticsIdForCurrentDeployment() { + const configuredId = process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID?.trim(); + if (configuredId && /^G-[A-Z0-9]{6,20}$/i.test(configuredId)) return configuredId; + if (typeof window === "undefined") return ""; + const hostname = window.location.hostname.toLowerCase(); + return hostname === "optlabs.com.br" || hostname === "www.optlabs.com.br" + ? OFFICIAL_GOOGLE_ANALYTICS_ID + : ""; +} + +function GoogleAnalytics({ enabled }: { enabled: boolean }) { + const [analyticsId, setAnalyticsId] = useState(""); + const [consent, setConsent] = useState<"granted" | "denied" | null>(null); + const configured = useRef(false); + + useEffect(() => { + setAnalyticsId(enabled ? analyticsIdForCurrentDeployment() : ""); + const readConsent = () => { + const saved = window.localStorage.getItem(ANALYTICS_CONSENT_KEY); + setConsent(saved === "granted" || saved === "denied" ? saved : null); + }; + readConsent(); + window.addEventListener("bb:analytics-consent", readConsent); + return () => window.removeEventListener("bb:analytics-consent", readConsent); + }, [enabled]); + + const choose = (value: "granted" | "denied") => { + const wasGranted = consent === "granted"; + window.localStorage.setItem(ANALYTICS_CONSENT_KEY, value); + setConsent(value); + if (wasGranted && value === "denied") window.location.reload(); + }; + + const configure = useCallback(() => { + if (!analyticsId || configured.current) return; + window.dataLayer = window.dataLayer || []; + window.gtag = (...args: unknown[]) => window.dataLayer?.push(args); + window.gtag("js", new Date()); + window.gtag("config", analyticsId, { + anonymize_ip: true, + allow_google_signals: false, + allow_ad_personalization_signals: false, + }); + configured.current = true; + }, [analyticsId]); + + if (!analyticsId) return null; + + return ( + <> + {consent === "granted" && ( + <> +