@convex-internal/web-analytics 1.1.1 → 2.0.0
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 +51 -31
- package/dist/capture.js +0 -1
- package/dist/consent.d.ts +10 -0
- package/dist/consent.js +22 -0
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +1 -0
- package/dist/index.d.ts +1 -3
- package/dist/index.js +0 -3
- package/dist/posthog.d.ts +4 -0
- package/dist/posthog.js +56 -0
- package/dist/{ConsentBanner.d.ts → react/ConsentBanner.d.ts} +1 -1
- package/dist/{ConsentBanner.js → react/ConsentBanner.js} +4 -3
- package/dist/react/WebAnalyticsProvider.d.ts +11 -0
- package/dist/react/WebAnalyticsProvider.js +24 -0
- package/dist/react/index.d.ts +3 -0
- package/dist/react/index.js +3 -0
- package/dist/{useCookie.js → react/useCookie.js} +1 -1
- package/dist/strings.d.ts +10 -0
- package/dist/strings.js +10 -0
- package/dist/svelte/WebAnalytics.svelte +110 -0
- package/dist/svelte/index.d.ts +1 -0
- package/dist/svelte/index.js +2 -0
- package/package.json +33 -4
- package/dist/WebAnalyticsProvider.d.ts +0 -17
- package/dist/WebAnalyticsProvider.js +0 -94
- /package/dist/{Consented.d.ts → react/Consented.d.ts} +0 -0
- /package/dist/{Consented.js → react/Consented.js} +0 -0
- /package/dist/{useConsent.d.ts → react/useConsent.d.ts} +0 -0
- /package/dist/{useConsent.js → react/useConsent.js} +0 -0
- /package/dist/{useCookie.d.ts → react/useCookie.d.ts} +0 -0
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
|
-
##
|
|
5
|
+
## Core
|
|
10
6
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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
|
|
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
|
|
|
@@ -73,6 +66,38 @@ Theme with `--wa-banner-*` variables on `.wa-banner`:
|
|
|
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
|
-
|
|
129
|
+
## Svelte
|
|
105
130
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
136
|
+
```svelte
|
|
137
|
+
<script>
|
|
138
|
+
import { WebAnalytics } from "@convex-internal/web-analytics/svelte";
|
|
139
|
+
</script>
|
|
118
140
|
|
|
119
|
-
|
|
120
|
-
|
|
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/capture.js
CHANGED
|
@@ -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;
|
package/dist/consent.js
ADDED
|
@@ -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 @@
|
|
|
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
|
@@ -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;
|
package/dist/posthog.js
ADDED
|
@@ -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 "
|
|
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 {
|
|
4
|
+
import { bannerCopy } from "../strings.js";
|
|
5
|
+
import { capture } from "../capture.js";
|
|
5
6
|
import { useConsent } from "./useConsent.js";
|
|
6
|
-
import "
|
|
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":
|
|
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
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { useCallback, useEffect, useState } from "react";
|
|
3
|
-
import { getCookie, setCookie } from "
|
|
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
|
+
};
|
package/dist/strings.js
ADDED
|
@@ -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";
|
package/package.json
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@convex-internal/web-analytics",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
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
|