@convex-internal/web-analytics 1.1.1 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,20 +1,12 @@
1
1
  # @convex-internal/web-analytics
2
2
 
3
3
  Shared consent state, consent banner, and analytics setup for Convex websites.
4
- Wrap the app in `WebAnalyticsProvider`. It renders the consent banner. Wrap
5
- other tracking, such as GA or an ad pixel, in `Consented` so it loads only
6
- after consent is confirmed. Use `useConsent` when you need the consent status
7
- itself.
8
4
 
9
- ## Usage
5
+ ## Core
10
6
 
11
- Install the package, then render `WebAnalyticsProvider` at the root of the
12
- app. It is a client component. `useConsent` throws if called outside that
13
- provider.
14
-
15
- The provider starts analytics in production, except on localhost, syncs
16
- consent to PostHog, and shows the consent banner when the visitor has not
17
- chosen yet.
7
+ Mount a framework entry at the root ([React](#react) or [Svelte](#svelte)).
8
+ Analytics start in production, except on localhost. Consent is synced to
9
+ PostHog. The banner shows when the visitor has not chosen yet.
18
10
 
19
11
  PostHog already copies standard campaign params (UTMs, `gclid`, `rdt_cid`,
20
12
  `fbclid`, and other common click IDs) from the landing URL onto events. This
@@ -24,7 +16,8 @@ person profile. Campaign params still available at that point can be stored as
24
16
 
25
17
  ### `capture`
26
18
 
27
- Record an event from client code. The optional third argument is
19
+ Record an event from client code through the framework-neutral root entry.
20
+ The optional third argument is
28
21
  [PostHog's capture options](https://posthog.com/docs/libraries/js/usage).
29
22
 
30
23
  ```tsx
@@ -38,7 +31,7 @@ capture("www-login", undefined, {
38
31
 
39
32
  ### Consent banner
40
33
 
41
- The provider shows the banner when `consent` is `"undecided"`. It records
34
+ The banner shows when `consent` is `"undecided"`. It records
42
35
  `cookie_consent_shown` when it appears and `cookie_consent_decided` with
43
36
  `{ decision: "accepted" | "declined" }` just before the choice is saved.
44
37
 
@@ -66,13 +59,45 @@ Theme with `--wa-banner-*` variables on `.wa-banner`:
66
59
  - Link and focus: `--wa-banner-link-color`, `--wa-banner-focus-color`
67
60
  - Buttons: `--wa-banner-button-border-radius`, `--wa-banner-button-padding`,
68
61
  `--wa-banner-button-min-height`, `--wa-banner-button-font-size`,
69
- `--wa-banner-button-font-weight`, `--wa-banner-button-line-height`
62
+ `--wa-banner-button-font-weight`
70
63
  - Accept button: `--wa-banner-accept-background-color`, `--wa-banner-accept-text-color`
71
64
  - Decline button: `--wa-banner-decline-background-color`,
72
65
  `--wa-banner-decline-text-color`
73
66
 
74
67
  Buttons default to 80% opacity and go to 100% on hover or keyboard focus.
75
68
 
69
+ ### Cookie behavior
70
+
71
+ Once the cookie has been read, `consent` is resolved in this order:
72
+
73
+ 1. **Global Privacy Control.** If `navigator.globalPrivacyControl === true`,
74
+ `consent` is `"declined"`. GPC is not stored: the cookie is not written or
75
+ changed. Turn GPC off and the stored cookie (if set) applies again.
76
+ 2. **Cookie.** `true` is `"consented"`, `false` is `"declined"`, and a missing
77
+ or unrecognized value is `"undecided"`.
78
+
79
+ The cookie is stored with `path=/` and a max-age of one year. On `convex.dev`
80
+ and `*.convex.dev` it is also set with `domain=.convex.dev` so the website,
81
+ Docs, Stack, and other subdomains share one choice. The cookie is marked
82
+ `Secure` everywhere except localhost.
83
+
84
+ Changes made through `setConsent` update immediately. Changes made
85
+ elsewhere, such as in another tab or DevTools, are synchronized through cookie
86
+ change events when available, and when the window regains focus or its
87
+ visibility changes as a fallback.
88
+
89
+ ## React
90
+
91
+ Import from `@convex-internal/web-analytics/react`.
92
+ `WebAnalyticsProvider` is a client component. `useConsent` throws if called
93
+ outside that provider.
94
+
95
+ ```tsx
96
+ import { WebAnalyticsProvider } from "@convex-internal/web-analytics/react";
97
+
98
+ <WebAnalyticsProvider>{children}</WebAnalyticsProvider>;
99
+ ```
100
+
76
101
  ### `Consented`
77
102
 
78
103
  Renders children only when `consent` is `"consented"`. It renders nothing during
@@ -80,7 +105,7 @@ SSR, while consent is still `"unknown"` or `"undecided"`, if the visitor
80
105
  declined, or while Global Privacy Control is on.
81
106
 
82
107
  ```tsx
83
- import { Consented } from "@convex-internal/web-analytics";
108
+ import { Consented } from "@convex-internal/web-analytics/react";
84
109
 
85
110
  <Consented>
86
111
  <YourAnalyticsScripts />
@@ -101,25 +126,20 @@ import { Consented } from "@convex-internal/web-analytics";
101
126
  Use the hook when you need `consent` or `setConsent` directly. Enable tracking
102
127
  only when `consent === "consented"`.
103
128
 
104
- ### Cookie behavior
129
+ ## Svelte
105
130
 
106
- Once the cookie has been read, `consent` is resolved in this order:
107
-
108
- 1. **Global Privacy Control.** If `navigator.globalPrivacyControl === true`,
109
- `consent` is `"declined"`. GPC is not stored: the cookie is not written or
110
- changed. Turn GPC off and the stored cookie (if set) applies again.
111
- 2. **Cookie.** `true` is `"consented"`, `false` is `"declined"`, and a missing
112
- or unrecognized value is `"undecided"`.
131
+ The Svelte entry exports only `WebAnalytics`. Render it once in the root
132
+ layout. It has no props and no snippet: it starts PostHog and shows the banner.
133
+ There is no `Consented` wrapper or consent store. Those can be added later;
134
+ nothing using the Svelte entry has needed them yet.
113
135
 
114
- The cookie is stored with `path=/` and a max-age of one year. On `convex.dev`
115
- and `*.convex.dev` it is also set with `domain=.convex.dev` so the website,
116
- Docs, Stack, and other subdomains share one choice. The cookie is marked
117
- `Secure` everywhere except localhost.
136
+ ```svelte
137
+ <script>
138
+ import { WebAnalytics } from "@convex-internal/web-analytics/svelte";
139
+ </script>
118
140
 
119
- Changes made through `setConsent` update immediately. Changes made
120
- elsewhere, such as in another tab or DevTools, are synchronized through cookie
121
- change events when available, and when the window regains focus or its
122
- visibility changes as a fallback.
141
+ <WebAnalytics />
142
+ ```
123
143
 
124
144
  ## Test in-development changes
125
145
 
package/dist/banner.css CHANGED
@@ -14,6 +14,7 @@
14
14
  color: var(--wa-banner-text-color, black);
15
15
  font-family: var(--wa-banner-font-family, inherit);
16
16
  font-size: var(--wa-banner-font-size, 1rem);
17
+ line-height: 1.25;
17
18
 
18
19
  .wa-banner-text {
19
20
  margin: 0 0 0.5rem;
@@ -44,7 +45,7 @@
44
45
  font-family: inherit;
45
46
  font-size: var(--wa-banner-button-font-size, inherit);
46
47
  font-weight: var(--wa-banner-button-font-weight, inherit);
47
- line-height: var(--wa-banner-button-line-height, inherit);
48
+ line-height: 1;
48
49
  cursor: pointer;
49
50
  opacity: 0.8;
50
51
  transition: opacity 0.15s;
package/dist/capture.js CHANGED
@@ -1,4 +1,3 @@
1
- "use client";
2
1
  import { posthog } from "posthog-js";
3
2
  export function capture(event, properties, options) {
4
3
  posthog.capture(event, properties, options);
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `"unknown"` until the cookie has been read (SSR and first client render).
3
+ * `"undecided"` when it has been read and there is no recognized choice.
4
+ * `"consented"` when the cookie is `true` and Global Privacy Control is off.
5
+ * `"declined"` when the cookie is `false`, or while GPC is on.
6
+ */
7
+ export type ConsentStatus = "unknown" | "undecided" | "consented" | "declined";
8
+ export declare function resolveConsent(isReady: boolean, cookieValue: boolean | undefined, gpcOptOut: boolean): ConsentStatus;
9
+ export declare const COOKIE_NAME = "allowsCookies";
10
+ export declare function hasGPC(): boolean;
@@ -0,0 +1,22 @@
1
+ // GPC wins over the stored cookie and does not write it, so turning GPC off
2
+ // restores the saved choice (or the banner if there is none). A missing or
3
+ // unrecognized cookie after the read defaults to `"undecided"`.
4
+ export function resolveConsent(isReady, cookieValue, gpcOptOut) {
5
+ if (!isReady) {
6
+ return "unknown";
7
+ }
8
+ if (gpcOptOut) {
9
+ return "declined";
10
+ }
11
+ if (cookieValue === true) {
12
+ return "consented";
13
+ }
14
+ if (cookieValue === false) {
15
+ return "declined";
16
+ }
17
+ return "undecided";
18
+ }
19
+ export const COOKIE_NAME = "allowsCookies";
20
+ export function hasGPC() {
21
+ return (typeof navigator !== "undefined" && navigator.globalPrivacyControl === true);
22
+ }
@@ -0,0 +1,2 @@
1
+ export { capture, type CaptureOptions } from "../capture.js";
2
+ export type { ConsentStatus } from "../consent.js";
@@ -0,0 +1 @@
1
+ export { capture } from "../capture.js";
package/dist/index.d.ts CHANGED
@@ -1,4 +1,2 @@
1
- export { WebAnalyticsProvider, type WebAnalyticsProviderProps, type ConsentStatus, } from "./WebAnalyticsProvider.js";
2
- export { Consented, type ConsentedProps } from "./Consented.js";
3
- export { useConsent } from "./useConsent.js";
4
1
  export { capture, type CaptureOptions } from "./capture.js";
2
+ export type { ConsentStatus } from "./consent.js";
package/dist/index.js CHANGED
@@ -1,4 +1 @@
1
- export { WebAnalyticsProvider, } from "./WebAnalyticsProvider.js";
2
- export { Consented } from "./Consented.js";
3
- export { useConsent } from "./useConsent.js";
4
1
  export { capture } from "./capture.js";
@@ -0,0 +1,4 @@
1
+ import type { ConsentStatus } from "./consent.js";
2
+ export declare const POSTHOG_PUBLIC_PROJECT_TOKEN = "phc_JDNTRxeh2li2sQTRO0IcOYMJcp8fPs5nTK9TU751nQK";
3
+ export declare function initPostHog(): void;
4
+ export declare function syncPostHogConsent(consent: ConsentStatus): void;
@@ -0,0 +1,56 @@
1
+ import { posthog } from "posthog-js";
2
+ // PostHog project token for event ingest and feature flags. `phc_` tokens are
3
+ // public by design (browser JS, no env var) and cannot read private project data.
4
+ // https://posthog.com/docs/privacy#is-it-ok-for-my-api-key-to-be-exposed-and-public
5
+ export const POSTHOG_PUBLIC_PROJECT_TOKEN = "phc_JDNTRxeh2li2sQTRO0IcOYMJcp8fPs5nTK9TU751nQK";
6
+ const POSTHOG_HOST = "https://ap.convex.dev";
7
+ export function initPostHog() {
8
+ if (posthog.__loaded) {
9
+ return;
10
+ }
11
+ // Keep local/dev sessions out of production PostHog data.
12
+ if (process.env.NODE_ENV !== "production" ||
13
+ window.location.hostname === "localhost" ||
14
+ window.location.hostname === "127.0.0.1" ||
15
+ window.location.hostname === "[::1]") {
16
+ return;
17
+ }
18
+ posthog.init(POSTHOG_PUBLIC_PROJECT_TOKEN, {
19
+ api_host: POSTHOG_HOST,
20
+ ui_host: "https://us.posthog.com/",
21
+ // Automatically capture navigations using the History API.
22
+ capture_pageview: "history_change",
23
+ // Together, these two settings capture cookieless events before the user
24
+ // chooses and continue doing so if they decline. Accepting enables persistent
25
+ // tracking. See more: https://posthog.com/tutorials/cookieless-tracking
26
+ opt_out_capturing_by_default: true,
27
+ cookieless_mode: "on_reject",
28
+ // Extends PostHog's built-in campaign params (gclid, rdt_cid, UTMs, etc.)
29
+ // onto events.
30
+ custom_campaign_params: ["dub_id"],
31
+ // Person profiles are enabled later, after consent is received.
32
+ person_profiles: "identified_only",
33
+ });
34
+ }
35
+ export function syncPostHogConsent(consent) {
36
+ if (!posthog.__loaded) {
37
+ return;
38
+ }
39
+ // Skip until there's a real choice made.
40
+ if (consent === "unknown" || consent === "undecided") {
41
+ return;
42
+ }
43
+ // Skip opt-in/out when already in sync so returning visitors don't get a
44
+ // duplicate `$opt_in`.
45
+ if (consent === "consented") {
46
+ if (posthog.get_explicit_consent_status() !== "granted") {
47
+ posthog.opt_in_capturing();
48
+ }
49
+ // Opting in replaces the cookieless identity with a persistent one, so we
50
+ // create the profile afterward. This is a no-op if it already exists.
51
+ posthog.createPersonProfile();
52
+ }
53
+ else if (posthog.get_explicit_consent_status() !== "denied") {
54
+ posthog.opt_out_capturing();
55
+ }
56
+ }
@@ -1,2 +1,2 @@
1
- import "./banner.css";
1
+ import "../banner.css";
2
2
  export declare function ConsentBanner(): import("react").JSX.Element | null;
@@ -1,9 +1,10 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useEffect, useRef } from "react";
4
- import { capture } from "./capture.js";
4
+ import { bannerCopy } from "../strings.js";
5
+ import { capture } from "../capture.js";
5
6
  import { useConsent } from "./useConsent.js";
6
- import "./banner.css";
7
+ import "../banner.css";
7
8
  export function ConsentBanner() {
8
9
  const { consent, setConsent } = useConsent();
9
10
  const shouldShow = consent === "undecided";
@@ -27,5 +28,5 @@ export function ConsentBanner() {
27
28
  setConsent(decision === "accepted" ? "consented" : "declined");
28
29
  }
29
30
  };
30
- return (_jsxs("aside", { "aria-label": "Privacy choices", className: "wa-banner", children: [_jsx("p", { className: "wa-banner-text", children: "We use third-party cookies to understand how people interact with our site." }), _jsxs("p", { className: "wa-banner-text wa-banner-policy", children: ["See our", " ", _jsx("a", { className: "wa-banner-link", href: "https://www.convex.dev/legal/privacy", rel: "noopener noreferrer", target: "_blank", children: "Privacy Policy" }), " ", "to learn more."] }), _jsxs("div", { className: "wa-banner-actions", children: [_jsx("button", { className: "wa-banner-button wa-banner-decline", onClick: () => decide("declined"), type: "button", children: "Decline" }), _jsx("button", { className: "wa-banner-button wa-banner-accept", onClick: () => decide("accepted"), type: "button", children: "Accept" })] })] }));
31
+ return (_jsxs("aside", { "aria-label": bannerCopy.ariaLabel, className: "wa-banner", children: [_jsx("p", { className: "wa-banner-text", children: bannerCopy.body }), _jsxs("p", { className: "wa-banner-text wa-banner-policy", children: [bannerCopy.policyBeforeLink, " ", _jsx("a", { className: "wa-banner-link", href: bannerCopy.privacyPolicyHref, rel: "noopener noreferrer", target: "_blank", children: bannerCopy.policyLinkText }), " ", bannerCopy.policyAfterLink] }), _jsxs("div", { className: "wa-banner-actions", children: [_jsx("button", { className: "wa-banner-button wa-banner-decline", onClick: () => decide("declined"), type: "button", children: bannerCopy.declineLabel }), _jsx("button", { className: "wa-banner-button wa-banner-accept", onClick: () => decide("accepted"), type: "button", children: bannerCopy.acceptLabel })] })] }));
31
32
  }
@@ -0,0 +1,11 @@
1
+ import { type ReactNode } from "react";
2
+ import { type ConsentStatus } from "../consent.js";
3
+ export type { ConsentStatus };
4
+ export interface WebAnalyticsProviderProps {
5
+ children: ReactNode;
6
+ }
7
+ export declare const ConsentContext: import("react").Context<{
8
+ consent: ConsentStatus;
9
+ setConsent: (status: "consented" | "declined") => void;
10
+ } | null>;
11
+ export declare function WebAnalyticsProvider({ children }: WebAnalyticsProviderProps): import("react").JSX.Element;
@@ -0,0 +1,24 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { createContext, useEffect } from "react";
4
+ import { COOKIE_NAME, hasGPC, resolveConsent, } from "../consent.js";
5
+ import { initPostHog, syncPostHogConsent } from "../posthog.js";
6
+ import { ConsentBanner } from "./ConsentBanner.js";
7
+ import { useBooleanCookie } from "./useCookie.js";
8
+ export const ConsentContext = createContext(null);
9
+ export function WebAnalyticsProvider({ children }) {
10
+ const { value, isReady, setValue } = useBooleanCookie(COOKIE_NAME);
11
+ const consent = resolveConsent(isReady, value, hasGPC());
12
+ useEffect(() => {
13
+ initPostHog();
14
+ }, []);
15
+ useEffect(() => {
16
+ syncPostHogConsent(consent);
17
+ }, [consent]);
18
+ return (_jsxs(ConsentContext.Provider, { value: {
19
+ consent,
20
+ setConsent: (status) => {
21
+ setValue(status === "consented" ? "true" : "false");
22
+ },
23
+ }, children: [children, _jsx(ConsentBanner, {})] }));
24
+ }
@@ -0,0 +1,3 @@
1
+ export { WebAnalyticsProvider, type WebAnalyticsProviderProps, } from "./WebAnalyticsProvider.js";
2
+ export { Consented, type ConsentedProps } from "./Consented.js";
3
+ export { useConsent } from "./useConsent.js";
@@ -0,0 +1,3 @@
1
+ export { WebAnalyticsProvider, } from "./WebAnalyticsProvider.js";
2
+ export { Consented } from "./Consented.js";
3
+ export { useConsent } from "./useConsent.js";
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { useCallback, useEffect, useState } from "react";
3
- import { getCookie, setCookie } from "./cookie.js";
3
+ import { getCookie, setCookie } from "../cookie.js";
4
4
  export function useCookie(name) {
5
5
  const [value, setValueState] = useState(undefined);
6
6
  const [isReady, setIsReady] = useState(false);
@@ -0,0 +1,10 @@
1
+ export declare const bannerCopy: {
2
+ readonly ariaLabel: "Privacy choices";
3
+ readonly body: "We use third-party cookies to understand how people interact with our site.";
4
+ readonly policyBeforeLink: "See our";
5
+ readonly policyLinkText: "Privacy Policy";
6
+ readonly policyAfterLink: "to learn more.";
7
+ readonly privacyPolicyHref: "https://www.convex.dev/legal/privacy";
8
+ readonly declineLabel: "Decline";
9
+ readonly acceptLabel: "Accept";
10
+ };
@@ -0,0 +1,10 @@
1
+ export const bannerCopy = {
2
+ ariaLabel: "Privacy choices",
3
+ body: "We use third-party cookies to understand how people interact with our site.",
4
+ policyBeforeLink: "See our",
5
+ policyLinkText: "Privacy Policy",
6
+ policyAfterLink: "to learn more.",
7
+ privacyPolicyHref: "https://www.convex.dev/legal/privacy",
8
+ declineLabel: "Decline",
9
+ acceptLabel: "Accept",
10
+ };
@@ -0,0 +1,110 @@
1
+ <script lang="ts">
2
+ import { onMount } from "svelte";
3
+ import { capture } from "../capture.js";
4
+ import { COOKIE_NAME, hasGPC, resolveConsent } from "../consent.js";
5
+ import { getCookie, setCookie } from "../cookie.js";
6
+ import { initPostHog, syncPostHogConsent } from "../posthog.js";
7
+ import { bannerCopy } from "../strings.js";
8
+ import "../banner.css";
9
+
10
+ let isReady = $state(false);
11
+ let cookieValue = $state<boolean | undefined>(undefined);
12
+
13
+ const consent = $derived(resolveConsent(isReady, cookieValue, hasGPC()));
14
+ const shouldShowBanner = $derived(consent === "undecided");
15
+
16
+ function readCookieValue(): boolean | undefined {
17
+ const raw = getCookie(COOKIE_NAME);
18
+ return raw === "true" ? true : raw === "false" ? false : undefined;
19
+ }
20
+
21
+ function syncCookie() {
22
+ cookieValue = readCookieValue();
23
+ isReady = true;
24
+ }
25
+
26
+ onMount(() => {
27
+ initPostHog();
28
+ syncCookie();
29
+
30
+ const handleCookieChange = (event: CookieChangeEvent) => {
31
+ const changed = [...event.changed, ...event.deleted].some(
32
+ (cookie) => cookie.name === COOKIE_NAME
33
+ );
34
+ if (changed) {
35
+ syncCookie();
36
+ }
37
+ };
38
+
39
+ window.addEventListener("focus", syncCookie);
40
+ document.addEventListener("visibilitychange", syncCookie);
41
+ window.cookieStore?.addEventListener("change", handleCookieChange);
42
+
43
+ return () => {
44
+ window.removeEventListener("focus", syncCookie);
45
+ document.removeEventListener("visibilitychange", syncCookie);
46
+ window.cookieStore?.removeEventListener("change", handleCookieChange);
47
+ };
48
+ });
49
+
50
+ $effect(() => {
51
+ syncPostHogConsent(consent);
52
+ });
53
+
54
+ let wasShowingBanner = false;
55
+ $effect(() => {
56
+ if (shouldShowBanner && !wasShowingBanner) {
57
+ // Captures once when the banner becomes visible, not on later renders.
58
+ capture("cookie_consent_shown");
59
+ }
60
+ wasShowingBanner = shouldShowBanner;
61
+ });
62
+
63
+ function setConsent(status: "consented" | "declined") {
64
+ setCookie(COOKIE_NAME, status === "consented" ? "true" : "false");
65
+ cookieValue = status === "consented";
66
+ }
67
+
68
+ function decide(decision: "accepted" | "declined") {
69
+ try {
70
+ // Captures before the cookie write so decline is captured cookieless.
71
+ capture("cookie_consent_decided", { decision });
72
+ } finally {
73
+ setConsent(decision === "accepted" ? "consented" : "declined");
74
+ }
75
+ }
76
+ </script>
77
+
78
+ {#if shouldShowBanner}
79
+ <aside aria-label={bannerCopy.ariaLabel} class="wa-banner">
80
+ <p class="wa-banner-text">{bannerCopy.body}</p>
81
+ <p class="wa-banner-text wa-banner-policy">
82
+ {bannerCopy.policyBeforeLink}{" "}
83
+ <a
84
+ class="wa-banner-link"
85
+ href={bannerCopy.privacyPolicyHref}
86
+ rel="noopener noreferrer"
87
+ target="_blank"
88
+ >
89
+ {bannerCopy.policyLinkText}
90
+ </a>{" "}
91
+ {bannerCopy.policyAfterLink}
92
+ </p>
93
+ <div class="wa-banner-actions">
94
+ <button
95
+ class="wa-banner-button wa-banner-decline"
96
+ onclick={() => decide("declined")}
97
+ type="button"
98
+ >
99
+ {bannerCopy.declineLabel}
100
+ </button>
101
+ <button
102
+ class="wa-banner-button wa-banner-accept"
103
+ onclick={() => decide("accepted")}
104
+ type="button"
105
+ >
106
+ {bannerCopy.acceptLabel}
107
+ </button>
108
+ </div>
109
+ </aside>
110
+ {/if}
@@ -0,0 +1 @@
1
+ export { default as WebAnalytics } from "./WebAnalytics.svelte";
@@ -0,0 +1,2 @@
1
+ /// <reference types="svelte" />
2
+ export { default as WebAnalytics } from "./WebAnalytics.svelte";
package/package.json CHANGED
@@ -1,12 +1,21 @@
1
1
  {
2
2
  "name": "@convex-internal/web-analytics",
3
- "version": "1.1.1",
3
+ "version": "2.0.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
7
7
  "types": "./dist/index.d.ts",
8
8
  "default": "./dist/index.js"
9
9
  },
10
+ "./react": {
11
+ "types": "./dist/react/index.d.ts",
12
+ "default": "./dist/react/index.js"
13
+ },
14
+ "./svelte": {
15
+ "types": "./dist/svelte/index.d.ts",
16
+ "svelte": "./dist/svelte/index.js",
17
+ "default": "./dist/svelte/index.js"
18
+ },
10
19
  "./banner.css": "./dist/banner.css"
11
20
  },
12
21
  "files": [
@@ -16,9 +25,13 @@
16
25
  "access": "public"
17
26
  },
18
27
  "scripts": {
19
- "build": "tsc && cp src/banner.css dist/banner.css",
28
+ "build": "npm run check:svelte && tsc && cp src/banner.css dist/banner.css && cp src/svelte/WebAnalytics.svelte dist/svelte/WebAnalytics.svelte",
29
+ "check:svelte": "svelte-check --tsconfig ./src/svelte/tsconfig.json",
20
30
  "prepack": "npm run build",
21
- "test": "vitest run"
31
+ "test": "vitest run",
32
+ "test:coverage": "vitest run --coverage",
33
+ "dev:apps:react": "npm run build && vite --config test-apps/react/vite.config.ts",
34
+ "dev:apps:svelte": "npm run build && vite --config test-apps/svelte/vite.config.ts"
22
35
  },
23
36
  "sideEffects": [
24
37
  "**/*.css"
@@ -27,17 +40,33 @@
27
40
  "posthog-js": "^1.211.0"
28
41
  },
29
42
  "peerDependencies": {
30
- "react": "^18.0.0 || ^19.0.0"
43
+ "react": "^18.0.0 || ^19.0.0",
44
+ "svelte": "^5.0.0"
45
+ },
46
+ "peerDependenciesMeta": {
47
+ "react": {
48
+ "optional": true
49
+ },
50
+ "svelte": {
51
+ "optional": true
52
+ }
31
53
  },
32
54
  "devDependencies": {
55
+ "@sveltejs/vite-plugin-svelte": "^7.1.2",
33
56
  "@testing-library/react": "^16.3.2",
57
+ "@testing-library/svelte": "^5.3.1",
34
58
  "@types/node": "^26.2.0",
35
59
  "@types/react": "^19.2.18",
36
60
  "@types/react-dom": "^19.2.4",
61
+ "@vitejs/plugin-react": "^6.0.5",
62
+ "@vitest/coverage-v8": "^4.1.10",
37
63
  "jsdom": "^30.0.1",
38
64
  "react": "^19.2.4",
39
65
  "react-dom": "^19.2.8",
66
+ "svelte": "^5.56.4",
67
+ "svelte-check": "^4.7.6",
40
68
  "typescript": "^5.9.2",
69
+ "vite": "^8.2.1",
41
70
  "vitest": "^4.1.10"
42
71
  }
43
72
  }
@@ -1,17 +0,0 @@
1
- import { type ReactNode } from "react";
2
- export declare const POSTHOG_PUBLIC_PROJECT_TOKEN = "phc_JDNTRxeh2li2sQTRO0IcOYMJcp8fPs5nTK9TU751nQK";
3
- /**
4
- * `"unknown"` until the cookie has been read (SSR and first client render).
5
- * `"undecided"` when it has been read and there is no recognized choice.
6
- * `"consented"` when the cookie is `true` and Global Privacy Control is off.
7
- * `"declined"` when the cookie is `false`, or while GPC is on.
8
- */
9
- export type ConsentStatus = "unknown" | "undecided" | "consented" | "declined";
10
- export interface WebAnalyticsProviderProps {
11
- children: ReactNode;
12
- }
13
- export declare const ConsentContext: import("react").Context<{
14
- consent: ConsentStatus;
15
- setConsent: (status: "consented" | "declined") => void;
16
- } | null>;
17
- export declare function WebAnalyticsProvider({ children }: WebAnalyticsProviderProps): import("react").JSX.Element;
@@ -1,94 +0,0 @@
1
- "use client";
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { posthog } from "posthog-js";
4
- import { createContext, useEffect } from "react";
5
- import { ConsentBanner } from "./ConsentBanner.js";
6
- import { useBooleanCookie } from "./useCookie.js";
7
- // PostHog project token for event ingest and feature flags. `phc_` tokens are
8
- // public by design (browser JS, no env var) and cannot read private project data.
9
- // https://posthog.com/docs/privacy#is-it-ok-for-my-api-key-to-be-exposed-and-public
10
- export const POSTHOG_PUBLIC_PROJECT_TOKEN = "phc_JDNTRxeh2li2sQTRO0IcOYMJcp8fPs5nTK9TU751nQK";
11
- const POSTHOG_HOST = "https://ap.convex.dev";
12
- const COOKIE_NAME = "allowsCookies";
13
- // GPC wins over the stored cookie and does not write it, so turning GPC off
14
- // restores the saved choice (or the banner if there is none). A missing or
15
- // unrecognized cookie after the read defaults to `"undecided"`.
16
- function resolveConsent(isReady, cookieValue, gpcOptOut) {
17
- if (!isReady) {
18
- return "unknown";
19
- }
20
- if (gpcOptOut) {
21
- return "declined";
22
- }
23
- if (cookieValue === true) {
24
- return "consented";
25
- }
26
- if (cookieValue === false) {
27
- return "declined";
28
- }
29
- return "undecided";
30
- }
31
- export const ConsentContext = createContext(null);
32
- export function WebAnalyticsProvider({ children }) {
33
- const { value, isReady, setValue } = useBooleanCookie(COOKIE_NAME);
34
- const gpcOptOut = typeof navigator !== "undefined" && navigator.globalPrivacyControl === true;
35
- const consent = resolveConsent(isReady, value, gpcOptOut);
36
- // Initialize PostHog once, in cookieless mode. Consent is applied below.
37
- useEffect(() => {
38
- if (posthog.__loaded) {
39
- return;
40
- }
41
- // Keep local/dev sessions out of production PostHog data.
42
- if (process.env.NODE_ENV !== "production" ||
43
- window.location.hostname === "localhost" ||
44
- window.location.hostname === "127.0.0.1" ||
45
- window.location.hostname === "[::1]") {
46
- return;
47
- }
48
- posthog.init(POSTHOG_PUBLIC_PROJECT_TOKEN, {
49
- api_host: POSTHOG_HOST,
50
- ui_host: "https://us.posthog.com/",
51
- // Automatically capture navigations using the History API.
52
- capture_pageview: "history_change",
53
- // Together, these two settings capture cookieless events before the user
54
- // chooses and continue doing so if they decline. Accepting enables persistent
55
- // tracking. See more: https://posthog.com/tutorials/cookieless-tracking
56
- opt_out_capturing_by_default: true,
57
- cookieless_mode: "on_reject",
58
- // Extends PostHog's built-in campaign params (gclid, rdt_cid, UTMs, etc.)
59
- // onto events.
60
- custom_campaign_params: ["dub_id"],
61
- // Person profiles are enabled later, after consent is received.
62
- person_profiles: "identified_only",
63
- });
64
- }, []);
65
- // Keep PostHog capturing in sync with consent.
66
- useEffect(() => {
67
- if (!posthog.__loaded) {
68
- return;
69
- }
70
- // Skip until there's a real choice made.
71
- if (consent === "unknown" || consent === "undecided") {
72
- return;
73
- }
74
- // Skip opt-in/out when already in sync so returning visitors don't get a
75
- // duplicate `$opt_in`.
76
- if (consent === "consented") {
77
- if (posthog.get_explicit_consent_status() !== "granted") {
78
- posthog.opt_in_capturing();
79
- }
80
- // Opting in replaces the cookieless identity with a persistent one, so we
81
- // create the profile afterward. This is a no-op if it already exists.
82
- posthog.createPersonProfile();
83
- }
84
- else if (posthog.get_explicit_consent_status() !== "denied") {
85
- posthog.opt_out_capturing();
86
- }
87
- }, [consent]);
88
- return (_jsxs(ConsentContext.Provider, { value: {
89
- consent,
90
- setConsent: (status) => {
91
- setValue(status === "consented" ? "true" : "false");
92
- },
93
- }, children: [children, _jsx(ConsentBanner, {})] }));
94
- }
File without changes
File without changes
File without changes
File without changes
File without changes