返回 CodeWhale
usage-counting.tsx
根目录 / web / components / usage-counting.tsx
1 "use client";
2
3 /**
4 * Anonymous usage counting on the website — the recorder owner and the
5 * privacy-page control.
6 *
7 * <UsageCounting> Mounted once in the locale layout. Renders
8 * nothing. Counts `page_view` on every route
9 * change (`docs_view` too on docs routes) and any
10 * element carrying `data-usage="<counter>"` when it
11 * is clicked; flushes once after a pause or on
12 * pagehide. `recordUsage()` is the import for
13 * client components (copy buttons, the error
14 * boundary).
15 * <UsagePreferenceControl> The only place on the site that shows or changes
16 * the choice. It lives on the privacy page, not in
17 * a banner: counting is on by default, every
18 * opt-out stays off, and other tabs follow through
19 * the `storage` event.
20 *
21 * Nothing renders on the server, so the page is complete and static without
22 * it; reduced motion is respected because there is no motion.
23 */
24
25 import { usePathname } from "next/navigation";
26 import { useCallback, useEffect, useState } from "react";
27 import { USAGE_COUNTING_COPY } from "@/lib/content/usage-counting";
28 import { pickText } from "@/lib/i18n/dictionaries";
29 import { isDocsPath } from "@/lib/i18n/path";
30 import {
31 PREFERENCE_STORAGE_KEY,
32 PRODUCT_COUNTER_FIELDS,
33 createUsageRecorder,
34 type ProductCounter,
35 type UsagePreference,
36 type UsageRecorder,
37 } from "@/lib/telemetry/product-usage";
38
39 const ENDPOINT = "/api/product-telemetry";
40
41 let recorder: UsageRecorder | null = null;
42
43 function getRecorder(appVersion: string): UsageRecorder | null {
44 if (typeof window === "undefined") return null;
45 if (!recorder) {
46 let storage: Storage | null = null;
47 try {
48 storage = window.localStorage;
49 } catch {
50 storage = null;
51 }
52 if (!storage) return null;
53 recorder = createUsageRecorder({
54 surface: "website",
55 appVersion,
56 endpoint: ENDPOINT,
57 storage,
58 });
59 }
60 return recorder;
61 }
62
63 /** Count one interaction. A no-op while counting is turned off. */
64 export function recordUsage(counter: ProductCounter): void {
65 recorder?.record(counter);
66 }
67
68 function isCounter(value: string | undefined): value is ProductCounter {
69 return value !== undefined && (PRODUCT_COUNTER_FIELDS as readonly string[]).includes(value);
70 }
71
72 export function UsageCounting({ appVersion }: { appVersion: string }) {
73 const pathname = usePathname();
74
75 useEffect(() => {
76 const current = getRecorder(appVersion);
77 if (!current) return;
78 const onStorage = (event: StorageEvent) => {
79 if (event.key !== null && event.key !== PREFERENCE_STORAGE_KEY) return;
80 current.sync();
81 };
82 const onClick = (event: MouseEvent) => {
83 const target = event.target as HTMLElement | null;
84 const marked = target?.closest<HTMLElement>("[data-usage]");
85 const counter = marked?.dataset.usage;
86 if (isCounter(counter)) current.record(counter);
87 };
88 const onPageHide = () => {
89 void current.flush();
90 };
91 window.addEventListener("storage", onStorage);
92 document.addEventListener("click", onClick);
93 window.addEventListener("pagehide", onPageHide);
94 return () => {
95 window.removeEventListener("storage", onStorage);
96 document.removeEventListener("click", onClick);
97 window.removeEventListener("pagehide", onPageHide);
98 };
99 }, [appVersion]);
100
101 // Every route change is one page view; docs routes are also a docs view.
102 useEffect(() => {
103 const current = getRecorder(appVersion);
104 if (!current || !pathname) return;
105 current.record("page_view");
106 if (isDocsPath(pathname)) current.record("docs_view");
107 }, [appVersion, pathname]);
108
109 return null;
110 }
111
112 /** The privacy page's status line and opt-out control. */
113 export function UsagePreferenceControl({ locale, appVersion }: { locale: string; appVersion: string }) {
114 // `null` until mounted; `"unavailable"` when the browser offers no storage.
115 const [preference, setPreference] = useState<UsagePreference | "unavailable" | null>(null);
116 const t = (text: { en: string; zh: string }) => pickText(text, locale);
117
118 useEffect(() => {
119 const current = getRecorder(appVersion);
120 if (!current) {
121 setPreference("unavailable");
122 return;
123 }
124 setPreference(current.preference());
125 const onStorage = (event: StorageEvent) => {
126 if (event.key !== null && event.key !== PREFERENCE_STORAGE_KEY) return;
127 setPreference(current.preference());
128 };
129 window.addEventListener("storage", onStorage);
130 return () => window.removeEventListener("storage", onStorage);
131 }, [appVersion]);
132
133 const toggle = useCallback(() => {
134 const current = getRecorder(appVersion);
135 if (!current) return;
136 if (current.preference() === "off") current.enable();
137 else current.disable();
138 setPreference(current.preference());
139 }, [appVersion]);
140
141 const off = preference === "off";
142 return (
143 <div className="usage-counting" data-state={preference ?? "loading"}>
144 <p>{t(USAGE_COUNTING_COPY.summary)}</p>
145 <p>{t(USAGE_COUNTING_COPY.choice)}</p>
146 {preference !== null && (
147 <p className="usage-counting-status" role="status">
148 {t(USAGE_COUNTING_COPY.status[preference])}
149 </p>
150 )}
151 {preference !== null && preference !== "unavailable" && (
152 <button type="button" className="usage-counting-button" onClick={toggle} aria-pressed={!off}>
153 {off ? t(USAGE_COUNTING_COPY.turnOn) : t(USAGE_COUNTING_COPY.turnOff)}
154 </button>
155 )}
156 <p className="usage-counting-elsewhere">{t(USAGE_COUNTING_COPY.elsewhere)}</p>
157 </div>
158 );
159 }
160
160 lines Plain Text