| 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 |