@convex-internal/web-analytics 1.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 +197 -0
- package/dist/ConsentBanner.d.ts +2 -0
- package/dist/ConsentBanner.js +31 -0
- package/dist/Consented.d.ts +5 -0
- package/dist/Consented.js +9 -0
- package/dist/WebAnalyticsProvider.d.ts +22 -0
- package/dist/WebAnalyticsProvider.js +88 -0
- package/dist/banner.css +82 -0
- package/dist/capture.d.ts +3 -0
- package/dist/capture.js +5 -0
- package/dist/cookie.d.ts +2 -0
- package/dist/cookie.js +23 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/useConsent.d.ts +4 -0
- package/dist/useConsent.js +10 -0
- package/dist/useCookie.d.ts +10 -0
- package/dist/useCookie.js +38 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# @convex-internal/web-analytics
|
|
2
|
+
|
|
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
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
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. Pass `posthogConfig` for site-specific options, such as the
|
|
18
|
+
website's `dub_id` campaign param:
|
|
19
|
+
|
|
20
|
+
```tsx
|
|
21
|
+
<WebAnalyticsProvider posthogConfig={{ custom_campaign_params: ["dub_id"] }}>
|
|
22
|
+
{children}
|
|
23
|
+
</WebAnalyticsProvider>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### `capture`
|
|
27
|
+
|
|
28
|
+
Record an event from client code. The optional third argument is
|
|
29
|
+
[PostHog's capture options](https://posthog.com/docs/libraries/js/usage).
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
import { capture } from "@convex-internal/web-analytics";
|
|
33
|
+
|
|
34
|
+
capture("www-login", undefined, {
|
|
35
|
+
transport: "sendBeacon",
|
|
36
|
+
send_instantly: true,
|
|
37
|
+
});
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Consent banner
|
|
41
|
+
|
|
42
|
+
The provider shows the banner when `consent` is `"undecided"`. It records
|
|
43
|
+
`cookie_consent_shown` when it appears and `cookie_consent_decided` with
|
|
44
|
+
`{ decision: "accepted" | "declined" }` just before the choice is saved.
|
|
45
|
+
|
|
46
|
+
The banner stays hidden while Global Privacy Control is on, because that
|
|
47
|
+
resolves `consent` to `"declined"` rather than `"undecided"`. GPC does not
|
|
48
|
+
write the cookie; see [Cookie behavior](#cookie-behavior).
|
|
49
|
+
|
|
50
|
+
Theme with `--wa-banner-*` variables on `.wa-banner`:
|
|
51
|
+
|
|
52
|
+
```css
|
|
53
|
+
.wa-banner {
|
|
54
|
+
--wa-banner-background-color: var(--color-seashell);
|
|
55
|
+
--wa-banner-font-family: var(--font-primary);
|
|
56
|
+
--wa-banner-accept-background-color: var(--color-plum-p4);
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
- Background, text, and border: `--wa-banner-background-color`,
|
|
61
|
+
`--wa-banner-text-color`, `--wa-banner-border-color`,
|
|
62
|
+
`--wa-banner-border-radius`, `--wa-banner-box-shadow`,
|
|
63
|
+
`--wa-banner-backdrop-filter`
|
|
64
|
+
- Type and layout: `--wa-banner-font-family`, `--wa-banner-font-size`,
|
|
65
|
+
`--wa-banner-padding`, `--wa-banner-max-width`, `--wa-banner-action-gap`,
|
|
66
|
+
`--wa-banner-z-index`
|
|
67
|
+
- Link and focus: `--wa-banner-link-color`, `--wa-banner-focus-color`
|
|
68
|
+
- Buttons: `--wa-banner-button-border-radius`, `--wa-banner-button-padding`,
|
|
69
|
+
`--wa-banner-button-min-height`, `--wa-banner-button-font-size`,
|
|
70
|
+
`--wa-banner-button-font-weight`, `--wa-banner-button-line-height`
|
|
71
|
+
- Accept button: `--wa-banner-accept-background-color`, `--wa-banner-accept-text-color`,
|
|
72
|
+
`--wa-banner-accept-hover-background-color`
|
|
73
|
+
- Decline button: `--wa-banner-decline-background-color`,
|
|
74
|
+
`--wa-banner-decline-text-color`, `--wa-banner-decline-hover-background-color`
|
|
75
|
+
|
|
76
|
+
### `Consented`
|
|
77
|
+
|
|
78
|
+
Renders children only when `consent` is `"consented"`. It renders nothing during
|
|
79
|
+
SSR, while consent is still `"unknown"` or `"undecided"`, if the visitor
|
|
80
|
+
declined, or while Global Privacy Control is on.
|
|
81
|
+
|
|
82
|
+
```tsx
|
|
83
|
+
import { Consented } from "@convex-internal/web-analytics";
|
|
84
|
+
|
|
85
|
+
<Consented>
|
|
86
|
+
<YourAnalyticsScripts />
|
|
87
|
+
</Consented>;
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### `useConsent`
|
|
91
|
+
|
|
92
|
+
- `consent` (`ConsentStatus`)
|
|
93
|
+
- `"unknown"` until cookie has been read (SSR and first client render)
|
|
94
|
+
- `"undecided"` after that if there is no recognized choice yet
|
|
95
|
+
- `"consented"` if the cookie is `true` and GPC is not set
|
|
96
|
+
- `"declined"` if the cookie is `false` or GPC is set
|
|
97
|
+
- `setConsent("consented" | "declined")`
|
|
98
|
+
- Saves the choice to the `allowsCookies` cookie and updates context
|
|
99
|
+
- Do not write the cookie yourself
|
|
100
|
+
|
|
101
|
+
Use the hook when you need `consent` or `setConsent` directly. Enable tracking
|
|
102
|
+
only when `consent === "consented"`.
|
|
103
|
+
|
|
104
|
+
### Cookie behavior
|
|
105
|
+
|
|
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"`.
|
|
113
|
+
|
|
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.
|
|
118
|
+
|
|
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.
|
|
123
|
+
|
|
124
|
+
## Test in-development changes
|
|
125
|
+
|
|
126
|
+
1. In the `web-analytics` repo, build and pack the package:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
cd /path/to/web-analytics
|
|
130
|
+
npm run build
|
|
131
|
+
npm pack
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
This creates a tarball such as `convex-internal-web-analytics-1.0.0.tgz`.
|
|
135
|
+
|
|
136
|
+
1. Install that tarball in the consuming app:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
cd /path/to/consuming-app
|
|
140
|
+
npm install /path/to/web-analytics/convex-internal-web-analytics-1.0.0.tgz --force
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`--force` ensures npm refreshes the package when the filename and version have
|
|
144
|
+
not changed.
|
|
145
|
+
|
|
146
|
+
## Publish a version to npm
|
|
147
|
+
|
|
148
|
+
1. Confirm that you are signed in to the npm account with access to the
|
|
149
|
+
`@convex-internal` organization:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
npm whoami
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Run `npm login` first if needed.
|
|
156
|
+
|
|
157
|
+
1. Commit any README or code changes, then bump the version. `npm version`
|
|
158
|
+
requires a clean Git worktree and creates a version commit and tag.
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
npm version major # breaking, or 0.x → 1.0.0
|
|
162
|
+
npm version minor # new backwards-compatible functionality
|
|
163
|
+
npm version patch # bug fixes
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
1. Run tests, inspect the package contents, then publish. `.npmrc` sets
|
|
167
|
+
`ignore-scripts=true`, so `prepack` will not run — build first:
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
npm test
|
|
171
|
+
npm run build
|
|
172
|
+
npm pack --dry-run
|
|
173
|
+
npm publish --access public
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
If npm 2FA is enabled, add `--otp=<code>`.
|
|
177
|
+
|
|
178
|
+
## Consume a published version
|
|
179
|
+
|
|
180
|
+
Convex repos set `min-release-age=7` in `.npmrc`, so a version published in
|
|
181
|
+
the last 7 days will fail to install (including `npm ci` in CI). Override
|
|
182
|
+
the window for the install, then exclude this package so later installs
|
|
183
|
+
succeed:
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
npm install @convex-internal/web-analytics@<version> --min-release-age=0
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
```
|
|
190
|
+
# consuming-app/.npmrc
|
|
191
|
+
min-release-age-exclude[]=@convex-internal/web-analytics
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
You can drop the exclude once this version has been on npm for 7 days.
|
|
195
|
+
|
|
196
|
+
Use the local tarball workflow whenever you need to test changes that have
|
|
197
|
+
not been published yet.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
import { capture } from "./capture.js";
|
|
5
|
+
import { useConsent } from "./useConsent.js";
|
|
6
|
+
import "./banner.css";
|
|
7
|
+
export function ConsentBanner() {
|
|
8
|
+
const { consent, setConsent } = useConsent();
|
|
9
|
+
const shouldShow = consent === "undecided";
|
|
10
|
+
const wasShowing = useRef(false);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
if (shouldShow && !wasShowing.current) {
|
|
13
|
+
// Captures once when the banner becomes visible, not on later renders.
|
|
14
|
+
capture("cookie_consent_shown");
|
|
15
|
+
}
|
|
16
|
+
wasShowing.current = shouldShow;
|
|
17
|
+
}, [shouldShow]);
|
|
18
|
+
if (!shouldShow) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const decide = (decision) => {
|
|
22
|
+
try {
|
|
23
|
+
// Captures before the cookie write so decline is captured cookieless.
|
|
24
|
+
capture("cookie_consent_decided", { decision });
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
setConsent(decision === "accepted" ? "consented" : "declined");
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
return (_jsxs("aside", { "aria-label": "Privacy choices", className: "wa-banner", children: [_jsx("p", { className: "wa-banner-text", children: "We use cookies and similar technologies 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
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type PostHogConfig } from "posthog-js";
|
|
2
|
+
import { type ReactNode } from "react";
|
|
3
|
+
export type { PostHogConfig };
|
|
4
|
+
/**
|
|
5
|
+
* `"unknown"` until the cookie has been read on the client (SSR and first
|
|
6
|
+
* render). `"undecided"` when there is no recognized choice yet. `"consented"`
|
|
7
|
+
* when the cookie is `true` and Global Privacy Control (GPC) is not set.
|
|
8
|
+
* `"declined"` when the cookie is `false`, or while GPC is set.
|
|
9
|
+
*
|
|
10
|
+
* GPC does not write or change the cookie. If it is later turned off, the
|
|
11
|
+
* stored cookie (or the banner, if none) applies again.
|
|
12
|
+
*/
|
|
13
|
+
export type ConsentStatus = "unknown" | "undecided" | "consented" | "declined";
|
|
14
|
+
export interface WebAnalyticsProviderProps {
|
|
15
|
+
children: ReactNode;
|
|
16
|
+
posthogConfig?: Partial<PostHogConfig>;
|
|
17
|
+
}
|
|
18
|
+
export declare const ConsentContext: import("react").Context<{
|
|
19
|
+
consent: ConsentStatus;
|
|
20
|
+
setConsent: (status: "consented" | "declined") => void;
|
|
21
|
+
} | null>;
|
|
22
|
+
export declare function WebAnalyticsProvider({ children, posthogConfig, }: WebAnalyticsProviderProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { posthog } from "posthog-js";
|
|
4
|
+
import { PostHogProvider } from "posthog-js/react";
|
|
5
|
+
import { createContext, useEffect } from "react";
|
|
6
|
+
import { ConsentBanner } from "./ConsentBanner.js";
|
|
7
|
+
import { useBooleanCookie } from "./useCookie.js";
|
|
8
|
+
const COOKIE_NAME = "allowsCookies";
|
|
9
|
+
const POSTHOG_KEY = "phc_JDNTRxeh2li2sQTRO0IcOYMJcp8fPs5nTK9TU751nQK";
|
|
10
|
+
const POSTHOG_HOST = "https://ap.convex.dev";
|
|
11
|
+
function resolveConsent(isReady, cookieValue, gpcOptOut) {
|
|
12
|
+
if (!isReady) {
|
|
13
|
+
return "unknown";
|
|
14
|
+
}
|
|
15
|
+
if (gpcOptOut) {
|
|
16
|
+
return "declined";
|
|
17
|
+
}
|
|
18
|
+
if (cookieValue === true) {
|
|
19
|
+
return "consented";
|
|
20
|
+
}
|
|
21
|
+
if (cookieValue === false) {
|
|
22
|
+
return "declined";
|
|
23
|
+
}
|
|
24
|
+
return "undecided";
|
|
25
|
+
}
|
|
26
|
+
export const ConsentContext = createContext(null);
|
|
27
|
+
export function WebAnalyticsProvider({ children, posthogConfig, }) {
|
|
28
|
+
const { value, isReady, setValue } = useBooleanCookie(COOKIE_NAME);
|
|
29
|
+
const gpcOptOut = typeof navigator !== "undefined" && navigator.globalPrivacyControl === true;
|
|
30
|
+
const consent = resolveConsent(isReady, value, gpcOptOut);
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
// Prevent PostHog from initializing in development or on localhost.
|
|
33
|
+
if (process.env.NODE_ENV !== "production" ||
|
|
34
|
+
window.location.hostname === "localhost" ||
|
|
35
|
+
window.location.hostname === "127.0.0.1" ||
|
|
36
|
+
window.location.hostname === "[::1]") {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
// If PostHog is already initialized, update its config.
|
|
40
|
+
if (posthog.__loaded) {
|
|
41
|
+
if (posthogConfig) {
|
|
42
|
+
posthog.set_config(posthogConfig);
|
|
43
|
+
}
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
// Initialize PostHog.
|
|
47
|
+
posthog.init(POSTHOG_KEY, {
|
|
48
|
+
api_host: POSTHOG_HOST,
|
|
49
|
+
ui_host: "https://us.posthog.com/",
|
|
50
|
+
// Automatically capture navigations using the History API.
|
|
51
|
+
capture_pageview: "history_change",
|
|
52
|
+
// Together, these two settings capture cookieless events before the user
|
|
53
|
+
// chooses and continue doing so if they decline. Accepting enables persistent
|
|
54
|
+
// tracking. See more: https://posthog.com/tutorials/cookieless-tracking
|
|
55
|
+
opt_out_capturing_by_default: true,
|
|
56
|
+
cookieless_mode: "on_reject",
|
|
57
|
+
// Site-specific options to add or override the defaults.
|
|
58
|
+
...posthogConfig,
|
|
59
|
+
});
|
|
60
|
+
}, [posthogConfig]);
|
|
61
|
+
// One-way sync: make PostHog match the resolved consent status.
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
// No decision yet or PostHog is not initialized.
|
|
64
|
+
if (consent === "unknown" || consent === "undecided" || !posthog.__loaded) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const posthogConsent = posthog.get_explicit_consent_status();
|
|
68
|
+
const targetConsent = consent === "consented" ? "granted" : "denied";
|
|
69
|
+
if (posthogConsent === targetConsent) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (consent === "consented") {
|
|
73
|
+
// Records an `$opt_in` event, which is distinct from ConsentBanner's
|
|
74
|
+
// `cookie_consent_decided` as it also fires when we sync an existing
|
|
75
|
+
// allowsCookies cookie for returning visitors.
|
|
76
|
+
posthog.opt_in_capturing();
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
posthog.opt_out_capturing();
|
|
80
|
+
}
|
|
81
|
+
}, [consent]);
|
|
82
|
+
return (_jsxs(ConsentContext.Provider, { value: {
|
|
83
|
+
consent,
|
|
84
|
+
setConsent: (status) => {
|
|
85
|
+
setValue(status === "consented" ? "true" : "false");
|
|
86
|
+
},
|
|
87
|
+
}, children: [_jsx(PostHogProvider, { client: posthog, children: children }), _jsx(ConsentBanner, {})] }));
|
|
88
|
+
}
|
package/dist/banner.css
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
.wa-banner {
|
|
2
|
+
box-sizing: border-box;
|
|
3
|
+
position: fixed;
|
|
4
|
+
z-index: var(--wa-banner-z-index, 50);
|
|
5
|
+
inset-inline: 1rem;
|
|
6
|
+
bottom: 1rem;
|
|
7
|
+
padding: var(--wa-banner-padding, 1rem);
|
|
8
|
+
border: 1px solid var(--wa-banner-border-color, black);
|
|
9
|
+
border-radius: var(--wa-banner-border-radius, 0);
|
|
10
|
+
background-color: var(--wa-banner-background-color, white);
|
|
11
|
+
box-shadow: var(--wa-banner-box-shadow, none);
|
|
12
|
+
-webkit-backdrop-filter: var(--wa-banner-backdrop-filter, none);
|
|
13
|
+
backdrop-filter: var(--wa-banner-backdrop-filter, none);
|
|
14
|
+
color: var(--wa-banner-text-color, black);
|
|
15
|
+
font-family: var(--wa-banner-font-family, inherit);
|
|
16
|
+
font-size: var(--wa-banner-font-size, 1rem);
|
|
17
|
+
|
|
18
|
+
.wa-banner-text {
|
|
19
|
+
margin: 0 0 0.5rem;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.wa-banner-policy {
|
|
23
|
+
margin-bottom: 1rem;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.wa-banner-link {
|
|
27
|
+
color: var(--wa-banner-link-color, inherit);
|
|
28
|
+
text-decoration: underline;
|
|
29
|
+
text-underline-offset: 0.125rem;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.wa-banner-actions {
|
|
33
|
+
display: flex;
|
|
34
|
+
justify-content: flex-end;
|
|
35
|
+
gap: var(--wa-banner-action-gap, 0.5rem);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.wa-banner-button {
|
|
39
|
+
box-sizing: border-box;
|
|
40
|
+
min-height: var(--wa-banner-button-min-height, 2.75rem);
|
|
41
|
+
padding: var(--wa-banner-button-padding, 0.5rem 0.75rem);
|
|
42
|
+
border: 0;
|
|
43
|
+
border-radius: var(--wa-banner-button-border-radius, 0);
|
|
44
|
+
font-family: inherit;
|
|
45
|
+
font-size: var(--wa-banner-button-font-size, inherit);
|
|
46
|
+
font-weight: var(--wa-banner-button-font-weight, inherit);
|
|
47
|
+
line-height: var(--wa-banner-button-line-height, inherit);
|
|
48
|
+
cursor: pointer;
|
|
49
|
+
transition: background-color 0.15s;
|
|
50
|
+
|
|
51
|
+
&:focus-visible {
|
|
52
|
+
outline: 2px solid var(--wa-banner-focus-color, black);
|
|
53
|
+
outline-offset: 2px;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
&.wa-banner-decline {
|
|
57
|
+
background-color: var(--wa-banner-decline-background-color, lightgray);
|
|
58
|
+
color: var(--wa-banner-decline-text-color, black);
|
|
59
|
+
|
|
60
|
+
&:hover {
|
|
61
|
+
background-color: var(
|
|
62
|
+
--wa-banner-decline-hover-background-color,
|
|
63
|
+
lightgray
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
&.wa-banner-accept {
|
|
69
|
+
background-color: var(--wa-banner-accept-background-color, black);
|
|
70
|
+
color: var(--wa-banner-accept-text-color, white);
|
|
71
|
+
|
|
72
|
+
&:hover {
|
|
73
|
+
background-color: var(--wa-banner-accept-hover-background-color, black);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
@media (min-width: 40rem) {
|
|
79
|
+
inset-inline-start: auto;
|
|
80
|
+
max-width: var(--wa-banner-max-width, 24rem);
|
|
81
|
+
}
|
|
82
|
+
}
|
package/dist/capture.js
ADDED
package/dist/cookie.d.ts
ADDED
package/dist/cookie.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const MAX_AGE = 365 * 24 * 60 * 60;
|
|
2
|
+
export function getCookie(name) {
|
|
3
|
+
const row = document.cookie
|
|
4
|
+
.split("; ")
|
|
5
|
+
.find((part) => part.startsWith(`${name}=`));
|
|
6
|
+
if (!row) {
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
return row.slice(name.length + 1);
|
|
10
|
+
}
|
|
11
|
+
export function setCookie(name, value) {
|
|
12
|
+
const hostname = window.location.hostname;
|
|
13
|
+
const parts = [`${name}=${value}`, "path=/", `max-age=${MAX_AGE}`];
|
|
14
|
+
// Share cookies across the website, Docs, Stack, etc. When making changes
|
|
15
|
+
// to domain or cookie names, update those projects as well.
|
|
16
|
+
if (hostname === "convex.dev" || hostname.endsWith(".convex.dev")) {
|
|
17
|
+
parts.push("domain=.convex.dev");
|
|
18
|
+
}
|
|
19
|
+
if (hostname !== "localhost") {
|
|
20
|
+
parts.push("secure");
|
|
21
|
+
}
|
|
22
|
+
document.cookie = parts.join("; ");
|
|
23
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { WebAnalyticsProvider, type WebAnalyticsProviderProps, type PostHogConfig, type ConsentStatus, } from "./WebAnalyticsProvider.js";
|
|
2
|
+
export { Consented, type ConsentedProps } from "./Consented.js";
|
|
3
|
+
export { useConsent } from "./useConsent.js";
|
|
4
|
+
export { capture, type CaptureOptions } from "./capture.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useContext } from "react";
|
|
3
|
+
import { ConsentContext } from "./WebAnalyticsProvider.js";
|
|
4
|
+
export function useConsent() {
|
|
5
|
+
const context = useContext(ConsentContext);
|
|
6
|
+
if (context === null) {
|
|
7
|
+
throw new Error("useConsent must be used within WebAnalyticsProvider");
|
|
8
|
+
}
|
|
9
|
+
return context;
|
|
10
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare function useCookie<T extends string = string>(name: string): {
|
|
2
|
+
value: string | undefined;
|
|
3
|
+
setValue: (next: T) => void;
|
|
4
|
+
isReady: boolean;
|
|
5
|
+
};
|
|
6
|
+
export declare function useBooleanCookie(name: string): {
|
|
7
|
+
value: boolean | undefined;
|
|
8
|
+
isReady: boolean;
|
|
9
|
+
setValue: (next: "false" | "true") => void;
|
|
10
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useEffect, useState } from "react";
|
|
3
|
+
import { getCookie, setCookie } from "./cookie.js";
|
|
4
|
+
export function useCookie(name) {
|
|
5
|
+
const [value, setValueState] = useState(undefined);
|
|
6
|
+
const [isReady, setIsReady] = useState(false);
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
const sync = () => {
|
|
9
|
+
setValueState(getCookie(name));
|
|
10
|
+
setIsReady(true);
|
|
11
|
+
};
|
|
12
|
+
const handleCookieChange = (event) => {
|
|
13
|
+
const changed = [...event.changed, ...event.deleted].some((cookie) => cookie.name === name);
|
|
14
|
+
if (changed) {
|
|
15
|
+
sync();
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
sync();
|
|
19
|
+
window.addEventListener("focus", sync);
|
|
20
|
+
document.addEventListener("visibilitychange", sync);
|
|
21
|
+
window.cookieStore?.addEventListener("change", handleCookieChange);
|
|
22
|
+
return () => {
|
|
23
|
+
window.removeEventListener("focus", sync);
|
|
24
|
+
document.removeEventListener("visibilitychange", sync);
|
|
25
|
+
window.cookieStore?.removeEventListener("change", handleCookieChange);
|
|
26
|
+
};
|
|
27
|
+
}, [name]);
|
|
28
|
+
const setValue = useCallback((next) => {
|
|
29
|
+
setCookie(name, next);
|
|
30
|
+
setValueState(next);
|
|
31
|
+
}, [name]);
|
|
32
|
+
return { value, setValue, isReady };
|
|
33
|
+
}
|
|
34
|
+
export function useBooleanCookie(name) {
|
|
35
|
+
const { value: raw, isReady, setValue } = useCookie(name);
|
|
36
|
+
const value = raw === "true" ? true : raw === "false" ? false : undefined;
|
|
37
|
+
return { value, isReady, setValue };
|
|
38
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@convex-internal/web-analytics",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"default": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"./banner.css": "./dist/banner.css"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc && cp src/banner.css dist/banner.css",
|
|
17
|
+
"prepack": "npm run build",
|
|
18
|
+
"test": "vitest run"
|
|
19
|
+
},
|
|
20
|
+
"sideEffects": [
|
|
21
|
+
"**/*.css"
|
|
22
|
+
],
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"posthog-js": "^1.211.0"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"react": "^18.0.0 || ^19.0.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@testing-library/react": "^16.3.2",
|
|
31
|
+
"@types/node": "^26.2.0",
|
|
32
|
+
"@types/react": "^19.2.18",
|
|
33
|
+
"@types/react-dom": "^19.2.4",
|
|
34
|
+
"jsdom": "^30.0.1",
|
|
35
|
+
"react": "^19.2.4",
|
|
36
|
+
"react-dom": "^19.2.8",
|
|
37
|
+
"typescript": "^5.9.2",
|
|
38
|
+
"vitest": "^4.1.10"
|
|
39
|
+
}
|
|
40
|
+
}
|