diff --git a/.gitignore b/.gitignore index deb95be..941392b 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ tsup.config.bundled_*.mjs .DS_Store Thumbs.db +# Cursor +.cursor/ + # Editor .vscode/ !.vscode/settings.json diff --git a/docs/spec-react-ga4.md b/docs/spec-react-ga4.md new file mode 100644 index 0000000..f385bb1 --- /dev/null +++ b/docs/spec-react-ga4.md @@ -0,0 +1,209 @@ +# Specification: Junction + GA4 on a React website + +This document specifies how to implement **Junction** with the **Google Analytics 4** destination on a **client-rendered or hybrid React** application. It reflects the behavior of `@junctionjs/client` and `@junctionjs/destination-ga4` in this repository. + +--- + +## 1. Goals and scope + +| In scope | Out of scope (unless you add it) | +|----------|----------------------------------| +| Browser-side GA4 via **gtag.js** (loaded by Junction if missing) | Next.js App Router (use `@junctionjs/next` + `PageTracker` separately) | +| **Consent** (Junction state machine + optional **Google Consent Mode v2**) | Server-only GA4 (Measurement Protocol) without a collect path | +| **Event naming** (`entity` + `action`) and GA4 **recommended event** mapping | GTM as the primary loader (avoid double GA config) | +| Optional **Zod contracts** for `properties` | Other vendors | + +**Stack assumption:** React 18+ (or compatible), any bundler. There is no `@junctionjs/react` package—use **one `createClient` instance** and **React context** (same idea as `JunctionProvider` in `@junctionjs/next`). + +--- + +## 2. Dependencies + +```bash +npm install @junctionjs/core @junctionjs/client @junctionjs/destination-ga4 zod +``` + +- **`zod`** — only if you use **event contracts**. + +--- + +## 3. Configuration requirements + +### 3.1 GA4 + +- **Measurement ID** `G-XXXXXXXXXX` — OK in the client bundle. +- **API secret** — for **Measurement Protocol** only; do **not** ship in a public SPA unless you accept that risk (prefer backend/edge collect). + +### 3.2 Collector config + +| Field | Purpose | +|-------|---------| +| `name`, `environment` | Identity / environment tagging. | +| `consent` | `ConsentConfig`: `defaultState`, `queueTimeout`, DNT/GPC, optional `strictMode`, `consentFallback`, `signals`. | +| `destinations` | Includes GA4 `DestinationEntry`. | + +### 3.3 GA4 destination entry + +Use **`ga4`** or **`createGA4()`** from `@junctionjs/destination-ga4`. Typical client setup: + +```typescript +import { ga4 } from "@junctionjs/destination-ga4"; + +{ + destination: ga4, + config: { + measurementId: "G-XXXXXXXXXX", + loadScript: true, // default; loads gtag if missing + consentMode: true, // default consent + updates for Google + sendPageView: false, // Junction client emits page_view via page/viewed + }, + consent: ["analytics", "marketing"], +} +``` + +The GA4 destination is defined with **`consent: ["analytics", "marketing"]`** — **both** must be granted (AND) before sends. Align CMP updates and `client.consent({ ... })` with that. + +--- + +## 4. Google Consent Mode v2 + +1. **`consentMode: true`** on GA4 `config` — `init` runs `gtag("consent", "default", …)` before `gtag("config", …)`; `onConsent` runs `gtag("consent", "update", …)` on Junction consent changes. + +2. **`googleConsentMode({ waitForUpdate })`** as **`consent.signals`** — for `wait_for_update` or centralized behavior; you can pair with **`consentMode: false`** on the destination if the signal owns updates. + +Mapping is implemented in code (Junction categories → `analytics_storage`, `ad_storage`, etc.). + +--- + +## 5. React integration + +### 5.1 Lifecycle + +- **`createClient(config)` once** per app (e.g. in `useEffect` with `[]`). +- **`client.shutdown()`** on unmount (removes listeners/global, teardown). + +### 5.2 Context + +Expose `JunctionClient` via **`createContext`** + **`useJunction()`** for `track`, `identify`, `consent`, `getConsent`. + +### 5.3 Page views + +With **`autoPageView: true`** (default), the client tracks **`page` / `viewed`** on load and on **`popstate`** / **`history.pushState`**. For routers that don’t use the History API, subscribe to route changes and call `client.track("page", "viewed", …)` manually. + +### 5.4 `window.jct` + +Optional: `globalName: "jct"`. Use `globalName: false` to avoid a global. + +--- + +## 6. Events and GA4 mapping + +- API: **`track(entity, action, properties?)`**. + +Built-in examples (see `packages/destination-ga4/src/index.ts`): + +| Junction | GA4 | +|----------|-----| +| `page:viewed` | `page_view` | +| `product:viewed` | `view_item` | +| `product:added` | `add_to_cart` | +| `product:list_viewed` | `view_item_list` | +| `checkout:started` | `begin_checkout` | +| `order:completed` | `purchase` | + +Unmapped pairs become **`{entity}_{action}`** unless **`eventNameMap`** overrides. **`parameterMap`** overrides default property renames (e.g. `product_id` → `item_id`). + +For **`items[]`** on ecommerce events, validate against GA4 docs; you may need **`parameterMap`** or custom payload shapes. + +--- + +## 7. Zod contracts + +Pass **`contracts: EventContract[]`** on the client config. **Strict** drops bad events; **lenient** warns and still sends; **no contract** allows any shape for that `entity`+`action`. + +--- + +## 8. Identity + +- **`client.identify(userId, traits?)`** — sets user; GA4 uses **`user_id`** and **`anonymousId`** as **`client_id`**. + +--- + +## 9. CMP checklist + +1. Configure **`defaultState`**, **`queueTimeout`**, **`strictMode`**, **`consentFallback`** as required. +2. On CMP choice, call **`client.consent({ analytics, marketing, ... })`**. +3. For stock GA4, grant **both** `analytics` and `marketing` if you want events to flow under default destination consent. + +--- + +## 10. Verification + +- **`debug: true`** on collector config; optional **`@junctionjs/debug`** panel. +- Browser network + GA4 **DebugView**. + +--- + +## 11. Provider skeleton + +```tsx +import { createClient, type ClientConfig, type JunctionClient } from "@junctionjs/client"; +import { ga4 } from "@junctionjs/destination-ga4"; +import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; + +const JunctionContext = createContext(null); + +export function useJunction(): JunctionClient { + const client = useContext(JunctionContext); + if (!client) throw new Error("useJunction must be used within JunctionProvider"); + return client; +} + +export function JunctionProvider({ children }: { children: ReactNode }) { + const [client, setClient] = useState(null); + + useEffect(() => { + const config: ClientConfig = { + name: "my-react-app", + environment: import.meta.env.MODE, + consent: { + defaultState: {}, + queueTimeout: 30_000, + respectDNT: true, + respectGPC: true, + }, + destinations: [ + { + destination: ga4, + config: { + measurementId: import.meta.env.VITE_GA_MEASUREMENT_ID, + consentMode: true, + sendPageView: false, + }, + consent: ["analytics", "marketing"], + }, + ], + globalName: "jct", + autoPageView: true, + }; + + const instance = createClient(config); + setClient(instance); + return () => { + void instance.shutdown(); + setClient(null); + }; + }, []); + + if (!client) return null; + return {children}; +} +``` + +--- + +## 12. Revision + +| Version | Notes | +|---------|--------| +| 1.0 | Initial React + GA4 spec | diff --git a/package-lock.json b/package-lock.json index b0a1bc4..fd77cfb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11681,10 +11681,10 @@ }, "packages/astro": { "name": "@junctionjs/astro", - "version": "0.1.5", + "version": "1.0.0", "license": "MIT", "dependencies": { - "@junctionjs/core": "^0.1.2" + "@junctionjs/core": "^0.2.0" }, "devDependencies": { "@junctionjs/debug": "*", @@ -11696,7 +11696,7 @@ "node": ">=18.0.0" }, "peerDependencies": { - "@junctionjs/debug": "^0.1.0", + "@junctionjs/debug": "^1.0.0", "astro": "^5.0.0" }, "peerDependenciesMeta": { @@ -11707,10 +11707,10 @@ }, "packages/auto-collect": { "name": "@junctionjs/auto-collect", - "version": "0.1.1", + "version": "0.1.2", "license": "MIT", "dependencies": { - "@junctionjs/core": "^0.1.2" + "@junctionjs/core": "^0.2.0" }, "devDependencies": { "tsup": "^8.0.0", @@ -11722,10 +11722,10 @@ }, "packages/client": { "name": "@junctionjs/client", - "version": "0.1.1", + "version": "0.1.2", "license": "MIT", "dependencies": { - "@junctionjs/core": "^0.1.2" + "@junctionjs/core": "^0.2.0" }, "devDependencies": { "tsup": "^8.0.0", @@ -11737,7 +11737,7 @@ }, "packages/cmp-onetrust": { "name": "@junctionjs/cmp-onetrust", - "version": "0.1.0", + "version": "1.0.0", "license": "MIT", "devDependencies": { "@junctionjs/core": "*", @@ -11748,12 +11748,12 @@ "node": ">=18.0.0" }, "peerDependencies": { - "@junctionjs/core": "^0.1.0" + "@junctionjs/core": "^0.2.0" } }, "packages/core": { "name": "@junctionjs/core", - "version": "0.1.2", + "version": "0.2.1", "license": "MIT", "dependencies": { "zod": "^3.23.0" @@ -11768,7 +11768,7 @@ }, "packages/debug": { "name": "@junctionjs/debug", - "version": "0.1.2", + "version": "1.0.0", "license": "MIT", "devDependencies": { "@junctionjs/core": "*", @@ -11779,12 +11779,12 @@ "node": ">=18.0.0" }, "peerDependencies": { - "@junctionjs/core": "^0.1.0" + "@junctionjs/core": "^0.2.0" } }, "packages/destination-amplitude": { "name": "@junctionjs/destination-amplitude", - "version": "0.1.1", + "version": "1.0.0", "license": "MIT", "devDependencies": { "@junctionjs/core": "*", @@ -11795,12 +11795,12 @@ "node": ">=18.0.0" }, "peerDependencies": { - "@junctionjs/core": "^0.1.0" + "@junctionjs/core": "^0.2.0" } }, "packages/destination-ga4": { "name": "@junctionjs/destination-ga4", - "version": "0.1.1", + "version": "1.0.0", "license": "MIT", "devDependencies": { "@junctionjs/core": "*", @@ -11811,12 +11811,12 @@ "node": ">=18.0.0" }, "peerDependencies": { - "@junctionjs/core": "^0.1.0" + "@junctionjs/core": "^0.2.0" } }, "packages/destination-http": { "name": "@junctionjs/destination-http", - "version": "0.1.1", + "version": "1.0.0", "license": "MIT", "devDependencies": { "@junctionjs/core": "*", @@ -11827,12 +11827,12 @@ "node": ">=18.0.0" }, "peerDependencies": { - "@junctionjs/core": "^0.1.0" + "@junctionjs/core": "^0.2.0" } }, "packages/destination-meta": { "name": "@junctionjs/destination-meta", - "version": "0.1.1", + "version": "1.0.0", "license": "MIT", "devDependencies": { "@junctionjs/core": "*", @@ -11843,12 +11843,12 @@ "node": ">=18.0.0" }, "peerDependencies": { - "@junctionjs/core": "^0.1.0" + "@junctionjs/core": "^0.2.0" } }, "packages/destination-plausible": { "name": "@junctionjs/destination-plausible", - "version": "0.1.1", + "version": "1.0.0", "license": "MIT", "devDependencies": { "@junctionjs/core": "*", @@ -11859,15 +11859,15 @@ "node": ">=18.0.0" }, "peerDependencies": { - "@junctionjs/core": "^0.1.0" + "@junctionjs/core": "^0.2.0" } }, "packages/gateway": { "name": "@junctionjs/gateway", - "version": "0.1.1", + "version": "0.1.2", "license": "MIT", "dependencies": { - "@junctionjs/core": "^0.1.2" + "@junctionjs/core": "^0.2.0" }, "devDependencies": { "tsup": "^8.0.0", @@ -11879,9 +11879,12 @@ }, "packages/next": { "name": "@junctionjs/next", - "version": "0.1.1", + "version": "1.0.1", "license": "MIT", "devDependencies": { + "@junctionjs/client": "*", + "@junctionjs/core": "*", + "@junctionjs/debug": "*", "@types/react": "^19.0.0", "next": "^15.1.0", "react": "^19.0.0", @@ -11889,9 +11892,9 @@ "typescript": "^5.5.0" }, "peerDependencies": { - "@junctionjs/client": "^0.1.0", - "@junctionjs/core": "^0.1.0", - "@junctionjs/debug": "^0.1.0", + "@junctionjs/client": "^0.1.2", + "@junctionjs/core": "^0.2.1", + "@junctionjs/debug": "^1.0.0", "next": ">=14.0.0", "react": ">=18.0.0" },