From 1f57f6b222fb8de5e66c28c5055361c33b803a54 Mon Sep 17 00:00:00 2001 From: Samuel Dahunsi Date: Mon, 9 Nov 2026 09:13:00 +0100 Subject: [PATCH 1/3] feat: add privacy-conscious visitor and Testnet action analytics --- packages/app/.env.example | 8 + packages/app/app/home/disclose-flow.tsx | 2 + packages/app/app/home/home-client.tsx | 3 + packages/app/app/layout.tsx | 2 + packages/app/app/page.tsx | 5 +- packages/app/app/privacy/page.tsx | 42 +++++ packages/app/app/profile/page.tsx | 3 + packages/app/app/verify/verify-panel.tsx | 3 + .../app/components/analytics/Analytics.tsx | 36 +++++ packages/app/lib/analytics.ts | 149 ++++++++++++++++++ packages/app/lib/use-action.ts | 5 + packages/app/lib/wallet-context.tsx | 13 +- packages/app/lib/wallet.ts | 14 +- packages/app/next.config.mjs | 1 + 14 files changed, 276 insertions(+), 10 deletions(-) create mode 100644 packages/app/.env.example create mode 100644 packages/app/app/privacy/page.tsx create mode 100644 packages/app/components/analytics/Analytics.tsx create mode 100644 packages/app/lib/analytics.ts diff --git a/packages/app/.env.example b/packages/app/.env.example new file mode 100644 index 0000000..8d8fb90 --- /dev/null +++ b/packages/app/.env.example @@ -0,0 +1,8 @@ +# Analytics is off unless explicitly enabled. These values are public, NOT secrets. +NEXT_PUBLIC_ANALYTICS_ENABLED=false +# Exact production hostname, no https://, port or path. Preview/local hosts are excluded. +NEXT_PUBLIC_ANALYTICS_HOSTNAME=www.lucent-pay.xyz +# Website ID from your Umami tracking snippet (not an API key). +NEXT_PUBLIC_UMAMI_WEBSITE_ID= +# Copy the script URL supplied by Umami. Change only for a trusted instance. +NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://cloud.umami.is/script.js diff --git a/packages/app/app/home/disclose-flow.tsx b/packages/app/app/home/disclose-flow.tsx index 0ba4f01..8546961 100644 --- a/packages/app/app/home/disclose-flow.tsx +++ b/packages/app/app/home/disclose-flow.tsx @@ -12,6 +12,7 @@ import { useCallback, useState } from "react"; import type { TransferEvent, DisclosureRequest } from "@lucent/sdk"; import type { ConfidentialWallet } from "@/lib/wallet"; +import { trackEvent } from "@/lib/analytics"; import { errMsg } from "@/lib/err"; import { Button } from "@/components/ui/Button"; import { Textarea } from "@/components/ui/Textarea"; @@ -52,6 +53,7 @@ export function DiscloseFlow({ const request = parseDisclosureRequest(requestJson); const bundle = direction === "received" ? await wallet.discloseReceived(ev, request) : await wallet.discloseSent(ev, request); + trackEvent("disclosure_created", { direction }); setBundleJson(JSON.stringify(bundle, null, 2)); } catch (e) { setError(errMsg(e)); diff --git a/packages/app/app/home/home-client.tsx b/packages/app/app/home/home-client.tsx index 33580bb..773e0f6 100644 --- a/packages/app/app/home/home-client.tsx +++ b/packages/app/app/home/home-client.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, type ComponentType } from "react"; import Link from "next/link"; +import { trackEvent } from "@/lib/analytics"; import { useRouter } from "next/navigation"; import { motion } from "framer-motion"; import { @@ -359,6 +360,7 @@ export function HomeClient() { } async function handleRegister() { + trackEvent("action_started", { action: "registration" }); setError(null); setRegisterSteps([{ id: "register", label: "Prove key ownership", status: "active", estSeconds: 4 }]); setRegistering(true); @@ -367,6 +369,7 @@ export function HomeClient() { setRegisterSteps((s) => s.map((x) => ({ ...x, status: "done" }))); await refresh(); } catch (e) { + trackEvent("action_failed", { action: "registration" }); setError(errMsg(e)); setRegisterSteps((s) => s.map((x) => ({ ...x, status: "error" }))); } finally { diff --git a/packages/app/app/layout.tsx b/packages/app/app/layout.tsx index aaf8a1c..51eefae 100644 --- a/packages/app/app/layout.tsx +++ b/packages/app/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata, Viewport } from "next"; import "./globals.css"; +import { Analytics } from "@/components/analytics/Analytics"; import { WalletProvider } from "@/lib/wallet-context"; export const metadata: Metadata = { @@ -20,6 +21,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) return ( + {children} diff --git a/packages/app/app/page.tsx b/packages/app/app/page.tsx index 40f3377..bc9bb29 100644 --- a/packages/app/app/page.tsx +++ b/packages/app/app/page.tsx @@ -429,7 +429,10 @@ export default function LandingPage() {

The amount is the only secret

