Skip to content

Commit 8133aa0

Browse files
authored
feat: add website analytics tracking (#38)
1 parent 04d51bb commit 8133aa0

9 files changed

Lines changed: 201 additions & 15 deletions

File tree

RxCodeMobile/Views/MobileChatView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,7 @@ struct MobileChatView: View {
349349
.environment(\.chatTrackedMessageID, trackedUserMessageID)
350350
.environment(\.chatTrackedMessageGeometry, updateLatestUserMinY)
351351
}
352-
.scrollDismissesKeyboard(.interactively)
352+
.mobileDismissesKeyboardOnScroll(.interactively)
353353
.onGeometryChange(for: CGRect.self) { proxy in
354354
proxy.frame(in: .global)
355355
} action: { rect in
Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,40 @@
11
import SwiftUI
2+
import UIKit
3+
4+
private struct MobileKeyboardDismissOnScrollModifier: ViewModifier {
5+
let mode: ScrollDismissesKeyboardMode
6+
@GestureState private var isDragging = false
7+
8+
func body(content: Content) -> some View {
9+
content
10+
.scrollDismissesKeyboard(mode)
11+
.simultaneousGesture(
12+
DragGesture(minimumDistance: 2)
13+
.updating($isDragging) { _, state, _ in
14+
if !state {
15+
UIApplication.shared.dismissKeyboard()
16+
}
17+
state = true
18+
}
19+
)
20+
}
21+
}
222

323
extension View {
424
func mobileDismissesKeyboardOnScroll(
525
_ mode: ScrollDismissesKeyboardMode = .interactively
626
) -> some View {
7-
scrollDismissesKeyboard(mode)
27+
modifier(MobileKeyboardDismissOnScrollModifier(mode: mode))
28+
}
29+
}
30+
31+
private extension UIApplication {
32+
func dismissKeyboard() {
33+
sendAction(
34+
#selector(UIResponder.resignFirstResponder),
35+
to: nil,
36+
from: nil,
37+
for: nil
38+
)
839
}
940
}

website/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ Open [http://localhost:3000](http://localhost:3000) with your browser to see the
1818

1919
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
2020

21+
## Environment
22+
23+
Set `NEXT_PUBLIC_GOOGLE_ANALYTICS_ID` to enable Google Analytics page-view
24+
tracking and CTA click events.
25+
2126
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
2227

2328
## Learn More

website/app/analytics.tsx

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import Script from "next/script";
2+
3+
const GOOGLE_ANALYTICS_ID = process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID?.trim();
4+
5+
export const isGoogleAnalyticsEnabled = Boolean(GOOGLE_ANALYTICS_ID);
6+
7+
export type AnalyticsEventName =
8+
| "download_button_click"
9+
| "app_store_button_click";
10+
11+
type AnalyticsEventParams = Record<string, string | number | boolean | null>;
12+
13+
declare global {
14+
interface Window {
15+
dataLayer?: unknown[];
16+
gtag?: (
17+
command: "js" | "config" | "event",
18+
target: string | Date,
19+
params?: Record<string, unknown>
20+
) => void;
21+
}
22+
}
23+
24+
export function GoogleAnalytics() {
25+
if (!GOOGLE_ANALYTICS_ID) {
26+
return null;
27+
}
28+
29+
return (
30+
<>
31+
<Script
32+
src={`https://www.googletagmanager.com/gtag/js?id=${GOOGLE_ANALYTICS_ID}`}
33+
strategy="afterInteractive"
34+
/>
35+
<Script id="google-analytics" strategy="afterInteractive">
36+
{`
37+
window.dataLayer = window.dataLayer || [];
38+
function gtag(){dataLayer.push(arguments);}
39+
gtag('js', new Date());
40+
gtag('config', '${GOOGLE_ANALYTICS_ID}', { send_page_view: false });
41+
`}
42+
</Script>
43+
</>
44+
);
45+
}
46+
47+
export function trackAnalyticsEvent(
48+
eventName: AnalyticsEventName,
49+
params: AnalyticsEventParams = {}
50+
) {
51+
if (!isGoogleAnalyticsEnabled || typeof window === "undefined") {
52+
return;
53+
}
54+
55+
window.gtag?.("event", eventName, params);
56+
}
57+

website/app/layout.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { Metadata } from "next";
22
import { Geist, Inter, JetBrains_Mono } from "next/font/google";
3+
import { GoogleAnalytics } from "./analytics";
34
import "./globals.css";
5+
import { PageViewTracker } from "./page-view-tracker";
46

57
const inter = Inter({
68
variable: "--font-inter",
@@ -63,7 +65,11 @@ export default function RootLayout({
6365
lang="en"
6466
className={`${inter.variable} ${geist.variable} ${jetbrainsMono.variable}`}
6567
>
66-
<body className="min-h-screen flex flex-col">{children}</body>
68+
<body className="min-h-screen flex flex-col">
69+
{children}
70+
<PageViewTracker />
71+
</body>
72+
<GoogleAnalytics />
6773
</html>
6874
);
6975
}

website/app/page-view-tracker.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"use client";
2+
3+
import { usePathname } from "next/navigation";
4+
import { useEffect } from "react";
5+
import { isGoogleAnalyticsEnabled } from "./analytics";
6+
7+
export function PageViewTracker() {
8+
const pathname = usePathname();
9+
10+
useEffect(() => {
11+
if (!isGoogleAnalyticsEnabled || typeof window === "undefined") {
12+
return;
13+
}
14+
15+
const pagePath = `${window.location.pathname}${window.location.search}`;
16+
17+
window.gtag?.("event", "page_view", {
18+
page_path: pagePath,
19+
page_location: window.location.href,
20+
page_title: document.title,
21+
});
22+
}, [pathname]);
23+
24+
return null;
25+
}

website/app/page.tsx

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Image from "next/image";
22
import Link from "next/link";
33
import { AgentTalkFeature } from "./agent-talk-feature";
44
import { formatSize, getLatestRelease } from "./lib/release";
5+
import { TrackedLink } from "./tracked-link";
56

67
const GITHUB_REPO_URL = "https://github.com/rxtech-lab/rxcode";
78

@@ -202,12 +203,15 @@ function TopNav({
202203
</a>
203204
</div>
204205
</div>
205-
<a
206+
<TrackedLink
206207
href={release.dmgUrl}
208+
analyticsEventName="download_button_click"
209+
analyticsLabel="Download for macOS"
210+
analyticsLocation="top_nav"
207211
className="hidden sm:inline-flex items-center gap-2 bg-primary text-on-primary px-4 py-2 font-mono text-[11px] tracking-widest uppercase border border-primary hover:bg-transparent hover:text-primary transition-colors"
208212
>
209213
Download for macOS
210-
</a>
214+
</TrackedLink>
211215
</div>
212216
</nav>
213217
);
@@ -244,8 +248,11 @@ function Hero({
244248
</p>
245249

246250
<div className="mt-10 flex flex-col sm:flex-row gap-3">
247-
<a
251+
<TrackedLink
248252
href={release.dmgUrl}
253+
analyticsEventName="download_button_click"
254+
analyticsLabel={`Download for macOS ${release.tag}`}
255+
analyticsLocation="hero"
249256
className="inline-flex items-center justify-center gap-3 bg-primary text-on-primary px-8 py-3.5 font-mono text-xs tracking-widest uppercase border border-primary hover:bg-transparent hover:text-primary transition-colors active:scale-95"
250257
>
251258
<AppleIcon className="w-4 h-4" />
@@ -255,7 +262,7 @@ function Hero({
255262
{release.tag}
256263
{sizeLabel ? ` · ${sizeLabel}` : ""}
257264
</span>
258-
</a>
265+
</TrackedLink>
259266
<a
260267
href={GITHUB_REPO_URL}
261268
target="_blank"
@@ -466,8 +473,11 @@ function MobileCompanion() {
466473
))}
467474
</ul>
468475
{appStoreUrl ? (
469-
<a
476+
<TrackedLink
470477
href={appStoreUrl}
478+
analyticsEventName="app_store_button_click"
479+
analyticsLabel="Download on the App Store"
480+
analyticsLocation="mobile_companion"
471481
target="_blank"
472482
rel="noreferrer"
473483
className="mt-8 inline-block w-fit hover:opacity-85 transition-opacity active:scale-95"
@@ -480,7 +490,7 @@ function MobileCompanion() {
480490
unoptimized
481491
className="h-[52px] w-auto"
482492
/>
483-
</a>
493+
</TrackedLink>
484494
) : null}
485495
</div>
486496
<div className="grid grid-cols-2 gap-4 sm:gap-5">
@@ -523,13 +533,16 @@ function CTA({
523533
start driving your agents visually.
524534
</p>
525535
<div className="flex flex-col sm:flex-row gap-3 justify-center">
526-
<a
536+
<TrackedLink
527537
href={release.dmgUrl}
538+
analyticsEventName="download_button_click"
539+
analyticsLabel={`Download RxCode ${release.tag}`}
540+
analyticsLocation="cta"
528541
className="inline-flex items-center justify-center gap-3 bg-primary text-on-primary px-8 py-3.5 font-mono text-xs tracking-widest uppercase border border-primary hover:bg-transparent hover:text-primary transition-colors active:scale-95"
529542
>
530543
<AppleIcon className="w-4 h-4" />
531544
Download RxCode {release.tag}
532-
</a>
545+
</TrackedLink>
533546
<Link
534547
href="/release"
535548
className="inline-flex items-center justify-center gap-2 border border-outline text-on-surface px-8 py-3.5 font-mono text-xs tracking-widest uppercase hover:border-primary hover:text-primary transition-colors active:scale-95"

website/app/release/page.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
getLatestRelease,
99
type AppReleaseNote,
1010
} from "../lib/release";
11+
import { TrackedLink } from "../tracked-link";
1112

1213
const GITHUB_REPO_URL = "https://github.com/rxtech-lab/rxcode";
1314

@@ -122,12 +123,15 @@ function ReleaseList({
122123
<section className="max-w-[var(--container-max)] mx-auto px-6 pt-4">
123124
<div className="bg-surface border border-surface-variant p-8 text-center text-on-surface-variant">
124125
<p>Release notes are temporarily unavailable.</p>
125-
<a
126+
<TrackedLink
126127
href={latestDmg}
128+
analyticsEventName="download_button_click"
129+
analyticsLabel="Download latest build"
130+
analyticsLocation="release_empty_state"
127131
className="mt-6 inline-flex items-center justify-center gap-2 border border-outline px-6 py-3 font-mono text-xs tracking-widest uppercase hover:border-primary hover:text-primary transition-colors"
128132
>
129133
Download latest build
130-
</a>
134+
</TrackedLink>
131135
</div>
132136
</section>
133137
);
@@ -196,12 +200,15 @@ function ReleaseCard({
196200
</div>
197201
<div className="flex flex-wrap gap-3">
198202
{release.enclosureUrl && (
199-
<a
203+
<TrackedLink
200204
href={release.enclosureUrl}
205+
analyticsEventName="download_button_click"
206+
analyticsLabel={`Download ${tag}`}
207+
analyticsLocation="release_card"
201208
className="inline-flex items-center justify-center gap-2 bg-primary text-on-primary px-5 py-2.5 font-mono text-[11px] tracking-widest uppercase border border-primary hover:bg-transparent hover:text-primary transition-colors active:scale-95"
202209
>
203210
Download .dmg
204-
</a>
211+
</TrackedLink>
205212
)}
206213
{release.link && (
207214
<a

website/app/tracked-link.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"use client";
2+
3+
import type { AnchorHTMLAttributes, ReactNode } from "react";
4+
import {
5+
type AnalyticsEventName,
6+
trackAnalyticsEvent,
7+
} from "./analytics";
8+
9+
type TrackedLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
10+
analyticsEventName: AnalyticsEventName;
11+
analyticsLabel: string;
12+
analyticsLocation: string;
13+
children: ReactNode;
14+
};
15+
16+
export function TrackedLink({
17+
analyticsEventName,
18+
analyticsLabel,
19+
analyticsLocation,
20+
children,
21+
href,
22+
onClick,
23+
...props
24+
}: TrackedLinkProps) {
25+
return (
26+
<a
27+
href={href}
28+
onClick={(event) => {
29+
trackAnalyticsEvent(analyticsEventName, {
30+
label: analyticsLabel,
31+
location: analyticsLocation,
32+
link_url: href ?? null,
33+
transport_type: "beacon",
34+
});
35+
onClick?.(event);
36+
}}
37+
{...props}
38+
>
39+
{children}
40+
</a>
41+
);
42+
}

0 commit comments

Comments
 (0)