diff --git a/apps/reactotron-app/package.json b/apps/reactotron-app/package.json
index 3c2e81f8d..6cb859c7e 100644
--- a/apps/reactotron-app/package.json
+++ b/apps/reactotron-app/package.json
@@ -59,6 +59,8 @@
"lodash.debounce": "^4.0.8",
"react": "18.2.0",
"react-dom": "18.2.0",
+ "react-fade-in": "^2.0.1",
+ "react-ga4": "^2.1.0",
"react-hotkeys": "^2.0.0",
"react-icons": "^4.11.0",
"react-modal": "3.16.1",
diff --git a/apps/reactotron-app/src/main/menu.ts b/apps/reactotron-app/src/main/menu.ts
index 258e350fe..c86046e01 100644
--- a/apps/reactotron-app/src/main/menu.ts
+++ b/apps/reactotron-app/src/main/menu.ts
@@ -1,7 +1,5 @@
import { Menu, app, shell } from "electron"
-import Store from "electron-store"
-
-const configStore = new Store()
+import configStore from "../renderer/config"
const isDarwin = process.platform === "darwin"
diff --git a/apps/reactotron-app/src/renderer/App.tsx b/apps/reactotron-app/src/renderer/App.tsx
index b5778c4b1..cf3351989 100644
--- a/apps/reactotron-app/src/renderer/App.tsx
+++ b/apps/reactotron-app/src/renderer/App.tsx
@@ -1,9 +1,10 @@
-import React from "react"
+import React, { useEffect } from "react"
import { HashRouter as Router, Route, Routes } from "react-router-dom"
import styled from "styled-components"
import SideBar from "./components/SideBar"
import Footer from "./components/Footer"
+import AnalyticsOptOut from "./components/AnalyticsOptOut"
import RootContextProvider from "./contexts"
import RootModals from "./RootModals"
@@ -15,8 +16,9 @@ import Overlay from "./pages/reactNative/Overlay"
import Storybook from "./pages/reactNative/Storybook"
import CustomCommands from "./pages/customCommands"
import Help from "./pages/help"
+import { useAnalytics, usePageTracking } from "./util/analyticsHelpers"
-const AppContainer = styled.div`
+const AppContainerComponent = styled.div`
position: absolute;
top: 0;
bottom: 0;
@@ -28,6 +30,35 @@ const AppContainer = styled.div`
background-color: ${(props) => props.theme.background};
`
+// This wrapper container is used to track page views within the app automatically using react-router-dom
+// as well as to initialize analytics on app load.
+const AppContainer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+ usePageTracking()
+ const { initializeAnalytics } = useAnalytics()
+
+ const [showsAnalyticsInterface, setShowsAnalyticsInterface] = React.useState(false)
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ const status = initializeAnalytics()
+ if (status === "unknown" && showsAnalyticsInterface === false) {
+ // Show the user the interface to enable/disable analytics
+ setShowsAnalyticsInterface(true)
+ }
+ }, 250)
+ return () => clearTimeout(timer)
+ }, [showsAnalyticsInterface])
+
+ return (
+
+ {children}
+ {showsAnalyticsInterface && (
+ setShowsAnalyticsInterface(false)} />
+ )}
+
+ )
+}
+
const TopSection = styled.div`
overflow: hidden;
display: flex;
diff --git a/apps/reactotron-app/src/renderer/KeybindHandler.tsx b/apps/reactotron-app/src/renderer/KeybindHandler.tsx
index e5d20af8c..96be83a39 100644
--- a/apps/reactotron-app/src/renderer/KeybindHandler.tsx
+++ b/apps/reactotron-app/src/renderer/KeybindHandler.tsx
@@ -2,6 +2,7 @@ import React, { useContext } from "react"
import { GlobalHotKeys, KeyEventName } from "react-hotkeys"
import { ReactotronContext, StateContext, TimelineContext } from "reactotron-core-ui"
import LayoutContext from "./contexts/Layout"
+import { useAnalytics } from "./util/analyticsHelpers"
const keyMap = {
// Application wide
@@ -94,26 +95,33 @@ function KeybindHandler({ children }) {
const { openDispatchModal, openSubscriptionModal, clearCommands } = useContext(ReactotronContext)
const { openSearch, toggleSearch } = useContext(TimelineContext)
const { createSnapshot } = useContext(StateContext)
+ const { sendKeyboardShortcutAnalyticsEvent } = useAnalytics()
const handlers = {
// Tab Navigation
OpenHomeTab: () => {
window.location.hash = "/"
+ sendKeyboardShortcutAnalyticsEvent("OpenHomeTab")
},
OpenTimelineTab: () => {
window.location.hash = "/timeline"
+ sendKeyboardShortcutAnalyticsEvent("OpenTimelineTab")
},
OpenStateTab: () => {
window.location.hash = "/state/subscriptions"
+ sendKeyboardShortcutAnalyticsEvent("OpenStateTab")
},
OpenReactNativeTab: () => {
window.location.hash = "/native/overlay"
+ sendKeyboardShortcutAnalyticsEvent("OpenReactNativeTab")
},
OpenCustomCommandsTab: () => {
window.location.hash = "/customCommands"
+ sendKeyboardShortcutAnalyticsEvent("OpenCustomCommandsTab")
},
OpenHelpTab: () => {
window.location.hash = "/help"
+ sendKeyboardShortcutAnalyticsEvent("OpenHelpTab")
},
// Modals
@@ -128,11 +136,13 @@ function KeybindHandler({ children }) {
},
TakeSnapshot: () => {
createSnapshot()
+ sendKeyboardShortcutAnalyticsEvent("TakeSnapshot")
},
// Miscellaneous
ToggleSidebar: () => {
toggleSideBar()
+ sendKeyboardShortcutAnalyticsEvent("ToggleSidebar")
},
ToggleSearch: () => {
// If we're on the timeline page, toggle the search, otherwise switch to the timeline tab and open search
@@ -145,6 +155,7 @@ function KeybindHandler({ children }) {
},
ClearTimeline: () => {
clearCommands()
+ sendKeyboardShortcutAnalyticsEvent("ClearTimeline")
},
}
diff --git a/apps/reactotron-app/src/renderer/RootModals.tsx b/apps/reactotron-app/src/renderer/RootModals.tsx
index 7c5194318..bc0e144d6 100644
--- a/apps/reactotron-app/src/renderer/RootModals.tsx
+++ b/apps/reactotron-app/src/renderer/RootModals.tsx
@@ -5,8 +5,10 @@ import {
ReactotronContext,
StateContext,
} from "reactotron-core-ui"
+import { useAnalytics } from "./util/analyticsHelpers"
function RootModals() {
+ const { sendAnalyticsEvent } = useAnalytics()
const {
sendCommand,
@@ -20,10 +22,6 @@ function RootModals() {
} = useContext(ReactotronContext)
const { addSubscription } = useContext(StateContext)
- const dispatchAction = (action: any) => {
- sendCommand("state.action.dispatch", { action })
- }
-
return (
<>
{
closeDispatchModal()
+ sendAnalyticsEvent({
+ category: "dispatch",
+ action: "dispatchAbort",
+ })
+ }}
+ onDispatchAction={(action: any) => {
+ sendCommand("state.action.dispatch", { action })
+ sendAnalyticsEvent({
+ category: "dispatch",
+ action: "dispatchConfirm",
+ })
}}
- onDispatchAction={dispatchAction}
isDarwin={window.process.platform === "darwin"}
/>
{
closeSubscriptionModal()
+ sendAnalyticsEvent({
+ category: "subscription",
+ action: "addAbort",
+ })
}}
onAddSubscription={(path: string) => {
// TODO: Get this out of here.
closeSubscriptionModal()
addSubscription(path)
+ sendAnalyticsEvent({
+ category: "subscription",
+ action: "addConfirm",
+ })
}}
/>
>
diff --git a/apps/reactotron-app/src/renderer/components/AnalyticsOptOut/index.tsx b/apps/reactotron-app/src/renderer/components/AnalyticsOptOut/index.tsx
new file mode 100644
index 000000000..2a510db91
--- /dev/null
+++ b/apps/reactotron-app/src/renderer/components/AnalyticsOptOut/index.tsx
@@ -0,0 +1,118 @@
+import React from "react"
+import styled, { useTheme } from "styled-components"
+import FadeIn from "react-fade-in"
+import { reactotronAnalytics } from "../../images"
+import configStore from "../../config"
+import { useAnalytics } from "../../util/analyticsHelpers"
+
+const Overlay = styled.div`
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ display: flex;
+ background-color: ${(props) => props.theme.backgroundLighter};
+ justify-content: center;
+ align-items: center;
+`
+
+const AlertContainer = styled.div`
+ padding: 30px;
+ text-align: left;
+ border-radius: 10px;
+ background: ${(props) => props.theme.backgroundDarker};
+ color: ${(props) => props.theme.foregroundLight};
+
+ box-shadow: 0 20px 75px ${(props) => props.theme.backgroundSubtleLight};
+ width: 80%;
+ max-width: 500px;
+`
+
+const AlertHeader = styled.div`
+ display: flex;
+ justify-content: center;
+ margin-bottom: 20px;
+`
+
+const AlertHeaderImage = styled.img`
+ height: 128px;
+`
+
+const ButtonGroup = styled.div`
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ margin-top: 20px;
+`
+
+const Button = styled.button`
+ outline: none;
+ background: ${(props) => props.theme.backgroundLighter};
+ border: none;
+ display: inline-block;
+ padding: 6px 18px;
+ color: ${(props) => props.theme.background};
+ margin-right: 10px;
+ border-radius: 5px;
+ font-size: 16px;
+ cursor: pointer;
+`
+
+// This is a custom alert that we use to ask the user if they want to opt-in to analytics
+// We use this instead of the default alert because we want to style it to match our app.
+const AnalyticsOptOut = ({ onClose }) => {
+ const theme = useTheme()
+ const { sendOptOutAnalyticsEvent } = useAnalytics()
+
+ return (
+
+
+
+
+
+
Opt in to Reactotron analytics?
+
Help us improve Reactotron!
+
+ We'd like to collect anonymous usage data to enhance Reactotron's performance
+ and features. This data includes general usage patterns and interactions. No personal
+ information will be collected.
+
+
+ You can change this setting at any time and by opting in, you can contribute to making
+ Reactotron better for everyone!
+
+
Would you like to participate?
+
+
+
+
+
+
+ )
+}
+
+export default AnalyticsOptOut
diff --git a/apps/reactotron-app/src/renderer/config.ts b/apps/reactotron-app/src/renderer/config.ts
index e7114e5c8..2611d1cc3 100644
--- a/apps/reactotron-app/src/renderer/config.ts
+++ b/apps/reactotron-app/src/renderer/config.ts
@@ -1,6 +1,12 @@
-import Store from "electron-store"
+import Store, { type Schema } from "electron-store"
-const schema = {
+type StoreType = {
+ serverPort: number,
+ commandHistory: number,
+ analyticsOptOut: string | boolean,
+}
+
+const schema:Schema = {
serverPort: {
type: "number",
default: 9090,
@@ -9,16 +15,41 @@ const schema = {
type: "number",
default: 500,
},
+ analyticsOptOut: {
+ type: ["string", "boolean"],
+ default: "unknown",
+ },
}
-const configStore = new Store({ schema } as any)
+const configStore = new Store({
+ schema,
+ defaults: {
+ serverPort: schema.serverPort.default,
+ commandHistory: schema.commandHistory.default,
+ analyticsOptOut: schema.analyticsOptOut.default,
+ },
+ beforeEachMigration: (_store, context) => {
+ console.log(`[main-config] migrate from ${context.fromVersion} → ${context.toVersion}`)
+ },
+ migrations: {
+ "1.0.0": (store) => {
+ // Added analyticsOptOut in order to track if the user has opted out of analytics
+ // and present the user with a blocking dialog box.
+ // When the user makes a choice, it will be set to "true" or "false"
+ store.set("analyticsOptOut", "unknown")
+ },
+ },
+})
// Setup defaults
if (!configStore.has("serverPort")) {
- configStore.set("serverPort", 9090)
+ configStore.set("serverPort", schema.serverPort.default)
}
if (!configStore.has("commandHistory")) {
- configStore.set("commandHistory", 500)
+ configStore.set("commandHistory", schema.commandHistory.default)
+}
+if (!configStore.has("analyticsOptOut")) {
+ configStore.set("analyticsOptOut", schema.analyticsOptOut.default)
}
export default configStore
diff --git a/apps/reactotron-app/src/renderer/images/Reactotron-analytics.png b/apps/reactotron-app/src/renderer/images/Reactotron-analytics.png
new file mode 100644
index 000000000..0727711a8
Binary files /dev/null and b/apps/reactotron-app/src/renderer/images/Reactotron-analytics.png differ
diff --git a/apps/reactotron-app/src/renderer/images/index.ts b/apps/reactotron-app/src/renderer/images/index.ts
index cc92bf95b..567e4ad2d 100644
--- a/apps/reactotron-app/src/renderer/images/index.ts
+++ b/apps/reactotron-app/src/renderer/images/index.ts
@@ -1,3 +1,4 @@
export const reactotronLogo: string = require("./Reactotron-128.png").default
+export const reactotronAnalytics: string = require("./Reactotron-analytics.png").default
export const storybookActiveImg: string = require("./storybook-logo-color.png").default
export const storybookInactiveImg: string = require("./storybook-logo.png").default
diff --git a/apps/reactotron-app/src/renderer/pages/customCommands/index.tsx b/apps/reactotron-app/src/renderer/pages/customCommands/index.tsx
index b297826ee..8c9c69b9c 100644
--- a/apps/reactotron-app/src/renderer/pages/customCommands/index.tsx
+++ b/apps/reactotron-app/src/renderer/pages/customCommands/index.tsx
@@ -5,6 +5,7 @@ import styled from "styled-components"
import { MdSearch } from "react-icons/md"
import { FaMagic } from "react-icons/fa"
import { produce } from "immer"
+import { useAnalytics } from "../../util/analyticsHelpers"
const Container = styled.div`
display: flex;
@@ -113,6 +114,7 @@ function CustomCommandItem({
customCommand: CustomCommand
sendCustomCommand: (command: any, args: any) => void
}) {
+ const { sendCustomCommandAnalyticsEvent } = useAnalytics()
const [state, dispatch] = useReducer(customCommandItemReducer, customCommand.args, (args) => {
if (!args) return {}
@@ -157,6 +159,7 @@ function CustomCommandItem({
{
sendCustomCommand(customCommand.command, state)
+ sendCustomCommandAnalyticsEvent(customCommand.command)
}}
>
Send Command
diff --git a/apps/reactotron-app/src/renderer/pages/help/components/AndroidDeviceHelp.tsx b/apps/reactotron-app/src/renderer/pages/help/components/AndroidDeviceHelp.tsx
index b64e53cab..6276193ce 100644
--- a/apps/reactotron-app/src/renderer/pages/help/components/AndroidDeviceHelp.tsx
+++ b/apps/reactotron-app/src/renderer/pages/help/components/AndroidDeviceHelp.tsx
@@ -8,6 +8,12 @@ import { IoReloadOutline as ReloadAppIcon } from "react-icons/io5"
import { EmptyState, Tooltip } from "reactotron-core-ui"
import { FaAndroid } from "react-icons/fa"
import { ItemContainer, ItemIconContainer } from "../SharedStyles"
+import { useAnalytics } from "../../../util/analyticsHelpers"
+
+interface IAndroidDevice {
+ id: string
+ state: string
+}
const Container = styled.div`
margin: 50px 0px;
@@ -101,10 +107,11 @@ const PortSettingsIconContainer = styled.div`
`
function AndroidDeviceHelp() {
- const [androidDevices, setAndroidDevices] = React.useState([])
+ const [androidDevices, setAndroidDevices] = React.useState([])
const [portsVisible, setPortsVisible] = React.useState(false)
const [reactotronPort, setReactotronPort] = React.useState("9090")
const [metroPort, setMetroPort] = React.useState("8081")
+ const { sendAnalyticsEvent } = useAnalytics()
// When the page loads, get the list of devices from ADB to help users debug android issues.
React.useEffect(() => {
@@ -112,7 +119,7 @@ function AndroidDeviceHelp() {
arg = arg.replace(/(\r\n|\n|\r)/gm, "\n").trim() // Fix newlines
const rawDevices = arg.split("\n")
rawDevices.shift() // Remove the first line
- const devices = rawDevices.map((device) => {
+ const devices: IAndroidDevice[] = rawDevices.map((device) => {
const [id, state] = device.split("\t")
return { id, state }
})
@@ -142,7 +149,14 @@ function AndroidDeviceHelp() {
setPortsVisible(!portsVisible)}
+ onClick={() => {
+ setPortsVisible(!portsVisible)
+ sendAnalyticsEvent({
+ category: "android",
+ action: "settings",
+ label: portsVisible ? "close" : "open",
+ })
+ }}
>
@@ -210,6 +224,7 @@ const AndroidDeviceList = ({
reactotronPort: string
metroPort: string
}) => {
+ const { sendAnalyticsEvent } = useAnalytics()
return (
<>
{devices.map((device) => (
@@ -217,9 +232,13 @@ const AndroidDeviceList = ({
{device.id}
+ onClick={() => {
ipcRenderer.send("reverse-tunnel-device", device.id, reactotronPort, metroPort)
- }
+ sendAnalyticsEvent({
+ category: "android",
+ action: "reverse-tunnel",
+ })
+ }}
data-tip={`This will allow reactotron to connect to your device via USB by running adb reverse tcp:${reactotronPort} tcp:${reactotronPort}
Reload your React Native app after pressing this.`}
data-for="reverse-tunnel"
>
@@ -230,7 +249,13 @@ const AndroidDeviceList = ({
ipcRenderer.send("reload-app", device.id)}
+ onClick={() => {
+ ipcRenderer.send("reload-app", device.id)
+ sendAnalyticsEvent({
+ category: "android",
+ action: "reload-app",
+ })
+ }}
data-tip="This will reload the React Native app currently running on this device. If you get the React Native red screen, relaunch the app from the build process."
data-for="reload-app"
>
@@ -241,7 +266,13 @@ const AndroidDeviceList = ({
ipcRenderer.send("shake-device", device.id)}
+ onClick={() => {
+ ipcRenderer.send("shake-device", device.id)
+ sendAnalyticsEvent({
+ category: "android",
+ action: "shake-device",
+ })
+ }}
data-tip="This will shake the device to bring up the React Native developer menu."
data-for="shake-device"
>
diff --git a/apps/reactotron-app/src/renderer/pages/help/index.tsx b/apps/reactotron-app/src/renderer/pages/help/index.tsx
index 6f9e661f9..572020e72 100644
--- a/apps/reactotron-app/src/renderer/pages/help/index.tsx
+++ b/apps/reactotron-app/src/renderer/pages/help/index.tsx
@@ -12,8 +12,8 @@ import { getApplicationKeyMap } from "react-hotkeys"
import { ItemContainer, ItemIconContainer } from "./SharedStyles"
import KeybindGroup from "./components/KeybindGroup"
import { reactotronLogo } from "../../images"
-
-const projectJson = require("../../../../package.json")
+import { useAnalytics } from "../../util/analyticsHelpers"
+import projectJson from "../../../../package.json"
const Container = styled.div`
display: flex;
@@ -85,6 +85,8 @@ function Keybinds() {
}
function Help() {
+ const { sendExternalLinkAnalyticsEvent } = useAnalytics()
+
return (
@@ -94,25 +96,45 @@ function Help() {
Let's Connect!
-
+ {
+ openRepo()
+ sendExternalLinkAnalyticsEvent("repo")
+ }}
+ >
GitHub Docs
-
+ {
+ openFeedback()
+ sendExternalLinkAnalyticsEvent("feedback")
+ }}
+ >
Feedback
-
+ {
+ openUpdates()
+ sendExternalLinkAnalyticsEvent("updates")
+ }}
+ >
Updates
-
+ {
+ openTwitter()
+ sendExternalLinkAnalyticsEvent("twitter")
+ }}
+ >
diff --git a/apps/reactotron-app/src/renderer/pages/home/welcome.tsx b/apps/reactotron-app/src/renderer/pages/home/welcome.tsx
index d102e31ca..0ded74427 100644
--- a/apps/reactotron-app/src/renderer/pages/home/welcome.tsx
+++ b/apps/reactotron-app/src/renderer/pages/home/welcome.tsx
@@ -3,6 +3,7 @@ import { shell } from "electron"
import styled from "styled-components"
import { reactotronLogo } from "../../images"
import { EmptyState } from "reactotron-core-ui"
+import { useAnalytics } from "../../util/analyticsHelpers"
const WelcomeText = styled.div`
font-size: 1.25em;
@@ -26,11 +27,15 @@ function openDocs() {
}
function Welcome() {
+ const { sendExternalLinkAnalyticsEvent } = useAnalytics()
+
return (
Connect a device or simulator to get started.Need to set up your app to use Reactotron?
- Check out the docs here!
+ {
+ sendExternalLinkAnalyticsEvent("docs")
+ openDocs()}}>Check out the docs here!
)
}
diff --git a/apps/reactotron-app/src/renderer/pages/reactNative/Overlay.tsx b/apps/reactotron-app/src/renderer/pages/reactNative/Overlay.tsx
index f06c73bd5..224701083 100644
--- a/apps/reactotron-app/src/renderer/pages/reactNative/Overlay.tsx
+++ b/apps/reactotron-app/src/renderer/pages/reactNative/Overlay.tsx
@@ -17,6 +17,7 @@ import { OverlayMargins } from "./components/OverlayMargins"
import type { DragEvent } from "react"
import type { JustifyContent, AlignItems } from "./components/OverlayAlignment"
import type { ResizeMode } from "./components/OverlayResizeMode"
+import { useAnalytics } from "../../util/analyticsHelpers"
const isDevelopment = process.env.NODE_ENV !== "production"
@@ -54,6 +55,7 @@ const ReapplyContainer = styled.div`
`
function Overlay() {
+ const { sendAnalyticsEvent } = useAnalytics()
const { overlayParams, updateOverlayParams } = useContext(ReactNativeContext)
const {
uri,
@@ -123,8 +125,18 @@ function Overlay() {
event.preventDefault()
event.stopPropagation()
if (event.dataTransfer.files.length !== 1) {
+ sendAnalyticsEvent({
+ category: "error",
+ action: "OverlayDropImage",
+ label: "Too many files",
+ })
return
}
+ sendAnalyticsEvent({
+ category: "overlay",
+ action: "OverlayDropImage",
+ label: "Success",
+ })
const file = event.dataTransfer.files[0]
importFile(file.path)
}
@@ -144,6 +156,11 @@ function Overlay() {
alignItems: "center",
justifyContent: "center",
})
+ sendAnalyticsEvent({
+ category: "overlay",
+ action: "OverlayRemoveImage",
+ label: "Success",
+ })
}
function handlePreventDefault(event: DragEvent) {
@@ -200,6 +217,11 @@ function Overlay() {
event.stopPropagation()
event.preventDefault()
updateOverlayParams({ showDebug: newShowDebug })
+ sendAnalyticsEvent({
+ category: "overlay",
+ action: "OverlayShowDebug",
+ label: newShowDebug ? "On" : "Off",
+ })
}
function renderDropZone() {
diff --git a/apps/reactotron-app/src/renderer/pages/reactNative/Storybook.tsx b/apps/reactotron-app/src/renderer/pages/reactNative/Storybook.tsx
index 3e3605e83..99efc2ac7 100644
--- a/apps/reactotron-app/src/renderer/pages/reactNative/Storybook.tsx
+++ b/apps/reactotron-app/src/renderer/pages/reactNative/Storybook.tsx
@@ -9,6 +9,7 @@ import {
MdWarning,
} from "react-icons/md"
import { storybookActiveImg, storybookInactiveImg } from "../../images"
+import { useAnalytics } from "../../util/analyticsHelpers"
const Container = styled.div`
display: flex;
@@ -60,6 +61,7 @@ const WarningDescription = styled.div`
`
function Storybook() {
+ const { sendAnalyticsEvent } = useAnalytics()
const { isStorybookOn, turnOffStorybook, turnOnStorybook } = useContext(ReactNativeContext)
return (
@@ -121,7 +123,16 @@ function Storybook() {
- turnOnStorybook()}>
+ {
+ turnOnStorybook()
+ sendAnalyticsEvent({
+ category: "storybook",
+ action: "ToggleStorybook",
+ label: "On",
+ })
+ }}
+ >
{isStorybookOn ? (
) : (
@@ -129,7 +140,16 @@ function Storybook() {
)}
On
- turnOffStorybook()}>
+ {
+ turnOffStorybook()
+ sendAnalyticsEvent({
+ category: "storybook",
+ action: "ToggleStorybook",
+ label: "Off",
+ })
+ }}
+ >
{isStorybookOn ? (
) : (
diff --git a/apps/reactotron-app/src/renderer/pages/state/Snapshots.tsx b/apps/reactotron-app/src/renderer/pages/state/Snapshots.tsx
index f587dbd21..eebb04217 100644
--- a/apps/reactotron-app/src/renderer/pages/state/Snapshots.tsx
+++ b/apps/reactotron-app/src/renderer/pages/state/Snapshots.tsx
@@ -19,6 +19,7 @@ import {
MdCallReceived,
MdFileDownload,
} from "react-icons/md"
+import { useAnalytics } from "../../util/analyticsHelpers"
const Container = styled.div`
display: flex;
@@ -68,6 +69,7 @@ function SnapshotItem({
removeSnapshot: (snapshot: Snapshot) => void
openSnapshotRenameModal: (snapshot: Snapshot) => void
}) {
+ const { sendAnalyticsEvent } = useAnalytics()
const [isOpen, setIsOpen] = useState(false)
return (
@@ -84,6 +86,10 @@ function SnapshotItem({
onClick={(e) => {
e.stopPropagation()
clipboard.writeText(JSON.stringify(snapshot))
+ sendAnalyticsEvent({
+ category: "snapshot",
+ action: "copy",
+ })
}}
>
@@ -95,6 +101,10 @@ function SnapshotItem({
onClick={(e) => {
e.stopPropagation()
restoreSnapshot(snapshot)
+ sendAnalyticsEvent({
+ category: "snapshot",
+ action: "restore",
+ })
}}
>
@@ -117,6 +127,10 @@ function SnapshotItem({
onClick={(e) => {
e.stopPropagation()
removeSnapshot(snapshot)
+ sendAnalyticsEvent({
+ category: "snapshot",
+ action: "remove",
+ })
}}
>
@@ -133,6 +147,7 @@ function SnapshotItem({
}
function Snapshots() {
+ const { sendAnalyticsEvent } = useAnalytics()
const {
snapshots,
createSnapshot,
@@ -173,6 +188,10 @@ function Snapshots() {
icon: MdCallReceived,
onClick: () => {
clipboard.writeText(JSON.stringify(snapshots))
+ sendAnalyticsEvent({
+ category: "snapshot",
+ action: "copy",
+ })
},
},
{
@@ -180,6 +199,10 @@ function Snapshots() {
icon: MdFileDownload,
onClick: () => {
createSnapshot()
+ sendAnalyticsEvent({
+ category: "snapshot",
+ action: "add",
+ })
},
},
]}
diff --git a/apps/reactotron-app/src/renderer/pages/state/Subscriptions.tsx b/apps/reactotron-app/src/renderer/pages/state/Subscriptions.tsx
index b7c8d87f8..c63024497 100644
--- a/apps/reactotron-app/src/renderer/pages/state/Subscriptions.tsx
+++ b/apps/reactotron-app/src/renderer/pages/state/Subscriptions.tsx
@@ -13,6 +13,7 @@ import { getApplicationKeyMap } from "react-hotkeys"
// Move this out of this page. We are just hacking around this for now
import { KeybindKeys, getPlatformSequence } from "../help/components/Keybind"
+import { useAnalytics } from "../../util/analyticsHelpers"
const Container = styled.div`
display: flex;
@@ -57,6 +58,7 @@ function getLatestChanges(commands: any[]) {
}
function Subscriptions() {
+ const { sendAnalyticsEvent } = useAnalytics()
const { commands, openSubscriptionModal } = useContext(ReactotronContext)
const { removeSubscription, clearSubscriptions } = useContext(StateContext)
@@ -96,6 +98,10 @@ function Subscriptions() {
icon: MdAdd,
onClick: () => {
openSubscriptionModal()
+ sendAnalyticsEvent({
+ category: "subscription",
+ action: "add",
+ })
},
},
{
@@ -103,6 +109,10 @@ function Subscriptions() {
icon: MdDeleteSweep,
onClick: () => {
clearSubscriptions()
+ sendAnalyticsEvent({
+ category: "subscription",
+ action: "clear",
+ })
},
},
]}
diff --git a/apps/reactotron-app/src/renderer/pages/timeline/index.tsx b/apps/reactotron-app/src/renderer/pages/timeline/index.tsx
index 3051a66d5..b0faa7ce5 100644
--- a/apps/reactotron-app/src/renderer/pages/timeline/index.tsx
+++ b/apps/reactotron-app/src/renderer/pages/timeline/index.tsx
@@ -14,6 +14,7 @@ import {
import { MdSearch, MdDeleteSweep, MdFilterList, MdSwapVert, MdReorder } from "react-icons/md"
import { FaTimes } from "react-icons/fa"
import styled from "styled-components"
+import { useAnalytics } from "../../util/analyticsHelpers"
const Container = styled.div`
display: flex;
@@ -54,6 +55,7 @@ export const ButtonContainer = styled.div`
`
function Timeline() {
+ const { sendAnalyticsEvent } = useAnalytics()
const { sendCommand, clearCommands, commands, openDispatchModal } = useContext(ReactotronContext)
const {
isSearchOpen,
@@ -93,6 +95,11 @@ function Timeline() {
icon: MdSearch,
onClick: () => {
toggleSearch()
+ sendAnalyticsEvent({
+ category: "timeline",
+ action: "search",
+ label: isSearchOpen ? "open" : "close",
+ })
},
},
{
@@ -100,6 +107,11 @@ function Timeline() {
icon: MdFilterList,
onClick: () => {
openFilter()
+ sendAnalyticsEvent({
+ category: "timeline",
+ action: "filter",
+ label: isFilterOpen ? "open" : "close",
+ })
},
},
{
@@ -107,6 +119,11 @@ function Timeline() {
icon: MdSwapVert,
onClick: () => {
toggleReverse()
+ sendAnalyticsEvent({
+ category: "timeline",
+ action: "reverse",
+ label: isReversed ? "on" : "off",
+ })
},
},
{
@@ -114,6 +131,10 @@ function Timeline() {
icon: MdDeleteSweep,
onClick: () => {
clearCommands()
+ sendAnalyticsEvent({
+ category: "timeline",
+ action: "clear",
+ })
},
},
]}
@@ -174,6 +195,11 @@ function Timeline() {
isOpen={isFilterOpen}
onClose={() => {
closeFilter()
+ sendAnalyticsEvent({
+ category: "timeline",
+ action: "filter",
+ label: "close",
+ })
}}
hiddenCommands={hiddenCommands}
setHiddenCommands={setHiddenCommands}
diff --git a/apps/reactotron-app/src/renderer/util/analyticsHelpers.tsx b/apps/reactotron-app/src/renderer/util/analyticsHelpers.tsx
new file mode 100644
index 000000000..8522cb194
--- /dev/null
+++ b/apps/reactotron-app/src/renderer/util/analyticsHelpers.tsx
@@ -0,0 +1,191 @@
+import React from "react"
+import ReactGA from "react-ga4"
+import packageJson from "../../../package.json"
+import { useLocation } from "react-router"
+import configStore from "../config"
+
+// This is the Google Analytics 4 key for Reactotron
+// TODO: Change this to the correct key for production.
+const GA4_KEY = "G-WZE3E5XCQ7"
+
+type IAnalyticsEventCategory =
+ | { category: "opt-out"; actions: ["opt-out"] }
+ | { category: "android"; actions: ["settings", "reverse-tunnel", "reload-app", "shake-device"] }
+ | { category: "navigation"; actions: ["keyboard_shortcut"] }
+ | { category: "external_link"; actions: ["click"] }
+ | { category: "timeline"; actions: ["search", "filter", "reverse", "clear"] }
+ | { category: "error"; actions: ["OverlayDropImage"] }
+ | { category: "overlay"; actions: ["OverlayDropImage", "OverlayRemoveImage", "OverlayShowDebug"] }
+ | { category: "dispatch"; actions: ["dispatchAbort", "dispatchConfirm"] }
+ | { category: "subscription"; actions: ["addAbort", "addConfirm", "add", "clear"] }
+ | { category: "snapshot"; actions: ["copy", "restore", "remove", "copy", "add"] }
+ | { category: "storybook"; actions: ["ToggleStorybook"] }
+ | { category: "custom_command"; actions: ["sendCommand"] }
+
+// I had trouble importing this type from the react-ga4 package, so I'm defining it here.
+type UaEventOptions = {
+ category: IAnalyticsEventCategory["category"]
+ action: IAnalyticsEventCategory["actions"][number]
+ label?: string
+ value?: number
+ nonInteraction?: boolean
+ transport?: "beacon" | "xhr" | "image"
+}
+
+// Our user's opt-out status can be one of these three values.
+// Analytics will never be initialized if the user has opted out or if the status is unknown.
+type IOptOutStatus = "unknown" | true | false
+
+// This is the main analytics hook that we use throughout the app.
+// It handles initializing analytics, sending events, and tracking page views.
+// It also handles the user's opt-out status.
+// We use a custom alert to ask the user if they want to opt-in to analytics.
+export const useAnalytics = () => {
+ const [initialized, setInitialized] = React.useState(false)
+ const [optedOut, setOptedOut] = React.useState("unknown")
+
+ React.useEffect(() => {
+ const storeWatcher = configStore.onDidChange("analyticsOptOut", (newValue) => {
+ console.log("[analytics] user has changed opt-out status", newValue)
+ setOptedOut(newValue as IOptOutStatus)
+ })
+ return () => {
+ storeWatcher().removeAllListeners()
+ }
+ }, [])
+
+ // Get the user's opt-out status from the config store
+ const initializeAnalytics = () => {
+ const status = configStore.get("analyticsOptOut") as IOptOutStatus
+
+ if (status === "unknown") {
+ console.log(`[analytics] user has not opted in or out`)
+ } else {
+ // If the user has opted out, we'll disable analytics
+ setOptedOut(status)
+ setInitialized(false)
+ console.log(`[analytics] user has opted ${status ? "out" : "in"}`)
+ }
+
+ return status
+ }
+
+ // Initialize analytics and set some system data like the app version and platform
+ // as well as the mode we are running in. We don't want to send analytics events
+ // during tests, so we disable them if we are running in test mode.
+ // We also disable analytics if the user has opted out.
+ React.useEffect(() => {
+ const initialize = () => {
+ const testMode = process.env.NODE_ENV === "test" // we don't want to send analytics events during tests
+ ReactGA.initialize(GA4_KEY, { testMode: testMode || optedOut === true })
+ !optedOut &&
+ ReactGA.set({
+ app_version: packageJson.version,
+ app_platform: process.platform,
+ app_arch: process.arch,
+ app_mode: process.env.NODE_ENV,
+ })
+ }
+
+ if (!initialized) {
+ initialize()
+ setInitialized(true)
+ }
+ }, [initialized, optedOut])
+
+ // Send an analytics event
+ // This is the main function we use to send events throughout the app.
+ // See documentation here for how to use react-ga4:
+ // https://github.com/codler/react-ga4
+ const sendAnalyticsEvent = React.useCallback(
+ (event: UaEventOptions) => {
+ if (!optedOut) {
+ console.log("[analytics] Sending event", event)
+ ReactGA.event(event)
+ }
+ },
+ [optedOut]
+ )
+
+ // Send a page view event
+ const sendPageViewAnalyticsEvent = React.useCallback(
+ (page: string) => {
+ if (!optedOut) {
+ console.log("[analytics] Sending page view event", page)
+ ReactGA.send({ hitType: "pageview", page })
+ }
+ },
+ [optedOut]
+ )
+
+ // Send a keyboard shortcut event
+ const sendKeyboardShortcutAnalyticsEvent = React.useCallback(
+ (label: string) => {
+ sendAnalyticsEvent({
+ category: "navigation",
+ action: "keyboard_shortcut",
+ nonInteraction: false,
+ label,
+ })
+ },
+ [sendAnalyticsEvent]
+ )
+
+ // Send a custom command event
+ const sendCustomCommandAnalyticsEvent = React.useCallback(
+ (command: string) => {
+ sendAnalyticsEvent({
+ category: "custom_command",
+ action: "sendCommand",
+ nonInteraction: false,
+ label: command,
+ })
+ },
+ [sendAnalyticsEvent]
+ )
+
+ // Send an external link event
+ const sendExternalLinkAnalyticsEvent = React.useCallback(
+ (label: string) => {
+ sendAnalyticsEvent({
+ category: "external_link",
+ action: "click",
+ nonInteraction: false,
+ label,
+ })
+ },
+ [sendAnalyticsEvent]
+ )
+
+ const sendOptOutAnalyticsEvent = React.useCallback(() => {
+ const event = {
+ category: "opt-out",
+ action: "opt-out",
+ nonInteraction: false,
+ }
+ console.log("[analytics] Sending opt-out event", event)
+ ReactGA.event(event) // this is the only time we send an event without checking the optedOut status
+ }, [])
+
+ return {
+ initializeAnalytics,
+ sendAnalyticsEvent,
+ sendPageViewAnalyticsEvent,
+ sendKeyboardShortcutAnalyticsEvent,
+ sendCustomCommandAnalyticsEvent,
+ sendExternalLinkAnalyticsEvent,
+ sendOptOutAnalyticsEvent,
+ }
+}
+
+// This hook is used to track page views within the app automatically using react-router-dom
+// This hook should only be used one time in the app, near the root of the component tree
+// but inside the HashRouter.
+export const usePageTracking = () => {
+ const location = useLocation()
+ const { sendPageViewAnalyticsEvent } = useAnalytics()
+
+ React.useEffect(() => {
+ sendPageViewAnalyticsEvent(location.pathname)
+ }, [location, sendPageViewAnalyticsEvent])
+}
diff --git a/apps/reactotron-app/tsconfig.json b/apps/reactotron-app/tsconfig.json
index 7014afeeb..e5fc8c2d5 100644
--- a/apps/reactotron-app/tsconfig.json
+++ b/apps/reactotron-app/tsconfig.json
@@ -18,6 +18,7 @@
"types": ["jest"],
"skipLibCheck": true,
"baseUrl": ".",
+ "resolveJsonModule": true,
},
"exclude": ["node_modules"],
"include": ["src"],
diff --git a/yarn.lock b/yarn.lock
index d739692c9..f81f82349 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -751,18 +751,7 @@ __metadata:
languageName: node
linkType: hard
-"@babel/plugin-syntax-flow@npm:^7.0.0, @babel/plugin-syntax-flow@npm:^7.24.1":
- version: 7.24.1
- resolution: "@babel/plugin-syntax-flow@npm:7.24.1"
- dependencies:
- "@babel/helper-plugin-utils": "npm:^7.24.0"
- peerDependencies:
- "@babel/core": ^7.0.0-0
- checksum: 10/87dfe32f3a3ea77941034fb2a39fdfc9ea18a994b8df40c3659a11c8787b2bc5adea029259c4eafc03cd35f11628f6533aa2a06381db7fcbe3b2cc3c2a2bb54f
- languageName: node
- linkType: hard
-
-"@babel/plugin-syntax-flow@npm:^7.12.1, @babel/plugin-syntax-flow@npm:^7.18.0, @babel/plugin-syntax-flow@npm:^7.23.3":
+"@babel/plugin-syntax-flow@npm:^7.0.0, @babel/plugin-syntax-flow@npm:^7.12.1, @babel/plugin-syntax-flow@npm:^7.18.0, @babel/plugin-syntax-flow@npm:^7.23.3":
version: 7.23.3
resolution: "@babel/plugin-syntax-flow@npm:7.23.3"
dependencies:
@@ -817,18 +806,7 @@ __metadata:
languageName: node
linkType: hard
-"@babel/plugin-syntax-jsx@npm:^7.0.0":
- version: 7.24.1
- resolution: "@babel/plugin-syntax-jsx@npm:7.24.1"
- dependencies:
- "@babel/helper-plugin-utils": "npm:^7.24.0"
- peerDependencies:
- "@babel/core": ^7.0.0-0
- checksum: 10/712f7e7918cb679f106769f57cfab0bc99b311032665c428b98f4c3e2e6d567601d45386a4f246df6a80d741e1f94192b3f008800d66c4f1daae3ad825c243f0
- languageName: node
- linkType: hard
-
-"@babel/plugin-syntax-jsx@npm:^7.23.3, @babel/plugin-syntax-jsx@npm:^7.7.2":
+"@babel/plugin-syntax-jsx@npm:^7.0.0, @babel/plugin-syntax-jsx@npm:^7.23.3, @babel/plugin-syntax-jsx@npm:^7.7.2":
version: 7.23.3
resolution: "@babel/plugin-syntax-jsx@npm:7.23.3"
dependencies:
@@ -988,18 +966,7 @@ __metadata:
languageName: node
linkType: hard
-"@babel/plugin-transform-block-scoped-functions@npm:^7.0.0":
- version: 7.24.1
- resolution: "@babel/plugin-transform-block-scoped-functions@npm:7.24.1"
- dependencies:
- "@babel/helper-plugin-utils": "npm:^7.24.0"
- peerDependencies:
- "@babel/core": ^7.0.0-0
- checksum: 10/d8e18bd57b156da1cd4d3c1780ab9ea03afed56c6824ca8e6e74f67959d7989a0e953ec370fe9b417759314f2eef30c8c437395ce63ada2e26c2f469e4704f82
- languageName: node
- linkType: hard
-
-"@babel/plugin-transform-block-scoped-functions@npm:^7.23.3":
+"@babel/plugin-transform-block-scoped-functions@npm:^7.0.0, @babel/plugin-transform-block-scoped-functions@npm:^7.23.3":
version: 7.23.3
resolution: "@babel/plugin-transform-block-scoped-functions@npm:7.23.3"
dependencies:
@@ -1076,18 +1043,7 @@ __metadata:
languageName: node
linkType: hard
-"@babel/plugin-transform-destructuring@npm:^7.0.0":
- version: 7.24.1
- resolution: "@babel/plugin-transform-destructuring@npm:7.24.1"
- dependencies:
- "@babel/helper-plugin-utils": "npm:^7.24.0"
- peerDependencies:
- "@babel/core": ^7.0.0-0
- checksum: 10/03d9a81cd9eeb24d48e207be536d460d6ad228238ac70da9b7ad4bae799847bb3be0aecfa4ea6223752f3a8d4ada3a58cd9a0f8fc70c01fdfc87ad0618f897d3
- languageName: node
- linkType: hard
-
-"@babel/plugin-transform-destructuring@npm:^7.12.1, @babel/plugin-transform-destructuring@npm:^7.20.0, @babel/plugin-transform-destructuring@npm:^7.23.3":
+"@babel/plugin-transform-destructuring@npm:^7.0.0, @babel/plugin-transform-destructuring@npm:^7.12.1, @babel/plugin-transform-destructuring@npm:^7.20.0, @babel/plugin-transform-destructuring@npm:^7.23.3":
version: 7.23.3
resolution: "@babel/plugin-transform-destructuring@npm:7.23.3"
dependencies:
@@ -1169,19 +1125,7 @@ __metadata:
languageName: node
linkType: hard
-"@babel/plugin-transform-flow-strip-types@npm:^7.0.0":
- version: 7.24.1
- resolution: "@babel/plugin-transform-flow-strip-types@npm:7.24.1"
- dependencies:
- "@babel/helper-plugin-utils": "npm:^7.24.0"
- "@babel/plugin-syntax-flow": "npm:^7.24.1"
- peerDependencies:
- "@babel/core": ^7.0.0-0
- checksum: 10/6e1db557d7d34a8dbfdf430557f47c75930a9044b838bb3cc706f9c816e11cd68a61c68239478dd05bbe3ec197113ad0c22c5be1bdddac8723040dd9e9cb9dc0
- languageName: node
- linkType: hard
-
-"@babel/plugin-transform-flow-strip-types@npm:^7.20.0, @babel/plugin-transform-flow-strip-types@npm:^7.23.3":
+"@babel/plugin-transform-flow-strip-types@npm:^7.0.0, @babel/plugin-transform-flow-strip-types@npm:^7.20.0, @babel/plugin-transform-flow-strip-types@npm:^7.23.3":
version: 7.23.3
resolution: "@babel/plugin-transform-flow-strip-types@npm:7.23.3"
dependencies:
@@ -1193,19 +1137,7 @@ __metadata:
languageName: node
linkType: hard
-"@babel/plugin-transform-for-of@npm:^7.0.0":
- version: 7.24.1
- resolution: "@babel/plugin-transform-for-of@npm:7.24.1"
- dependencies:
- "@babel/helper-plugin-utils": "npm:^7.24.0"
- "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.22.5"
- peerDependencies:
- "@babel/core": ^7.0.0-0
- checksum: 10/befd0908c3f6b31f9fa9363a3c112d25eaa0bc4a79cfad1f0a8bb5010937188b043a44fb23443bc8ffbcc40c015bb25f80e4cc585ce5cc580708e2d56e76fe37
- languageName: node
- linkType: hard
-
-"@babel/plugin-transform-for-of@npm:^7.12.1, @babel/plugin-transform-for-of@npm:^7.23.6":
+"@babel/plugin-transform-for-of@npm:^7.0.0, @babel/plugin-transform-for-of@npm:^7.12.1, @babel/plugin-transform-for-of@npm:^7.23.6":
version: 7.23.6
resolution: "@babel/plugin-transform-for-of@npm:7.23.6"
dependencies:
@@ -1265,18 +1197,7 @@ __metadata:
languageName: node
linkType: hard
-"@babel/plugin-transform-member-expression-literals@npm:^7.0.0":
- version: 7.24.1
- resolution: "@babel/plugin-transform-member-expression-literals@npm:7.24.1"
- dependencies:
- "@babel/helper-plugin-utils": "npm:^7.24.0"
- peerDependencies:
- "@babel/core": ^7.0.0-0
- checksum: 10/4ea641cc14a615f9084e45ad2319f95e2fee01c77ec9789685e7e11a6c286238a426a98f9c1ed91568a047d8ac834393e06e8c82d1ff01764b7aa61bee8e9023
- languageName: node
- linkType: hard
-
-"@babel/plugin-transform-member-expression-literals@npm:^7.23.3":
+"@babel/plugin-transform-member-expression-literals@npm:^7.0.0, @babel/plugin-transform-member-expression-literals@npm:^7.23.3":
version: 7.23.3
resolution: "@babel/plugin-transform-member-expression-literals@npm:7.23.3"
dependencies:
@@ -1425,19 +1346,7 @@ __metadata:
languageName: node
linkType: hard
-"@babel/plugin-transform-object-super@npm:^7.0.0":
- version: 7.24.1
- resolution: "@babel/plugin-transform-object-super@npm:7.24.1"
- dependencies:
- "@babel/helper-plugin-utils": "npm:^7.24.0"
- "@babel/helper-replace-supers": "npm:^7.24.1"
- peerDependencies:
- "@babel/core": ^7.0.0-0
- checksum: 10/d34d437456a54e2a5dcb26e9cf09ed4c55528f2a327c5edca92c93e9483c37176e228d00d6e0cf767f3d6fdbef45ae3a5d034a7c59337a009e20ae541c8220fa
- languageName: node
- linkType: hard
-
-"@babel/plugin-transform-object-super@npm:^7.23.3":
+"@babel/plugin-transform-object-super@npm:^7.0.0, @babel/plugin-transform-object-super@npm:^7.23.3":
version: 7.23.3
resolution: "@babel/plugin-transform-object-super@npm:7.23.3"
dependencies:
@@ -1548,18 +1457,7 @@ __metadata:
languageName: node
linkType: hard
-"@babel/plugin-transform-property-literals@npm:^7.0.0":
- version: 7.24.1
- resolution: "@babel/plugin-transform-property-literals@npm:7.24.1"
- dependencies:
- "@babel/helper-plugin-utils": "npm:^7.24.0"
- peerDependencies:
- "@babel/core": ^7.0.0-0
- checksum: 10/a73646d7ecd95b3931a3ead82c7d5efeb46e68ba362de63eb437d33531f294ec18bd31b6d24238cd3b6a3b919a6310c4a0ba4a2629927721d4d10b0518eb7715
- languageName: node
- linkType: hard
-
-"@babel/plugin-transform-property-literals@npm:^7.23.3":
+"@babel/plugin-transform-property-literals@npm:^7.0.0, @babel/plugin-transform-property-literals@npm:^7.23.3":
version: 7.23.3
resolution: "@babel/plugin-transform-property-literals@npm:7.23.3"
dependencies:
@@ -23036,6 +22934,15 @@ __metadata:
languageName: node
linkType: hard
+"react-fade-in@npm:^2.0.1":
+ version: 2.0.1
+ resolution: "react-fade-in@npm:2.0.1"
+ peerDependencies:
+ react: ^16.8 || 17
+ checksum: 10/34e6ddf1641417e170a268b07b514d3207603b14a56bc9836372828b72782df16e51d3bd066a271e6f6c7e2bf2916b100b72b06dd20a5447d965b16f78160cd5
+ languageName: node
+ linkType: hard
+
"react-fast-compare@npm:^3.0.1, react-fast-compare@npm:^3.2.0":
version: 3.2.2
resolution: "react-fast-compare@npm:3.2.2"
@@ -23072,6 +22979,13 @@ __metadata:
languageName: node
linkType: hard
+"react-ga4@npm:^2.1.0":
+ version: 2.1.0
+ resolution: "react-ga4@npm:2.1.0"
+ checksum: 10/e47edd040b7a272d35df732e08c4a8b6b14add659db04fb81a3a2cbde59bf34dc6eae9e0b8b4abd8937d11c837954773a6ba86feb2c605e5eb916f4c99e420c7
+ languageName: node
+ linkType: hard
+
"react-helmet-async@npm:^1.0.2":
version: 1.3.0
resolution: "react-helmet-async@npm:1.3.0"
@@ -23759,6 +23673,8 @@ __metadata:
prettier: "npm:^3.0.3"
react: "npm:18.2.0"
react-dom: "npm:18.2.0"
+ react-fade-in: "npm:^2.0.1"
+ react-ga4: "npm:^2.1.0"
react-hotkeys: "npm:^2.0.0"
react-icons: "npm:^4.11.0"
react-modal: "npm:3.16.1"