-
+
+ + Privacy + Stats diff --git a/packages/app/app/privacy/page.tsx b/packages/app/app/privacy/page.tsx new file mode 100644 index 0000000..f486371 --- /dev/null +++ b/packages/app/app/privacy/page.tsx @@ -0,0 +1,42 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { analyticsConfig, analyticsPreferences, setAnalyticsPreference, ANALYTICS_CHANGE_EVENT } from "@/lib/analytics"; + +export default function PrivacyPage() { + const [prefs, setPrefs] = useState | null>(null); + const [error, setError] = useState(false); + useEffect(() => { + const update = () => setPrefs(analyticsPreferences()); + update(); + window.addEventListener(ANALYTICS_CHANGE_EVENT, update); + window.addEventListener("storage", update); + return () => { window.removeEventListener(ANALYTICS_CHANGE_EVENT, update); window.removeEventListener("storage", update); }; + }, []); + function toggle(kind: "disabled" | "internal", value: boolean) { + setError(!setAnalyticsPreference(kind, value)); + } + return
+ ← Back to Lucent +

Privacy & analytics

+

Lucent is an unaudited Stellar Testnet application. Our demo auditor key is public. Use disposable test accounts and test assets.

+

What we measure

+

When configured for the public deployment, Umami measures page visits and named actions such as connecting a wallet, completing a Testnet transfer, or verifying a disclosure. This helps us improve onboarding and report early testing results. Visitor estimates are not verified people or customer counts.

+

Our analytics payload contains a known page name, event name, Testnet label, and optional allowlisted campaign source. We do not send wallet addresses, amounts, balances, transaction hashes, auditor keys, proofs, form input, raw errors, query strings, URL fragments, or referrer URLs. We do not enable session recording, autocapture or wallet-based identification.

+

Umami receives the network request and processes IP/browser information to estimate visitors using rotating identifiers. We do not add a persistent user ID. This is aggregate product analytics, not anonymity from the analytics service. See Umami's privacy policy. Retention depends on the selected plan; the initial rollout is intended to use the six-month Hobby plan.

+

Your controls

+

Opting out affects this browser only. Do Not Track and Global Privacy Control are also respected when exposed by your browser. Clearing browser storage resets these preferences and can also destroy confidential wallet recovery state—do not clear it just to change analytics settings.

+
+

{analyticsConfig() ? "Analytics is configured for the production hostname." : "Analytics is not configured in this build."}

+ {prefs && <> + {prefs.browserOptOut &&

Your browser privacy signal currently disables analytics.

} + + + } + {error &&

This browser could not save the preference. Analytics stays disabled when storage is unavailable.

} +
+

Separate network services

+

Your wallet, Stellar RPC, hosting provider and public blockchain process their own requests. Public transactions still expose participants and metadata. Turning off product analytics does not hide blockchain activity or remove infrastructure logs.

+
; +} diff --git a/packages/app/app/profile/page.tsx b/packages/app/app/profile/page.tsx index bf89132..1b051f4 100644 --- a/packages/app/app/profile/page.tsx +++ b/packages/app/app/profile/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import Link from "next/link"; import { useRouter } from "next/navigation"; import { Copy, Check, LogOut, Activity, BookOpen, Info, RefreshCw } from "lucide-react"; import { AppShell } from "@/components/layout/AppShell"; @@ -104,6 +105,8 @@ export default function ProfilePage() {
+ Privacy & analytics settings + Protocol
diff --git a/packages/app/app/verify/verify-panel.tsx b/packages/app/app/verify/verify-panel.tsx index 0762735..9ce02e0 100644 --- a/packages/app/app/verify/verify-panel.tsx +++ b/packages/app/app/verify/verify-panel.tsx @@ -7,6 +7,8 @@ * binding are all re-read from the chain, never trusted from the bundle. */ +import { trackEvent } from "@/lib/analytics"; + import { useCallback, useEffect, useState } from "react"; import { ChainClient, @@ -112,6 +114,7 @@ export function VerifyPanel() { pinnedVk: vkBytes(artifacts.vk.vkBase64), }), ); + trackEvent("disclosure_verified"); } finally { await prover.destroy(); } diff --git a/packages/app/components/analytics/Analytics.tsx b/packages/app/components/analytics/Analytics.tsx new file mode 100644 index 0000000..221d3bb --- /dev/null +++ b/packages/app/components/analytics/Analytics.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Script from "next/script"; +import { usePathname } from "next/navigation"; +import { ANALYTICS_CHANGE_EVENT, analyticsAllowed, analyticsConfig, flushAnalytics, trackPageview } from "@/lib/analytics"; + +export function Analytics() { + const pathname = usePathname(); + const [enabled, setEnabled] = useState(false); + useEffect(() => { + const update = () => setEnabled(analyticsAllowed()); + update(); + window.addEventListener(ANALYTICS_CHANGE_EVENT, update); + window.addEventListener("storage", update); + return () => { window.removeEventListener(ANALYTICS_CHANGE_EVENT, update); window.removeEventListener("storage", update); }; + }, []); + useEffect(() => { + if (enabled) trackPageview(pathname); + }, [pathname, enabled]); + const cfg = analyticsConfig(); + if (!enabled || !cfg) return null; + return