diff --git a/.env.example b/.env.example
index d7873d01..e15d8143 100644
--- a/.env.example
+++ b/.env.example
@@ -20,6 +20,16 @@ STEEM_RPC_URL=https://api.steemit.com
# Optional: Mixpanel analytics (client-side token)
# NEXT_PUBLIC_MIXPANEL_TOKEN=your-mixpanel-token
+# Optional: Google Analytics measurement ID (gtag). Read at runtime by the
+# server layout (no rebuild required). Legacy: SDC_GOOGLE_ANALYTICS_ID.
+# GOOGLE_ANALYTICS_ID=G-XXXXXXXX
+# SDC_GOOGLE_ANALYTICS_ID=G-XXXXXXXX
+
+# Optional: overseer whale thresholds (legacy config steem_whale / sbd_whale).
+# Used when relaying user_action events. Defaults: 10000 STEEM / 500 SBD.
+# STEEM_WHALE=10000
+# SBD_WHALE=500
+
# Optional: Rate limiting (Redis-backed when REDIS_URL is set; per-process
# in-memory fallback otherwise — not shared across instances).
# RATE_LIMIT_ALLOW_MEMORY_FALLBACK=false # reject (503) instead of falling back
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index 1f25ddd4..146e0401 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -37,6 +37,11 @@ services:
# Analytics
- MIXPANEL_TOKEN=${MIXPANEL_TOKEN:-}
- ANALYTICS_ENABLED=${ANALYTICS_ENABLED:-false}
+ # Runtime gtag ID (legacy SDC_GOOGLE_ANALYTICS_ID). Empty = script not injected.
+ - GOOGLE_ANALYTICS_ID=${GOOGLE_ANALYTICS_ID:-}
+ - SDC_GOOGLE_ANALYTICS_ID=${SDC_GOOGLE_ANALYTICS_ID:-}
+ - STEEM_WHALE=${STEEM_WHALE:-10000}
+ - SBD_WHALE=${SBD_WHALE:-500}
# Rate Limiting
- RATE_LIMIT_ENABLED=${RATE_LIMIT_ENABLED:-true}
diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx
index e06664a1..a3d09adb 100644
--- a/src/app/[locale]/layout.tsx
+++ b/src/app/[locale]/layout.tsx
@@ -2,10 +2,14 @@ import type { Metadata } from 'next';
import { Geist, Geist_Mono } from 'next/font/google';
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
+import { headers } from 'next/headers';
import { notFound } from 'next/navigation';
import { routing } from '@/i18n/routing';
import { Providers } from '../providers';
import { AppLayout } from '@/components/layout/app-layout';
+import { GoogleAnalytics } from '@/components/analytics/google-analytics';
+import { GoogleAnalyticsPageviews } from '@/components/analytics/google-analytics-pageviews';
+import { getGaMeasurementId } from '@/lib/analytics/ga-id';
import '../globals.css';
const geistSans = Geist({
@@ -18,6 +22,12 @@ const geistMono = Geist_Mono({
subsets: ['latin'],
});
+// All routes render per-request (condenser #4012 parity): runtime env like
+// GOOGLE_ANALYTICS_ID must never be baked into prerendered HTML. Pages are
+// already dynamic because of the CSP-nonce headers() call below; this export
+// makes the invariant explicit and independent of that mechanism.
+export const dynamic = 'force-dynamic';
+
export const metadata: Metadata = {
title: 'Steemit Wallet',
description: 'Steemit Wallet is an online wallet for managing Steem accounts.',
@@ -55,12 +65,20 @@ export default async function LocaleLayout({
// Providing all messages to the client
// side is the easiest way to get started
const messages = await getMessages();
+ const gaId = getGaMeasurementId();
+ const nonce = (await headers()).get('x-nonce') ?? undefined;
return (
+ {/* GA scripts must stay in a fragment free of 'use client' elements —
+ a client sibling inside the SAME fragment makes React defer the
+ scripts to hydration instead of emitting them in the SSR HTML.
+ Hence two separate conditionals, not one shared fragment. */}
+ {gaId ? : null}
+ {gaId ? : null}
{children}
diff --git a/src/app/api/analytics/overseer/route.ts b/src/app/api/analytics/overseer/route.ts
new file mode 100644
index 00000000..b1a58ade
--- /dev/null
+++ b/src/app/api/analytics/overseer/route.ts
@@ -0,0 +1,130 @@
+// POST /api/analytics/overseer
+// Relay frontend overseer.collect events (route tags + user actions).
+// Shape/allowlist only — the chain-side overseer service is the sink.
+import { NextRequest, NextResponse } from 'next/server';
+import { verifyCSRF, rateLimit } from '@/lib/middleware';
+import { SteemService } from '@/lib/steem/server';
+import {
+ buildRoutePayload,
+ buildUserActionPayload,
+ isOverseerRouteTag,
+ isOverseerUserAction,
+ isSteemAccountName,
+ isTrackingId,
+ whaleThresholdsFromEnv,
+ type UserActionParams,
+} from '@/lib/analytics/overseer-payload';
+
+const MAX_STRING = 64;
+const MAX_AMOUNT = 1e15;
+
+function asOptionalString(value: unknown): string | undefined {
+ if (typeof value !== 'string') return undefined;
+ const trimmed = value.trim();
+ if (!trimmed || trimmed.length > MAX_STRING) return undefined;
+ return trimmed;
+}
+
+function asOptionalAccount(value: unknown): string | undefined {
+ const s = asOptionalString(value);
+ if (!s) return undefined;
+ const name = s.replace(/^@/, '').toLowerCase();
+ return isSteemAccountName(name) ? name : undefined;
+}
+
+function asOptionalAmount(value: unknown): string | number | undefined {
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= MAX_AMOUNT) {
+ return value;
+ }
+ if (typeof value === 'string') {
+ const trimmed = value.trim();
+ if (!trimmed || trimmed.length > MAX_STRING) return undefined;
+ const n = parseFloat(trimmed.split(' ')[0] ?? '');
+ if (!Number.isFinite(n) || n < 0 || n > MAX_AMOUNT) return undefined;
+ return trimmed;
+ }
+ return undefined;
+}
+
+function sanitizeActionParams(raw: unknown): UserActionParams {
+ if (!raw || typeof raw !== 'object') return {};
+ const o = raw as Record;
+ const params: UserActionParams = {};
+ const username = asOptionalAccount(o.username);
+ const from = asOptionalAccount(o.from);
+ const to = asOptionalAccount(o.to);
+ const witness = asOptionalAccount(o.witness);
+ // Proxy may be empty string (clear proxy). Allow '' or a valid account.
+ let proxy: string | undefined;
+ if (typeof o.proxy === 'string') {
+ const p = o.proxy.trim().replace(/^@/, '').toLowerCase();
+ if (p === '') proxy = '';
+ else if (isSteemAccountName(p)) proxy = p;
+ }
+ const transferCoin = asOptionalString(o.transferCoin);
+ const amount = asOptionalAmount(o.amount);
+ if (username !== undefined) params.username = username;
+ if (from !== undefined) params.from = from;
+ if (to !== undefined) params.to = to;
+ if (witness !== undefined) params.witness = witness;
+ if (proxy !== undefined) params.proxy = proxy;
+ if (transferCoin !== undefined) params.transferCoin = transferCoin;
+ if (amount !== undefined) params.amount = amount;
+ return params;
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const csrfError = await verifyCSRF(request);
+ if (csrfError) return csrfError;
+
+ const rateLimitError = await rateLimit(request, 'analytics', {
+ maxRequests: 100,
+ windowSeconds: 60,
+ });
+ if (rateLimitError) return rateLimitError;
+
+ const body = (await request.json()) as Record;
+ const kind = body.kind;
+
+ if (kind === 'route') {
+ const tag = typeof body.tag === 'string' ? body.tag : '';
+ const trackingId = typeof body.trackingId === 'string' ? body.trackingId : '';
+ if (!isOverseerRouteTag(tag) || !isTrackingId(trackingId)) {
+ return NextResponse.json({ error: 'Invalid route event' }, { status: 400 });
+ }
+ const accountname = asOptionalAccount(
+ body.params && typeof body.params === 'object'
+ ? (body.params as Record).accountname
+ : undefined
+ );
+ const payload = buildRoutePayload(
+ trackingId,
+ tag,
+ accountname ? { accountname } : undefined,
+ body.isLogin === true
+ );
+ await SteemService.collectOverseer(payload);
+ return NextResponse.json({ success: true });
+ }
+
+ if (kind === 'action') {
+ const action = typeof body.action === 'string' ? body.action : '';
+ if (!isOverseerUserAction(action)) {
+ return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
+ }
+ const payload = buildUserActionPayload(
+ action,
+ sanitizeActionParams(body.params),
+ whaleThresholdsFromEnv()
+ );
+ await SteemService.collectOverseer(payload);
+ return NextResponse.json({ success: true });
+ }
+
+ return NextResponse.json({ error: 'Invalid event kind' }, { status: 400 });
+ } catch (error) {
+ console.error('Overseer analytics error:', error);
+ return NextResponse.json({ success: true });
+ }
+}
diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts
index 35d3c805..78632c85 100644
--- a/src/app/api/auth/login/route.ts
+++ b/src/app/api/auth/login/route.ts
@@ -4,6 +4,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { SteemService } from '@/lib/steem/server';
import { verifyCSRF, rateLimit } from '@/lib/middleware';
import { getRedis, redisKey } from '@/lib/cache/redis';
+import { buildUserLoginPayload } from '@/lib/analytics/overseer-payload';
export async function POST(request: NextRequest) {
try {
@@ -102,6 +103,10 @@ export async function POST(request: NextRequest) {
);
}
+ // Legacy `/login_account` checkpoint: overseer measurement `user_login`.
+ // Do not await — a slow/missing overseer must not delay the session response.
+ void SteemService.collectOverseer(buildUserLoginPayload(account.name));
+
// Return success with account info
return NextResponse.json({
success: true,
diff --git a/src/components/analytics/google-analytics-pageviews.tsx b/src/components/analytics/google-analytics-pageviews.tsx
new file mode 100644
index 00000000..c5925b0e
--- /dev/null
+++ b/src/components/analytics/google-analytics-pageviews.tsx
@@ -0,0 +1,33 @@
+'use client';
+
+import { useEffect } from 'react';
+// next/navigation's usePathname, NOT @/i18n/routing's: this component renders
+// in the layout OUTSIDE NextIntlClientProvider, where next-intl's usePathname
+// throws during SSR (React's error recovery then swallows sibling server
+// content). With localePrefix 'never' both hooks return the same path.
+import { usePathname } from 'next/navigation';
+
+declare global {
+ interface Window {
+ dataLayer?: unknown[];
+ gtag?: (...args: unknown[]) => void;
+ }
+}
+
+/**
+ * SPA virtual pageviews for gtag. The loader + init scripts render in the SSR
+ * HTML (see google-analytics.tsx) and execute at parse time, so window.gtag
+ * exists before hydration and the first effect run already reports the
+ * initial pageview — the init config sets send_page_view:false so this effect
+ * is the single source of pageviews (no double-count on first load).
+ */
+export function GoogleAnalyticsPageviews({ measurementId }: { measurementId: string }) {
+ const pathname = usePathname();
+
+ useEffect(() => {
+ if (typeof window.gtag !== 'function') return;
+ window.gtag('config', measurementId, { page_path: pathname });
+ }, [pathname, measurementId]);
+
+ return null;
+}
diff --git a/src/components/analytics/google-analytics.tsx b/src/components/analytics/google-analytics.tsx
new file mode 100644
index 00000000..12df7f32
--- /dev/null
+++ b/src/components/analytics/google-analytics.tsx
@@ -0,0 +1,49 @@
+/**
+ * gtag loader rendered straight into the SSR HTML so the browser loads it at
+ * parse time — no client-side injection, no hydration dependency (wallet-legacy
+ * `server-html.jsx` parity; also what condenser settled on in its #4010 fix).
+ * Init config matches legacy `JsPlugins.js`: cookie_domain auto, sample_rate 5,
+ * send_page_view:false (SPA pageviews are reported by GoogleAnalyticsPageviews
+ * — render it as a SEPARATE sibling conditional in the layout, never inside
+ * the same fragment: any 'use client' element sharing a fragment with these
+ * scripts makes React defer them to hydration instead of emitting them in the
+ * SSR HTML).
+ *
+ * measurementId must arrive validated (getGaMeasurementId — strict GA id
+ * regex) since it is interpolated into an inline script.
+ *
+ * Both scripts carry the CSP nonce: with 'strict-dynamic' the host allowlist
+ * only applies to legacy browsers, so an un-nonced script would be blocked.
+ */
+export function GoogleAnalytics({
+ measurementId,
+ nonce,
+}: {
+ measurementId: string;
+ nonce?: string;
+}) {
+ const scriptProps = nonce ? { nonce } : {};
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/src/components/analytics/overseer-page-tracker.tsx b/src/components/analytics/overseer-page-tracker.tsx
new file mode 100644
index 00000000..75026823
--- /dev/null
+++ b/src/components/analytics/overseer-page-tracker.tsx
@@ -0,0 +1,28 @@
+'use client';
+
+import { useEffect, useRef } from 'react';
+import { useSelector } from 'react-redux';
+import { usePathname } from '@/i18n/routing';
+import type { RootState } from '@/lib/store';
+import { recordRouteTag } from '@/lib/analytics/overseer';
+import { routeTagFromPathname } from '@/lib/analytics/overseer-payload';
+
+/**
+ * Legacy parity: each page dispatched `setRouteTag`, which saga'd into
+ * `overseer.collect` measurement `route`. One listener covers App Router navigations.
+ */
+export function OverseerPageTracker() {
+ const pathname = usePathname();
+ const isLogin = useSelector((state: RootState) => state.auth.isAuthenticated);
+ const last = useRef(null);
+
+ useEffect(() => {
+ const key = `${pathname}|${isLogin ? '1' : '0'}`;
+ if (last.current === key) return;
+ last.current = key;
+ const mapped = routeTagFromPathname(pathname);
+ recordRouteTag(mapped.tag, mapped.params, isLogin);
+ }, [pathname, isLogin]);
+
+ return null;
+}
diff --git a/src/components/layout/app-layout.tsx b/src/components/layout/app-layout.tsx
index 4cd30ec5..4c7e0900 100644
--- a/src/components/layout/app-layout.tsx
+++ b/src/components/layout/app-layout.tsx
@@ -4,6 +4,7 @@ import { Header } from './header';
import { SidePanel } from './side-panel';
import { TooltipProvider } from '@/components/ui/tooltip';
import { DegradationBanner } from './degradation-banner';
+import { OverseerPageTracker } from '@/components/analytics/overseer-page-tracker';
import { useState } from 'react';
import { Toaster } from 'sonner';
@@ -13,6 +14,7 @@ export function AppLayout({ children }: { children: React.ReactNode }) {
return (
+
setSidePanelOpen(true)} />
diff --git a/src/components/wallet/cancel-power-down-handler.tsx b/src/components/wallet/cancel-power-down-handler.tsx
index 68029752..e7c55e7e 100644
--- a/src/components/wallet/cancel-power-down-handler.tsx
+++ b/src/components/wallet/cancel-power-down-handler.tsx
@@ -6,6 +6,7 @@ import { useSelector } from 'react-redux';
import type { RootState } from '@/lib/store';
import { useActiveSigningKey } from '@/hooks/use-auth';
import { SteemSigner, apiClient } from '@/lib/steem/client';
+import { userActionRecord } from '@/lib/analytics/overseer';
export interface CancelPowerDownHandlerProps {
onSuccess: () => void;
@@ -33,6 +34,7 @@ export function CancelPowerDownHandler({ onSuccess, onCancel }: CancelPowerDownH
setError(response.error || t('cancelError'));
return;
}
+ userActionRecord('cancel_withdraw_vesting', { username });
onSuccess();
} catch (err) {
console.error('Cancel power down error:', err);
diff --git a/src/components/wallet/change-password-section.tsx b/src/components/wallet/change-password-section.tsx
index 73078d8d..18ae31b9 100644
--- a/src/components/wallet/change-password-section.tsx
+++ b/src/components/wallet/change-password-section.tsx
@@ -20,6 +20,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
+import { userActionRecord } from '@/lib/analytics/overseer';
export interface ChangePasswordSectionProps {
username: string;
@@ -116,6 +117,7 @@ export function ChangePasswordSection({
clearRememberedPostingKey();
setChangeSuccess(true);
+ userActionRecord('change_password', { username });
await logout();
} catch (err) {
const message = err instanceof Error ? err.message : t('broadcastFailed');
diff --git a/src/components/wallet/delegate-form.tsx b/src/components/wallet/delegate-form.tsx
index 49664ee1..d887d3e1 100644
--- a/src/components/wallet/delegate-form.tsx
+++ b/src/components/wallet/delegate-form.tsx
@@ -18,6 +18,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { transfersPathForUsername } from '@/lib/wallet/wallet-modal-search-params';
+import { userActionRecord } from '@/lib/analytics/overseer';
export type DelegateFormVariant = 'page' | 'dialog';
@@ -87,6 +88,13 @@ export function DelegateForm({
return;
}
+ userActionRecord('delegate_vesting_shares', {
+ transferCoin: 'VESTS',
+ amount: shareValue,
+ from: username,
+ to: delegatee.trim().replace(/^@/, '').toLowerCase(),
+ });
+
setIsLoading(false);
startTransition(() => {
if (onSuccess) onSuccess();
diff --git a/src/components/wallet/power-down-form.tsx b/src/components/wallet/power-down-form.tsx
index bd6bbf24..718f63f1 100644
--- a/src/components/wallet/power-down-form.tsx
+++ b/src/components/wallet/power-down-form.tsx
@@ -33,6 +33,7 @@ import {
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { transfersPathForUsername } from '@/lib/wallet/wallet-modal-search-params';
import { cn } from '@/lib/utils';
+import { userActionRecord } from '@/lib/analytics/overseer';
export type PowerDownFormVariant = 'page' | 'dialog';
@@ -139,6 +140,11 @@ export function PowerDownForm({ variant = 'page', onSuccess }: PowerDownFormProp
return;
}
+ userActionRecord('withdraw_vesting', {
+ username,
+ amount: steemPowerFromVests(withdraw, globalProps),
+ });
+
finishSuccess();
} catch (err) {
console.error('Power down error:', err);
diff --git a/src/components/wallet/power-up-form.tsx b/src/components/wallet/power-up-form.tsx
index c21993c5..b98dbc4d 100644
--- a/src/components/wallet/power-up-form.tsx
+++ b/src/components/wallet/power-up-form.tsx
@@ -17,6 +17,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { transfersPathForUsername } from '@/lib/wallet/wallet-modal-search-params';
+import { userActionRecord } from '@/lib/analytics/overseer';
export type PowerUpFormVariant = 'page' | 'dialog';
@@ -152,6 +153,13 @@ export function PowerUpForm({
return;
}
+ userActionRecord('transfer_to_vesting', {
+ transferCoin: 'STEEM',
+ amount: amountValue,
+ from: username,
+ to: recipientName,
+ });
+
finishSuccess();
} catch (err) {
console.error('Power up error:', err);
diff --git a/src/components/wallet/recover-account-confirmation-page.tsx b/src/components/wallet/recover-account-confirmation-page.tsx
index bff9802f..7c80d66c 100644
--- a/src/components/wallet/recover-account-confirmation-page.tsx
+++ b/src/components/wallet/recover-account-confirmation-page.tsx
@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { apiClient, SteemSigner } from '@/lib/steem/client';
+import { userActionRecord } from '@/lib/analytics/overseer';
function passwordToOwnerPubKey(username: string, password: string): string {
const raw = password.trim();
@@ -128,6 +129,7 @@ export function RecoverAccountConfirmationPage({ code }: { code: string }) {
}
setSuccess(true);
+ userActionRecord('recovery_account', { username: name });
} catch (err) {
setSubmitError(err instanceof Error ? err.message : t('unknownError'));
} finally {
diff --git a/src/components/wallet/savings-withdraw-history.tsx b/src/components/wallet/savings-withdraw-history.tsx
index ce67d031..db4cfc37 100644
--- a/src/components/wallet/savings-withdraw-history.tsx
+++ b/src/components/wallet/savings-withdraw-history.tsx
@@ -8,6 +8,7 @@ import { cachedFetch } from '@/lib/cache/client-fetch';
import { clientCache } from '@/lib/cache/client-cache';
import { formatTimeUntil } from '@/lib/wallet/format-time-ago';
import type { PendingSavingsWithdrawal } from '@/hooks/use-wallet-estimated-value';
+import { userActionRecord } from '@/lib/analytics/overseer';
import {
AlertDialog,
AlertDialogCancel,
@@ -81,6 +82,7 @@ export function SavingsWithdrawHistory({
setCancelling(false);
return;
}
+ userActionRecord('cancel_transfer_from_savings', { username });
setCancelTarget(null);
setCancelling(false);
clientCache.invalidate(extrasUrl);
diff --git a/src/components/wallet/transfer-form.tsx b/src/components/wallet/transfer-form.tsx
index 3e5adbee..8c20089a 100644
--- a/src/components/wallet/transfer-form.tsx
+++ b/src/components/wallet/transfer-form.tsx
@@ -30,6 +30,7 @@ import {
transfersPathForUsername,
type WalletTransferType,
} from '@/lib/wallet/wallet-modal-search-params';
+import { userActionRecord } from '@/lib/analytics/overseer';
export type TransferFormVariant = 'page' | 'dialog';
@@ -347,6 +348,23 @@ export function TransferForm({
return;
}
+ const recipient =
+ transferType === 'transfer'
+ ? formData.to.trim().replace(/^@/, '').toLowerCase()
+ : username;
+ const overseerAction =
+ transferType === 'transfer'
+ ? 'transfer'
+ : transferType === 'savings'
+ ? 'transfer_to_savings'
+ : 'transfer_from_savings';
+ userActionRecord(overseerAction, {
+ transferCoin: amountSuffix,
+ amount: amountValue,
+ from: username,
+ to: recipient,
+ });
+
setIsLoading(false);
startTransition(() => {
if (onSuccess) {
diff --git a/src/components/wallet/witness-vote-form.tsx b/src/components/wallet/witness-vote-form.tsx
index ddc441be..b7530fc6 100644
--- a/src/components/wallet/witness-vote-form.tsx
+++ b/src/components/wallet/witness-vote-form.tsx
@@ -11,6 +11,7 @@ import type { Witness } from '@/lib/steem/types';
import { LoginForm } from '@/components/auth/login-form';
import { DISABLED_SIGNING_KEY } from '@/lib/steem/constants';
import { toast } from 'sonner';
+import { userActionRecord } from '@/lib/analytics/overseer';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -158,6 +159,7 @@ export function WitnessVoteForm() {
(approve ? t('voteError') : t('unvoteError'))
);
}
+ userActionRecord('account_witness_vote', { username, witness: witnessName });
},
[t, username]
);
@@ -215,6 +217,7 @@ export function WitnessVoteForm() {
const signedTx = await SteemSigner.signWitnessProxy(username, nextProxy, key);
const resp = await apiClient.broadcastWitnessProxy(signedTx, username);
if (!resp.success) throw new Error(resp.error || tCommon('error'));
+ userActionRecord('account_witness_proxy', { username, proxy: nextProxy });
setProxyInput('');
toast.success(nextProxy ? t('proxySetSuccess') : t('proxyClearedSuccess'));
await refetchAccount();
@@ -255,6 +258,7 @@ export function WitnessVoteForm() {
const signedTx = await SteemSigner.signWitnessProxy(username, pendingAction.proxy, key);
const resp = await apiClient.broadcastWitnessProxy(signedTx, username);
if (!resp.success) throw new Error(resp.error || tCommon('error'));
+ userActionRecord('account_witness_proxy', { username, proxy: pendingAction.proxy });
toast.success(pendingAction.proxy ? t('proxySetSuccess') : t('proxyClearedSuccess'));
}
await refetchAccount();
diff --git a/src/lib/analytics/ga-id.ts b/src/lib/analytics/ga-id.ts
new file mode 100644
index 00000000..3febd262
--- /dev/null
+++ b/src/lib/analytics/ga-id.ts
@@ -0,0 +1,32 @@
+/**
+ * Google Analytics measurement ID helpers.
+ *
+ * Legacy injected gtag from `google_analytics_id` / `SDC_GOOGLE_ANALYTICS_ID`
+ * at render time (not a build-time public env). Read the ID on the server so
+ * production can change it without rebuilding the Next.js client bundle.
+ */
+
+const GA_ID_RE = /^(G|GT|AW)-[A-Z0-9]+$|^UA-\d+-\d+$/i;
+
+export function isValidGaMeasurementId(id: string): boolean {
+ return GA_ID_RE.test(id.trim());
+}
+
+/**
+ * Resolve the configured GA measurement ID, or null if unset/invalid.
+ * Checked in order: GOOGLE_ANALYTICS_ID, SDC_GOOGLE_ANALYTICS_ID (legacy ops),
+ * NEXT_PUBLIC_GOOGLE_ANALYTICS_ID (build-time fallback).
+ */
+export function getGaMeasurementId(): string | null {
+ const candidates = [
+ process.env.GOOGLE_ANALYTICS_ID,
+ process.env.SDC_GOOGLE_ANALYTICS_ID,
+ process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID,
+ ];
+ for (const raw of candidates) {
+ if (typeof raw !== 'string') continue;
+ const id = raw.trim();
+ if (id && isValidGaMeasurementId(id)) return id;
+ }
+ return null;
+}
diff --git a/src/lib/analytics/overseer-payload.ts b/src/lib/analytics/overseer-payload.ts
new file mode 100644
index 00000000..0a194a02
--- /dev/null
+++ b/src/lib/analytics/overseer-payload.ts
@@ -0,0 +1,298 @@
+/**
+ * Overseer payload builders matching wallet-legacy ServerApiClient.js.
+ *
+ * Legacy called `api.call('overseer.collect', ['custom', { measurement, fields, tags }])`
+ * from the browser (jussi routes `overseer.*` to the overseer service). The rewrite
+ * reconstructs the same payload on the server and relays it so browsers do not
+ * talk to api.steemit.com directly.
+ *
+ * Whale lookup lowercases `transferCoin` (`STEEM` → `steem`). Legacy compared
+ * against `whaleThreshold[params.transferCoin]` whose keys were lowercase, so
+ * STEEM/SBD transfers never tagged as whale. That was a bug; the config intent
+ * (`steem_whale` / `sbd_whale`) is preserved here.
+ */
+
+export const OVERSEER_USER_ACTIONS = [
+ 'transfer',
+ 'change_password',
+ 'recovery_account',
+ 'withdraw_vesting',
+ 'cancel_withdraw_vesting',
+ 'cancel_transfer_from_savings',
+ 'transfer_to_vesting',
+ 'transfer_to_savings',
+ 'transfer_from_savings',
+ 'delegate_vesting_shares',
+ 'account_witness_vote',
+ 'account_witness_proxy',
+] as const;
+
+export type OverseerUserAction = (typeof OVERSEER_USER_ACTIONS)[number];
+
+export const OVERSEER_ROUTE_TAGS = [
+ 'index',
+ 'login',
+ 'market',
+ 'proposals',
+ 'vote_to_witness',
+ 'privacy',
+ 'tos',
+ 'faq',
+ 'about',
+ 'support',
+ 'recover_account_step1',
+ 'recover_account_step2',
+ 'user_index',
+ 'change_password',
+ 'not_found',
+] as const;
+
+export type OverseerRouteTag = (typeof OVERSEER_ROUTE_TAGS)[number];
+
+export type OverseerTags = Record;
+export type OverseerFields = Record;
+
+export interface OverseerCustomPayload {
+ measurement: string;
+ tags: OverseerTags;
+ fields: OverseerFields;
+}
+
+export interface WhaleThresholds {
+ steem: number;
+ sbd: number;
+}
+
+export const DEFAULT_WHALE_THRESHOLDS: WhaleThresholds = {
+ steem: 10000,
+ sbd: 500,
+};
+
+export interface UserActionParams {
+ username?: string;
+ from?: string;
+ to?: string;
+ amount?: string | number;
+ transferCoin?: string;
+ witness?: string;
+ proxy?: string;
+}
+
+export interface RouteTagParams {
+ accountname?: string;
+}
+
+const ACTION_SET: ReadonlySet = new Set(OVERSEER_USER_ACTIONS);
+const TAG_SET: ReadonlySet = new Set(OVERSEER_ROUTE_TAGS);
+
+export function isOverseerUserAction(value: string): value is OverseerUserAction {
+ return ACTION_SET.has(value);
+}
+
+export function isOverseerRouteTag(value: string): value is OverseerRouteTag {
+ return TAG_SET.has(value);
+}
+
+/** Steem account names are 3–16 chars: leading letter, then [a-z0-9.-]. */
+export const STEEM_ACCOUNT_RE = /^[a-z][a-z0-9.-]{2,15}$/;
+
+export function isSteemAccountName(value: string): boolean {
+ return STEEM_ACCOUNT_RE.test(value);
+}
+
+/** Legacy session uid: 13 random bytes as hex (26 chars). Allow 8–32 hex. */
+export const TRACKING_ID_RE = /^[a-f0-9]{8,32}$/;
+
+export function isTrackingId(value: string): boolean {
+ return TRACKING_ID_RE.test(value);
+}
+
+export function amountNumber(amount: string | number | undefined): number {
+ if (typeof amount === 'number') return Number.isFinite(amount) ? amount : 0;
+ if (typeof amount !== 'string') return 0;
+ const raw = parseFloat(amount.trim().split(' ')[0] ?? '');
+ return Number.isFinite(raw) ? raw : 0;
+}
+
+function whaleFlag(amount: string | number | undefined, coin: string | undefined, thresholds: WhaleThresholds): string {
+ if (!coin) return 'false';
+ const key = coin.toLowerCase();
+ const threshold = key === 'steem' ? thresholds.steem : key === 'sbd' ? thresholds.sbd : undefined;
+ if (threshold === undefined) return 'false';
+ return (amountNumber(amount) > threshold).toString();
+}
+
+/**
+ * Static routes first (several of these strings are also valid Steem account
+ * names, e.g. `market`). Remaining `/` paths map to `user_index`.
+ */
+const EXACT_ROUTE_TAGS: Record = {
+ '/': 'index',
+ '/login': 'login',
+ '/market': 'market',
+ '/proposals': 'proposals',
+ '/witnesses': 'vote_to_witness',
+ '/privacy': 'privacy',
+ '/tos': 'tos',
+ '/faq': 'faq',
+ '/about': 'about',
+ '/support': 'support',
+ '/recover_account_step_1': 'recover_account_step1',
+};
+
+export function routeTagFromPathname(pathname: string): {
+ tag: OverseerRouteTag;
+ params?: RouteTagParams;
+} {
+ const path = (pathname.replace(/\/+$/, '') || '/') as string;
+ const exact = EXACT_ROUTE_TAGS[path];
+ if (exact) return { tag: exact };
+
+ if (/^\/account_recovery_confirmation\/[^/]+$/.test(path)) {
+ return { tag: 'recover_account_step2' };
+ }
+
+ const userMatch = /^\/([^/]+)(?:\/(.*))?$/.exec(path);
+ const account = userMatch?.[1];
+ if (userMatch && account && isSteemAccountName(account)) {
+ const rest = userMatch[2];
+ if (rest === 'settings') {
+ return { tag: 'change_password', params: { accountname: account } };
+ }
+ return { tag: 'user_index', params: { accountname: account } };
+ }
+
+ return { tag: 'not_found' };
+}
+
+export function buildRoutePayload(
+ trackingId: string,
+ tag: OverseerRouteTag,
+ params: RouteTagParams | undefined,
+ isLogin: boolean
+): OverseerCustomPayload {
+ const tags: OverseerTags = {
+ app: 'wallet',
+ tag,
+ is_login: isLogin,
+ };
+ const fields: OverseerFields =
+ tag === 'user_index' && params?.accountname
+ ? { trackingId, permlink: params.accountname }
+ : { trackingId };
+ return { measurement: 'route', tags, fields };
+}
+
+export function buildUserActionPayload(
+ action: OverseerUserAction,
+ params: UserActionParams,
+ thresholds: WhaleThresholds = DEFAULT_WHALE_THRESHOLDS
+): OverseerCustomPayload {
+ let tags: OverseerTags = {
+ app: 'wallet',
+ action_type: action,
+ };
+ let fields: OverseerFields = {};
+
+ switch (action) {
+ case 'transfer':
+ tags = {
+ app: 'wallet',
+ action_type: action,
+ transfer_coin: String(params.transferCoin ?? ''),
+ whale: whaleFlag(params.amount, params.transferCoin, thresholds),
+ };
+ fields = {
+ from_username: String(params.from ?? ''),
+ to_username: String(params.to ?? ''),
+ amount: amountNumber(params.amount),
+ };
+ break;
+ case 'change_password':
+ fields = { username: String(params.username ?? '') };
+ break;
+ case 'recovery_account':
+ fields = { username: String(params.username ?? '') };
+ break;
+ case 'withdraw_vesting':
+ tags = {
+ app: 'wallet',
+ action_type: action,
+ whale: whaleFlag(params.amount, 'steem', thresholds),
+ };
+ fields = {
+ username: String(params.username ?? ''),
+ amount: amountNumber(params.amount),
+ };
+ break;
+ case 'cancel_withdraw_vesting':
+ tags = { app: 'wallet', action_type: action };
+ fields = { username: String(params.username ?? '') };
+ break;
+ case 'cancel_transfer_from_savings':
+ fields = { username: String(params.username ?? '') };
+ break;
+ case 'transfer_to_vesting':
+ tags = {
+ app: 'wallet',
+ action_type: action,
+ whale: whaleFlag(params.amount, 'steem', thresholds),
+ };
+ fields = {
+ from_username: String(params.from ?? ''),
+ to_username: String(params.to ?? ''),
+ amount: amountNumber(params.amount),
+ };
+ break;
+ case 'transfer_to_savings':
+ case 'transfer_from_savings':
+ case 'delegate_vesting_shares':
+ tags = {
+ app: 'wallet',
+ action_type: action,
+ transfer_coin: String(params.transferCoin ?? ''),
+ whale: whaleFlag(params.amount, params.transferCoin, thresholds),
+ };
+ fields = {
+ from_username: String(params.from ?? ''),
+ to_username: String(params.to ?? ''),
+ amount: amountNumber(params.amount),
+ };
+ break;
+ case 'account_witness_vote':
+ fields = {
+ username: String(params.username ?? ''),
+ witness: String(params.witness ?? ''),
+ };
+ break;
+ case 'account_witness_proxy':
+ fields = {
+ username: String(params.username ?? ''),
+ proxy: String(params.proxy ?? ''),
+ };
+ break;
+ }
+
+ return { measurement: 'user_action', tags, fields };
+}
+
+export function buildUserLoginPayload(username: string): OverseerCustomPayload {
+ return {
+ measurement: 'user_login',
+ tags: { entry: 'wallet' },
+ fields: { username },
+ };
+}
+
+export function whaleThresholdsFromEnv(): WhaleThresholds {
+ return {
+ steem: parsePositiveNumber(process.env.STEEM_WHALE, DEFAULT_WHALE_THRESHOLDS.steem),
+ sbd: parsePositiveNumber(process.env.SBD_WHALE, DEFAULT_WHALE_THRESHOLDS.sbd),
+ };
+}
+
+function parsePositiveNumber(raw: string | undefined, fallback: number): number {
+ if (raw === undefined || raw === '') return fallback;
+ const n = Number(raw);
+ return Number.isFinite(n) && n > 0 ? n : fallback;
+}
diff --git a/src/lib/analytics/overseer.ts b/src/lib/analytics/overseer.ts
new file mode 100644
index 00000000..fde9c58d
--- /dev/null
+++ b/src/lib/analytics/overseer.ts
@@ -0,0 +1,77 @@
+'use client';
+
+/**
+ * Client helpers for overseer frontend tracking (legacy ServerApiClient).
+ *
+ * Events POST to /api/analytics/overseer (CSRF + rate limit); the server
+ * reconstructs the legacy payload and relays `overseer.collect` to jussi.
+ * The csrf_token cookie is issued by the proxy middleware on every document
+ * response, so it is available to anonymous visitors from the first page load.
+ * Failures are swallowed — analytics must never break wallet actions.
+ */
+
+import type { OverseerRouteTag, OverseerUserAction, RouteTagParams, UserActionParams } from './overseer-payload';
+
+const TRACKING_ID_KEY = 'wallet_tracking_id';
+
+function csrfHeader(): Record {
+ if (typeof document === 'undefined') return {};
+ const match = document.cookie.match(/(?:^|;\s*)csrf_token=([^;]*)/);
+ const token = match?.[1] ? decodeURIComponent(match[1]) : null;
+ return token ? { 'X-CSRF-Token': token } : {};
+}
+
+/**
+ * Anonymous visitor id (legacy `session.uid`: 13 random bytes as hex).
+ * Persisted in localStorage because this app has no server session cookie.
+ */
+export function getTrackingId(): string {
+ if (typeof window === 'undefined') return '';
+ try {
+ const existing = localStorage.getItem(TRACKING_ID_KEY);
+ if (existing && /^[a-f0-9]{8,32}$/.test(existing)) return existing;
+ const bytes = new Uint8Array(13);
+ crypto.getRandomValues(bytes);
+ let hex = '';
+ for (const b of bytes) hex += b.toString(16).padStart(2, '0');
+ localStorage.setItem(TRACKING_ID_KEY, hex);
+ return hex;
+ } catch {
+ return '';
+ }
+}
+
+async function postOverseer(body: unknown): Promise {
+ try {
+ await fetch('/api/analytics/overseer', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...csrfHeader(),
+ },
+ body: JSON.stringify(body),
+ });
+ } catch {
+ // Swallow — never surface analytics errors to the user.
+ }
+}
+
+export function recordRouteTag(
+ tag: OverseerRouteTag,
+ params: RouteTagParams | undefined,
+ isLogin = false
+): void {
+ const trackingId = getTrackingId();
+ if (!trackingId) return;
+ void postOverseer({
+ kind: 'route',
+ tag,
+ trackingId,
+ isLogin,
+ ...(params ? { params } : {}),
+ });
+}
+
+export function userActionRecord(action: OverseerUserAction, params: UserActionParams): void {
+ void postOverseer({ kind: 'action', action, params });
+}
diff --git a/src/lib/steem/server.ts b/src/lib/steem/server.ts
index 9cd58318..0c3fa656 100644
--- a/src/lib/steem/server.ts
+++ b/src/lib/steem/server.ts
@@ -6,6 +6,7 @@ import { steem } from '@steemit/steem-js';
import { formatSteemIsoTimestamp } from '@/lib/steem/chain-time';
+import type { OverseerCustomPayload } from '@/lib/analytics/overseer-payload';
import {
ORDERBOOK_LIMIT,
RECENT_TRADES_LIMIT,
@@ -44,6 +45,7 @@ const STEEM_RPC_URLS = (process.env.STEEM_RPC_URL || 'https://api.steemit.com')
.filter(Boolean);
let currentUrlIndex = 0;
+let overseerWarned = false;
function getCurrentRpcUrl(): string {
return STEEM_RPC_URLS[currentUrlIndex % STEEM_RPC_URLS.length] ?? STEEM_RPC_URLS[0]!;
}
@@ -998,6 +1000,38 @@ export class SteemService {
});
}
+ /**
+ * Relay an overseer.collect custom event through the Steem RPC (jussi).
+ * Fire-and-forget from callers: never throws, logs at most once per process
+ * so a missing overseer upstream cannot flood logs or fail user flows.
+ *
+ * Deliberately NOT wrapped in withFailover: analytics must never rotate the
+ * shared failover index (it would move real chain traffic to a different
+ * RPC just because the overseer namespace is down) nor retry every
+ * configured URL per event (a warn per URL per event). One attempt against
+ * the current RPC, then give up.
+ */
+ static async collectOverseer(payload: OverseerCustomPayload): Promise {
+ try {
+ ensureConfigured();
+ const api = steem.api as unknown as {
+ callAsync?: (method: string, params: unknown) => Promise;
+ };
+ if (typeof api.callAsync !== 'function') {
+ throw new Error('Steem API callAsync is not available');
+ }
+ await api.callAsync('overseer.collect', ['custom', payload]);
+ } catch (err) {
+ if (!overseerWarned) {
+ overseerWarned = true;
+ console.warn(
+ 'overseer.collect failed; further errors suppressed:',
+ err instanceof Error ? err.message : err
+ );
+ }
+ }
+ }
+
static async listProposalVotesByVoter(voter: string): Promise<
{
voter: string;
diff --git a/src/proxy.ts b/src/proxy.ts
index c5ed52d9..f95abd0c 100644
--- a/src/proxy.ts
+++ b/src/proxy.ts
@@ -20,12 +20,82 @@ function generateNonce(): string {
return btoa(binary);
}
+// ---------------------------------------------------------------------------
+// CSRF cookie issuance.
+//
+// The double-submit CSRF token (see src/lib/middleware/csrf.ts) is stateless:
+// `base64url(timestamp).base64url(HMAC-SHA256(CSRF_SECRET, timestamp))`. Minting
+// it here on every document response means the cookie exists from the very
+// first page load, so anonymous visitors (who never call /api/auth/challenge —
+// previously the only issuance point) can POST analytics events. Any instance
+// can verify tokens minted by any other because they share CSRF_SECRET.
+//
+// The middleware runs in the edge runtime, where node:crypto is unavailable;
+// Web Crypto (crypto.subtle) produces the exact same HMAC-SHA256 bytes. A unit
+// test asserts parity with the Node-side generator. Without a configured
+// CSRF_SECRET nothing is minted — mirroring the fail-closed posture of the
+// Node verifier (a per-runtime random secret would mint tokens that never
+// validate anywhere).
+// ---------------------------------------------------------------------------
+
+const CSRF_COOKIE_NAME = 'csrf_token';
+const CSRF_COOKIE_MAX_AGE = 24 * 60 * 60; // 24 hours, matches csrf.ts
+
+const encoder = new TextEncoder();
+let hmacKey: { secret: string; key: CryptoKey } | null = null;
+let warnedMissingCsrfSecret = false;
+
+function getCsrfSecret(): string | null {
+ const envSecret = process.env.CSRF_SECRET;
+ if (envSecret && envSecret.trim()) return envSecret.trim();
+ if (process.env.NODE_ENV === 'production' && !warnedMissingCsrfSecret) {
+ warnedMissingCsrfSecret = true;
+ console.error('CSRF_SECRET is not set — proxy mints no CSRF cookie; mutations will be rejected');
+ }
+ return null;
+}
+
+function toBase64url(binary: string): string {
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+}
+
+async function hmacBase64url(secret: string, message: string): Promise {
+ if (!hmacKey || hmacKey.secret !== secret) {
+ hmacKey = {
+ secret,
+ key: await crypto.subtle.importKey(
+ 'raw',
+ encoder.encode(secret),
+ { name: 'HMAC', hash: 'SHA-256' },
+ false,
+ ['sign']
+ ),
+ };
+ }
+ const signature = await crypto.subtle.sign('HMAC', hmacKey.key, encoder.encode(message));
+ let binary = '';
+ for (const b of new Uint8Array(signature)) binary += String.fromCharCode(b);
+ return toBase64url(binary);
+}
+
+/** Token format identical to generateCSRFToken() in src/lib/middleware/csrf.ts. */
+async function generateCsrfToken(secret: string): Promise {
+ const timestamp = Date.now().toString();
+ // Timestamps are ASCII digits, so btoa == Buffer.from(ts, 'utf8').toString('base64').
+ const tsB64 = toBase64url(timestamp);
+ const mac = await hmacBase64url(secret, timestamp);
+ return `${tsB64}.${mac}`;
+}
+
function buildCsp(nonce: string): string {
// Development needs 'unsafe-eval' for React DevTools' error stack reconstruction.
const devExtras = process.env.NODE_ENV === 'development' ? " 'unsafe-eval'" : '';
return [
"default-src 'self'",
- `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${devExtras}`,
+ // Host allowlists after 'strict-dynamic' are ignored by supporting browsers
+ // (the nonce'd gtag loader can fetch further scripts). They remain as a
+ // fallback for older browsers, matching wallet-legacy helmet scriptSrc.
+ `script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://www.googletagmanager.com https://www.google-analytics.com${devExtras}`,
"style-src 'self' 'unsafe-inline'",
// profile_image/cover_image are arbitrary URLs from on-chain metadata, so image hosts
// cannot be allowlisted by name; legacy allowed `imgSrc: *` for the same reason.
@@ -33,7 +103,7 @@ function buildCsp(nonce: string): string {
// by upgrade-insecure-requests below.
"img-src 'self' blob: data: https:",
"font-src 'self'",
- "connect-src 'self'",
+ "connect-src 'self' https://www.google-analytics.com https://*.google-analytics.com https://www.googletagmanager.com https://analytics.google.com https://*.analytics.google.com",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
@@ -58,7 +128,7 @@ function accountPathWithoutAtPrefix(pathname: string): string | null {
return `/${account}${suffix}`;
}
-export default function proxy(request: NextRequest) {
+export default async function proxy(request: NextRequest) {
// Intercept health check endpoint used by ELB and OpenResty.
// Must return before i18n middleware to avoid locale redirect issues.
// Only expose the status; version info (docker_tag/source_commit) is omitted
@@ -88,6 +158,21 @@ export default function proxy(request: NextRequest) {
}
const response = intlMiddleware(forIntl);
response.headers.set('Content-Security-Policy', csp);
+
+ // Rolling CSRF cookie on every document response: refreshes the 24h window
+ // while the user navigates and self-heals after a CSRF_SECRET rotation (one
+ // rejected POST at most before the next navigation re-mints). Cookie attrs
+ // must stay in sync with setCSRFToken() in src/lib/middleware/csrf.ts.
+ const csrfSecret = getCsrfSecret();
+ if (csrfSecret) {
+ response.cookies.set(CSRF_COOKIE_NAME, await generateCsrfToken(csrfSecret), {
+ httpOnly: false, // readable by JS: the client mirrors it into X-CSRF-Token
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'strict',
+ maxAge: CSRF_COOKIE_MAX_AGE,
+ path: '/',
+ });
+ }
return response;
}
diff --git a/tests/unit/ga-id.test.ts b/tests/unit/ga-id.test.ts
new file mode 100644
index 00000000..e3c64168
--- /dev/null
+++ b/tests/unit/ga-id.test.ts
@@ -0,0 +1,41 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { getGaMeasurementId, isValidGaMeasurementId } from '@/lib/analytics/ga-id';
+
+describe('getGaMeasurementId', () => {
+ afterEach(() => {
+ delete process.env.GOOGLE_ANALYTICS_ID;
+ delete process.env.SDC_GOOGLE_ANALYTICS_ID;
+ delete process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID;
+ });
+
+ it('returns null when unset', () => {
+ delete process.env.GOOGLE_ANALYTICS_ID;
+ delete process.env.SDC_GOOGLE_ANALYTICS_ID;
+ delete process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID;
+ expect(getGaMeasurementId()).toBeNull();
+ });
+
+ it('prefers GOOGLE_ANALYTICS_ID over the legacy and NEXT_PUBLIC names', () => {
+ process.env.GOOGLE_ANALYTICS_ID = 'G-PRIMARY';
+ process.env.SDC_GOOGLE_ANALYTICS_ID = 'G-LEGACY';
+ process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID = 'G-PUBLIC';
+ expect(getGaMeasurementId()).toBe('G-PRIMARY');
+ });
+
+ it('falls back to SDC_GOOGLE_ANALYTICS_ID', () => {
+ process.env.SDC_GOOGLE_ANALYTICS_ID = 'UA-1-1';
+ expect(getGaMeasurementId()).toBe('UA-1-1');
+ });
+
+ it('ignores an invalid value and tries the next candidate', () => {
+ process.env.GOOGLE_ANALYTICS_ID = 'not-valid';
+ process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID = 'G-OKAY';
+ expect(getGaMeasurementId()).toBe('G-OKAY');
+ });
+
+ it('trims whitespace', () => {
+ process.env.GOOGLE_ANALYTICS_ID = ' G-TRIMMED ';
+ expect(isValidGaMeasurementId(' G-TRIMMED ')).toBe(true);
+ expect(getGaMeasurementId()).toBe('G-TRIMMED');
+ });
+});
diff --git a/tests/unit/google-analytics.test.tsx b/tests/unit/google-analytics.test.tsx
new file mode 100644
index 00000000..eccfde34
--- /dev/null
+++ b/tests/unit/google-analytics.test.tsx
@@ -0,0 +1,78 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, cleanup } from '@testing-library/react';
+
+const mockPathname = vi.fn(() => '/market');
+vi.mock('next/navigation', () => ({
+ usePathname: () => mockPathname(),
+}));
+
+// RTL cleanup only unmounts the body container; React Float hoists async
+//