mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
more (modular local offline not selling data) analytics!
This commit is contained in:
+55
-9
@@ -45,6 +45,7 @@ import BarcodeGamesPanel from './components/BarcodeGamesPanel/index.jsx';
|
||||
import RewardRunOverlay from './components/RewardRunOverlay/index.jsx';
|
||||
import SocketConnectionPill from './components/SocketConnectionPill/index.jsx';
|
||||
import { pageBackgroundClass, themeGapClass, themeStackClass } from './themeFlags.js';
|
||||
import { trackAnalyticsEvent } from './analytics/index.js';
|
||||
|
||||
function useLayoutMode() {
|
||||
const [mode, setMode] = useState(() => {
|
||||
@@ -130,9 +131,21 @@ function MobileFeatureTabs({
|
||||
// disabled service and direct-address mode.
|
||||
return Boolean(vote?.votingEnabled);
|
||||
});
|
||||
const handleTabChange = useCallback(
|
||||
(tab) => {
|
||||
/*
|
||||
Tab changes are one of the highest-signal UI events because the app is a
|
||||
dense single-page control surface. Recording the selected panel gives
|
||||
Umami useful journeys without tracking every button inside each panel.
|
||||
*/
|
||||
setActiveTab(tab);
|
||||
trackAnalyticsEvent('tab_change', { tab, layout, surface: 'mobile_features' });
|
||||
},
|
||||
[layout],
|
||||
);
|
||||
return (
|
||||
<section className="text-base">
|
||||
<Tabs defaultTab="chat" currentTab={activeTab} onTabChange={setActiveTab}>
|
||||
<Tabs defaultTab="chat" currentTab={activeTab} onTabChange={handleTabChange}>
|
||||
<TabList>
|
||||
<Tab id="chat">Chat</Tab>
|
||||
<Tab id="activities">Activities</Tab>
|
||||
@@ -325,33 +338,66 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
|
||||
}
|
||||
}, [quickstartStatus, quickstartSettings?.showOnLoad]);
|
||||
|
||||
const openHelp = useCallback(() => setHelpVisible(true), []);
|
||||
const closeHelp = useCallback(() => setHelpVisible(false), []);
|
||||
const closeQuickstart = useCallback(() => setQuickstartVisible(false), []);
|
||||
useEffect(() => {
|
||||
if (!fullscreenVisible) return;
|
||||
trackAnalyticsEvent('fullscreen_prompt_show', { layout, mode: fullscreenMode });
|
||||
}, [fullscreenMode, fullscreenVisible, layout]);
|
||||
|
||||
const openHelp = useCallback(() => {
|
||||
setHelpVisible(true);
|
||||
trackAnalyticsEvent('help_open', { layout, source: 'panel' });
|
||||
}, [layout]);
|
||||
const closeHelp = useCallback(() => {
|
||||
setHelpVisible(false);
|
||||
trackAnalyticsEvent('help_close', { layout });
|
||||
}, [layout]);
|
||||
const closeQuickstart = useCallback(() => {
|
||||
setQuickstartVisible(false);
|
||||
trackAnalyticsEvent('quickstart_close', { layout });
|
||||
}, [layout]);
|
||||
const handleFloatingFullscreen = useCallback(async () => {
|
||||
if (fullscreenIsIOS) {
|
||||
trackAnalyticsEvent('fullscreen_prompt_manual_open', { layout, mode: 'pwa-hint', source: 'floating_button' });
|
||||
showPrompt();
|
||||
return;
|
||||
}
|
||||
const entered = await enterFullscreen();
|
||||
trackAnalyticsEvent(entered ? 'fullscreen_enter' : 'fullscreen_enter_failed', {
|
||||
layout,
|
||||
source: 'floating_button',
|
||||
});
|
||||
if (!entered) {
|
||||
showPrompt();
|
||||
}
|
||||
}, [enterFullscreen, fullscreenIsIOS, showPrompt]);
|
||||
}, [enterFullscreen, fullscreenIsIOS, layout, showPrompt]);
|
||||
const setQuickstartShowOnLoad = useCallback(
|
||||
(enabled) => {
|
||||
const next = Boolean(enabled);
|
||||
saveQuickstartSettings((current) => ({ ...(current ?? {}), showOnLoad: next }));
|
||||
trackAnalyticsEvent('quickstart_show_on_load_change', { layout, enabled: next });
|
||||
if (!next) {
|
||||
setQuickstartVisible(false);
|
||||
}
|
||||
},
|
||||
[saveQuickstartSettings],
|
||||
[layout, saveQuickstartSettings],
|
||||
);
|
||||
const openHelpFromQuickstart = useCallback(() => {
|
||||
setQuickstartVisible(false);
|
||||
setHelpVisible(true);
|
||||
}, []);
|
||||
trackAnalyticsEvent('help_open', { layout, source: 'quickstart' });
|
||||
}, [layout]);
|
||||
const handleFullscreenPromptEnter = useCallback(async () => {
|
||||
const entered = await enterFullscreen();
|
||||
trackAnalyticsEvent(entered ? 'fullscreen_enter' : 'fullscreen_enter_failed', {
|
||||
layout,
|
||||
source: 'prompt',
|
||||
});
|
||||
return entered;
|
||||
}, [enterFullscreen, layout]);
|
||||
const handleFullscreenPromptDismiss = useCallback(() => {
|
||||
dismiss();
|
||||
trackAnalyticsEvent('fullscreen_dismiss', { layout, mode: fullscreenMode });
|
||||
}, [dismiss, fullscreenMode, layout]);
|
||||
|
||||
const renderedLayout = useMemo(
|
||||
() =>
|
||||
@@ -387,8 +433,8 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
|
||||
<FullscreenPrompt
|
||||
visible={fullscreenVisible}
|
||||
mode={fullscreenMode}
|
||||
onEnterFullscreen={enterFullscreen}
|
||||
onDismiss={dismiss}
|
||||
onEnterFullscreen={handleFullscreenPromptEnter}
|
||||
onDismiss={handleFullscreenPromptDismiss}
|
||||
/>
|
||||
<QuickstartOverlay
|
||||
visible={quickstartVisible}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Analytics Reporter
|
||||
// Purpose: Publishes page/session context to the optional build-time analytics
|
||||
// adapter. Scope: observes route, layout, nickname, role, verification, and
|
||||
// rover assignment without owning any analytics vendor implementation.
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useSessionSelector } from '../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../settings/index.js';
|
||||
import { identifyAnalyticsSession, trackAnalyticsEvent } from './index.js';
|
||||
|
||||
function detectLayout() {
|
||||
if (typeof window === 'undefined') return 'desktop';
|
||||
if (window.innerWidth >= 1024) return 'desktop';
|
||||
return window.innerWidth > window.innerHeight ? 'mobile-landscape' : 'mobile-portrait';
|
||||
}
|
||||
|
||||
function useAnalyticsLayout() {
|
||||
const [layout, setLayout] = useState(() => detectLayout());
|
||||
|
||||
useEffect(() => {
|
||||
function updateLayout() {
|
||||
setLayout(detectLayout());
|
||||
}
|
||||
|
||||
updateLayout();
|
||||
window.addEventListener('resize', updateLayout);
|
||||
return () => window.removeEventListener('resize', updateLayout);
|
||||
}, []);
|
||||
|
||||
return layout;
|
||||
}
|
||||
|
||||
export default function AnalyticsReporter() {
|
||||
const location = useLocation();
|
||||
const layout = useAnalyticsLayout();
|
||||
const { value: profile } = useSettingsNamespace('profile', { nickname: '' });
|
||||
const session = useSessionSelector((state) => state.session);
|
||||
const previousRouteRef = useRef(null);
|
||||
const previousLayoutRef = useRef(null);
|
||||
const previousRoverRef = useRef(null);
|
||||
const previousIdentityRef = useRef('');
|
||||
const route = location.pathname || '/';
|
||||
const nickname = String(profile?.nickname || session?.nickname || '').trim();
|
||||
const roverId = String(session?.assignment?.roverId || '').trim();
|
||||
const role = String(session?.role || '').trim();
|
||||
const verified = Boolean(session?.isVerified);
|
||||
|
||||
const identity = useMemo(
|
||||
() => ({
|
||||
route,
|
||||
layout,
|
||||
nickname,
|
||||
hasNickname: Boolean(nickname),
|
||||
roverId,
|
||||
assignedRover: Boolean(roverId),
|
||||
role,
|
||||
verified,
|
||||
}),
|
||||
[layout, nickname, role, route, roverId, verified],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const serialized = JSON.stringify(identity);
|
||||
if (previousIdentityRef.current === serialized) return;
|
||||
previousIdentityRef.current = serialized;
|
||||
|
||||
/*
|
||||
This pushes the current browser/session context into the injected adapter.
|
||||
The adapter is responsible for applying build-time privacy/config choices,
|
||||
such as whether nickname and rover id should be sent to Umami.
|
||||
*/
|
||||
identifyAnalyticsSession(identity);
|
||||
}, [identity]);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousRouteRef.current === route) return;
|
||||
previousRouteRef.current = route;
|
||||
trackAnalyticsEvent('route_enter', { route, layout });
|
||||
}, [layout, route]);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousLayoutRef.current === layout) return;
|
||||
previousLayoutRef.current = layout;
|
||||
trackAnalyticsEvent('layout_change', { route, layout });
|
||||
}, [layout, route]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!roverId || previousRoverRef.current === roverId) return;
|
||||
previousRoverRef.current = roverId;
|
||||
trackAnalyticsEvent('rover_assigned', { roverId, route, layout });
|
||||
}, [layout, route, roverId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Analytics Bridge
|
||||
// Purpose: Gives React a tiny, provider-neutral analytics surface. Scope:
|
||||
// forwards optional app events to a build-time injected browser adapter without
|
||||
// importing Umami, embedding website ids, or making rover controls depend on
|
||||
// analytics availability.
|
||||
|
||||
const MAX_EVENT_NAME_LENGTH = 80;
|
||||
const MAX_PROPERTY_KEY_LENGTH = 80;
|
||||
const MAX_STRING_VALUE_LENGTH = 240;
|
||||
|
||||
function getAdapter() {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return window.roverAnalytics && typeof window.roverAnalytics === 'object'
|
||||
? window.roverAnalytics
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeEventName(name) {
|
||||
if (typeof name !== 'string') return null;
|
||||
const normalized = name.trim().toLowerCase().replace(/[^a-z0-9_:-]+/g, '_');
|
||||
return normalized ? normalized.slice(0, MAX_EVENT_NAME_LENGTH) : null;
|
||||
}
|
||||
|
||||
function normalizeValue(value) {
|
||||
if (value === null || value === undefined) return undefined;
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? value : undefined;
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed.slice(0, MAX_STRING_VALUE_LENGTH) : undefined;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
/*
|
||||
Analytics properties should stay compact and dashboard-friendly. Arrays
|
||||
are reduced to primitive strings instead of sending nested structures that
|
||||
Umami cannot use well for event breakdowns.
|
||||
*/
|
||||
const normalized = value
|
||||
.map((entry) => normalizeValue(entry))
|
||||
.filter((entry) => entry !== undefined)
|
||||
.map((entry) => String(entry));
|
||||
return normalized.length ? normalized.slice(0, 12).join(',') : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizePayload(payload = {}) {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return {};
|
||||
return Object.entries(payload).reduce((clean, [key, value]) => {
|
||||
if (typeof key !== 'string') return clean;
|
||||
const normalizedKey = key.trim().replace(/[^a-zA-Z0-9_:-]+/g, '_').slice(0, MAX_PROPERTY_KEY_LENGTH);
|
||||
if (!normalizedKey) return clean;
|
||||
const normalizedValue = normalizeValue(value);
|
||||
if (normalizedValue === undefined) return clean;
|
||||
clean[normalizedKey] = normalizedValue;
|
||||
return clean;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function trackAnalyticsEvent(name, payload = {}) {
|
||||
const eventName = normalizeEventName(name);
|
||||
if (!eventName) return;
|
||||
const adapter = getAdapter();
|
||||
if (!adapter || typeof adapter.track !== 'function') return;
|
||||
|
||||
try {
|
||||
/*
|
||||
Analytics must be observability-only. Catching adapter errors here keeps a
|
||||
broken or blocked third-party script from interfering with rover driving,
|
||||
scanner input, chat, or any other live-control UI.
|
||||
*/
|
||||
adapter.track(eventName, normalizePayload(payload));
|
||||
} catch (error) {
|
||||
console.warn('Analytics event failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function identifyAnalyticsSession(payload = {}) {
|
||||
const adapter = getAdapter();
|
||||
if (!adapter || typeof adapter.identify !== 'function') return;
|
||||
|
||||
try {
|
||||
/*
|
||||
Session identity is centralized so individual events do not need to repeat
|
||||
nickname, rover, role, layout, and route data. The injected build-time
|
||||
adapter still decides which fields are forwarded to the actual provider.
|
||||
*/
|
||||
adapter.identify(normalizePayload(payload));
|
||||
} catch (error) {
|
||||
console.warn('Analytics identify failed', error);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import useBarcodeGameState from '../../barcodeGames/useBarcodeGameState.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
|
||||
function clampRgbChannel(value) {
|
||||
if (!Number.isFinite(value)) return null;
|
||||
@@ -189,7 +190,7 @@ function Leaderboard({ players, ownPlayer }) {
|
||||
}
|
||||
|
||||
export default function BarcodeGamesPanel() {
|
||||
const { state, voteForGame } = useBarcodeGameState();
|
||||
const { state, connectionState, voteForGame } = useBarcodeGameState();
|
||||
const [pendingGameId, setPendingGameId] = useState(null);
|
||||
const activeGame = state.activeGame;
|
||||
const display = activeGame?.display || {};
|
||||
@@ -204,10 +205,41 @@ export default function BarcodeGamesPanel() {
|
||||
const participants = Array.isArray(state.participants) ? state.participants : [];
|
||||
const activeTheme = getGameTheme(activeGame?.themeColor);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectionState.stale || !connectionState.lastReceivedAt) return;
|
||||
/*
|
||||
Stale barcode-game state is worth tracking because it points to a real
|
||||
interaction problem: users can be looking at old game choices or scores
|
||||
even though the rest of the page appears loaded.
|
||||
*/
|
||||
trackAnalyticsEvent('barcode_game_state_stale', {
|
||||
connected: connectionState.connected,
|
||||
phase: state.phase || 'unknown',
|
||||
});
|
||||
}, [connectionState.connected, connectionState.lastReceivedAt, connectionState.stale, state.phase]);
|
||||
|
||||
const handleVote = async (gameId) => {
|
||||
setPendingGameId(gameId);
|
||||
trackAnalyticsEvent('barcode_game_vote', {
|
||||
gameId,
|
||||
phase: state.phase || 'unknown',
|
||||
status: 'started',
|
||||
});
|
||||
try {
|
||||
await voteForGame(gameId);
|
||||
trackAnalyticsEvent('barcode_game_vote', {
|
||||
gameId,
|
||||
phase: state.phase || 'unknown',
|
||||
status: 'accepted',
|
||||
});
|
||||
} catch (error) {
|
||||
trackAnalyticsEvent('barcode_game_vote', {
|
||||
gameId,
|
||||
phase: state.phase || 'unknown',
|
||||
status: 'failed',
|
||||
reason: error?.message || 'unknown',
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
setPendingGameId(null);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSessionActions } from '../../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
|
||||
export default function NicknameForm({ compact = false }) {
|
||||
const { setNickname } = useSessionActions();
|
||||
@@ -26,6 +27,14 @@ export default function NicknameForm({ compact = false }) {
|
||||
// the driver UI so chat/user-list labels stay consistent across routes.
|
||||
await setNickname(trimmed);
|
||||
save({ nickname: trimmed });
|
||||
/*
|
||||
The event records that a nickname was saved, while the shared analytics
|
||||
session reporter owns the actual nickname field. Keeping that split
|
||||
prevents every form call site from needing to know privacy/config rules.
|
||||
*/
|
||||
trackAnalyticsEvent('nickname_set', {
|
||||
length: trimmed.length,
|
||||
});
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
|
||||
@@ -29,9 +29,10 @@ import ButtonBoxPanel from '../ButtonBoxPanel/index.jsx';
|
||||
import BarcodeGamesPanel from '../BarcodeGamesPanel/index.jsx';
|
||||
import OverseerPreferencePanel from '../OverseerPreferencePanel/index.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||
import { themeGapClass, themeStackClass } from '../../themeFlags.js';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
|
||||
const CHAT_DOCK_INITIAL_HEIGHT = 224;
|
||||
const CHAT_DOCK_MIN_HEIGHT = 144;
|
||||
@@ -173,6 +174,18 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
||||
// unavailable, including disabled service and direct-address mode.
|
||||
return Boolean(vote?.votingEnabled);
|
||||
});
|
||||
const handleTabChange = useCallback(
|
||||
(tab) => {
|
||||
/*
|
||||
Desktop users spend most of their time on this one route, so panel
|
||||
changes are the cleanest way to understand feature usage without adding
|
||||
analytics calls to every nested control in the rover dashboard.
|
||||
*/
|
||||
setActiveTab(tab);
|
||||
trackAnalyticsEvent('tab_change', { tab, layout, surface: 'desktop_right_pane' });
|
||||
},
|
||||
[layout],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const chatDock = chatDockRef.current;
|
||||
@@ -259,7 +272,7 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
||||
|
||||
return (
|
||||
<section className="text-base">
|
||||
<Tabs defaultTab="telemetry" currentTab={activeTab} onTabChange={setActiveTab}>
|
||||
<Tabs defaultTab="telemetry" currentTab={activeTab} onTabChange={handleTabChange}>
|
||||
<TabList>
|
||||
<Tab id="telemetry">Controls</Tab>
|
||||
<Tab id="activities">Activities</Tab>
|
||||
|
||||
@@ -1,6 +1,56 @@
|
||||
<!-- place analytics tags here and they will be injected into <head> of index.html at build time of the web UI. -->
|
||||
<!-- these tags are loaded PAGE-WIDE, this means /, /spectate, /mini, etc. -->
|
||||
|
||||
<script>
|
||||
/*
|
||||
Example analytics adapter. React talks only to window.roverAnalytics, so
|
||||
this file owns the provider-specific forwarding without app-side Umami code.
|
||||
*/
|
||||
(function () {
|
||||
var pendingCalls = [];
|
||||
var flushTimer = null;
|
||||
|
||||
function callUmami(method, args) {
|
||||
if (!window.umami || typeof window.umami[method] !== 'function') return false;
|
||||
window.umami[method].apply(window.umami, args);
|
||||
return true;
|
||||
}
|
||||
|
||||
function flushPendingCalls() {
|
||||
if (!pendingCalls.length) return;
|
||||
if (!window.umami) return;
|
||||
|
||||
pendingCalls = pendingCalls.filter(function (call) {
|
||||
return !callUmami(call.method, call.args);
|
||||
});
|
||||
|
||||
if (!pendingCalls.length && flushTimer) {
|
||||
window.clearInterval(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(method, args) {
|
||||
if (callUmami(method, args)) return;
|
||||
pendingCalls.push({ method: method, args: args });
|
||||
if (!flushTimer) {
|
||||
flushTimer = window.setInterval(flushPendingCalls, 500);
|
||||
}
|
||||
}
|
||||
|
||||
window.roverAnalytics = {
|
||||
track: function (name, data) {
|
||||
enqueue('track', [name, data || {}]);
|
||||
},
|
||||
identify: function (data) {
|
||||
enqueue('identify', [data || {}]);
|
||||
},
|
||||
};
|
||||
|
||||
window.addEventListener('load', flushPendingCalls);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- otterlytics testing for blocking local -->
|
||||
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
||||
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useSessionActions, useSessionSelector } from './SessionContext.jsx';
|
||||
import messageSound from '../assets/message.mp3';
|
||||
import { useSettingsNamespace } from '../settings/index.js';
|
||||
import { AUDIO_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
|
||||
import { trackAnalyticsEvent } from '../analytics/index.js';
|
||||
|
||||
const CHAT_TIMELINE_DEFAULT = {
|
||||
messages: [],
|
||||
@@ -218,6 +219,15 @@ export function ChatProvider({ children }) {
|
||||
if (resp.error) {
|
||||
reject(new Error(resp.error));
|
||||
} else {
|
||||
/*
|
||||
Count successful chat sends without forwarding chat text. The
|
||||
centralized analytics session identity can still attach nickname
|
||||
when the build-time adapter is configured to include it.
|
||||
*/
|
||||
trackAnalyticsEvent('chat_send', {
|
||||
hasTts: Boolean(tts),
|
||||
length: typeof text === 'string' ? text.trim().length : 0,
|
||||
});
|
||||
resolve(resp);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import ServerDisplayApp from './display/ServerDisplayApp/ServerDisplayAppRoot.js
|
||||
import ScannerApp from './scanner/ScannerApp/ScannerAppRoot.jsx'
|
||||
import { SettingsProvider } from './settings/index.js'
|
||||
import DeterrenceChaos from './components/DeterrenceChaos/index.jsx'
|
||||
import AnalyticsReporter from './analytics/AnalyticsReporter.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
@@ -25,6 +26,7 @@ createRoot(document.getElementById('root')).render(
|
||||
<ChatProvider>
|
||||
<DeterrenceChaos />
|
||||
<BrowserRouter>
|
||||
<AnalyticsReporter />
|
||||
<Routes>
|
||||
<Route path="/" element={<App />} />
|
||||
<Route path="/spectate" element={<SpectatorApp />} />
|
||||
|
||||
Reference in New Issue
Block a user