@founderroute/analytics 0.1.0-beta.1 → 1.0.0-rc.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/collection.js ADDED
@@ -0,0 +1,60 @@
1
+ // Collection permission never renders UI or implies a visitor consented.
2
+ export class CollectionState {
3
+ constructor({ mode, preferenceKey, storage, changed, error }) {
4
+ if (mode != null && !['automatic', 'consent'].includes(mode)) throw new Error('Invalid collection mode');
5
+ this.mode = mode ?? null;
6
+ this.permission = 'not_provided';
7
+ this.refused = false;
8
+ this.destroyed = false;
9
+ this.preferenceKey = preferenceKey;
10
+ this.storage = storage;
11
+ this.changed = changed;
12
+ this.error = error;
13
+ this.refresh();
14
+ }
15
+ get enabled() {
16
+ return !this.destroyed && !this.refused && (this.mode === 'automatic' || (this.mode === 'consent' && this.permission === 'granted'));
17
+ }
18
+ refresh() {
19
+ try { this.refused = this.storage()?.getItem(this.preferenceKey) === 'refused'; }
20
+ catch { this.error('preference_storage_unavailable'); }
21
+ }
22
+ saveRefusal(refused) {
23
+ this.refused = refused;
24
+ try {
25
+ const storage = this.storage();
26
+ if (!storage) throw new Error('Storage unavailable');
27
+ if (refused) storage.setItem(this.preferenceKey, 'refused');
28
+ else storage.removeItem(this.preferenceKey);
29
+ } catch { this.error('preference_storage_unavailable'); }
30
+ }
31
+ setMode(mode) {
32
+ if (!['automatic', 'consent'].includes(mode)) throw new Error('Invalid collection mode');
33
+ this.mode = mode;
34
+ this.changed();
35
+ }
36
+ setConsent(granted) {
37
+ this.permission = granted ? 'granted' : 'denied';
38
+ this.saveRefusal(!granted);
39
+ this.changed();
40
+ }
41
+ optOut() {
42
+ this.permission = 'denied';
43
+ this.saveRefusal(true);
44
+ this.changed();
45
+ }
46
+ optIn() {
47
+ this.saveRefusal(false);
48
+ if (this.permission === 'denied') this.permission = 'not_provided';
49
+ this.changed();
50
+ }
51
+ receiveRefusal() {
52
+ // Other tabs may stop this instance, but never grant permission on its behalf.
53
+ this.refused = true;
54
+ this.permission = 'denied';
55
+ this.changed();
56
+ }
57
+ metadata() {
58
+ return { collection_mode: this.mode, consent_state: this.permission === 'granted' ? 'granted' : 'not_provided' };
59
+ }
60
+ }
package/index.d.ts CHANGED
@@ -1,8 +1,12 @@
1
1
  export type Scalar = string | number | boolean | null;
2
- export type AnalyticsOptions = { key: string; endpoint: string; verificationId?: string; autoPage?: boolean; allowedProperties?: string[]; allowedTraits?: string[]; storage?: Pick<Storage,"getItem"|"setItem"|"removeItem">; fetch?: typeof fetch };
2
+ export type AnalyticsOptions = { key: string; endpoint: string; propertyId?: string; environment?: "production" | "test"; collectionMode?: "automatic" | "consent"; verificationId?: string; autoPage?: boolean; allowedProperties?: string[]; allowedTraits?: string[]; storage?: Pick<Storage,"getItem"|"setItem"|"removeItem">; fetch?: typeof fetch };
3
3
  export class FounderRouteAnalytics {
4
4
  constructor(options: AnalyticsOptions);
5
+ readonly ready: Promise<void>;
5
6
  setConsent(granted: boolean): void;
7
+ optOut(): void;
8
+ optIn(): void;
9
+ setCollectionMode(mode: "automatic" | "consent"): void;
6
10
  identify(userId: string, options?: {token?: string;traits?: Record<string,Scalar>}): void;
7
11
  setAccount(accountId: string | null): void;
8
12
  track(name: string, properties?: Record<string,Scalar>, options?: {outcomeId?: string}): string | null;
@@ -12,7 +16,7 @@ export class FounderRouteAnalytics {
12
16
  flush(): Promise<void>;
13
17
  decorateLink(url:string,destinationPropertyId:string):Promise<string>;
14
18
  consumeHandoff(token:string):Promise<void>;
15
- getDiagnostics(): {consent:boolean;queued:number;dropped:number;rejected:number;acknowledged:number;lastError:string|null;anonymousId:string|null};
19
+ getDiagnostics(): {collectionMode:"automatic"|"consent"|null;consentState:"not_provided"|"granted"|"denied";optedOut:boolean;collectionEnabled:boolean;consent:boolean;queued:number;dropped:number;rejected:number;acknowledged:number;lastError:string|null;anonymousId:string|null};
16
20
  destroy(): void;
17
21
  }
18
22
  export function init(options: AnalyticsOptions): FounderRouteAnalytics;
package/index.js CHANGED
@@ -1,4 +1,5 @@
1
- const VERSION = "0.1.0-beta.1";
1
+ import { CollectionState } from "./collection.js";
2
+ const VERSION = "1.0.0-rc.1";
2
3
  const MAX_BYTES = 1024 * 1024;
3
4
  const MAX_EVENTS = 1000;
4
5
  const TTL = 86400000;
@@ -14,34 +15,81 @@ export class FounderRouteAnalytics {
14
15
  constructor(options) {
15
16
  if (!options?.key || !options?.endpoint) throw new Error("FounderRoute requires a public key and collector origin.");
16
17
  this.options = options; this.endpoint = options.endpoint.replace(/\/$/, "");
17
- this.storageKey = `founderroute:${options.key}`; this.consent = false; this.queue = [];
18
+ this.configurationReady = false; this.storageKey = `founderroute:${options.key}`; this.collecting = false; this.queue = [];
18
19
  this.identity = null; this.session = null; this.user = null; this.account = null; this.token = null; this.traits = {};
19
20
  this.dropped = 0; this.rejected = 0; this.sent = 0; this.lastError = null; this.inFlight = null; this.retryAt = 0; this.failures = 0;
20
21
  this.generation = 0; this.cleanups = []; this.timer = null; this.lastInteraction = 0; this.lastTick = 0; this.lastPage = null;
22
+ this.collection = new CollectionState({
23
+ mode: null, preferenceKey: `founderroute:${options.key}:preference`,
24
+ storage: () => options.storage ?? globalThis.localStorage,
25
+ error: reason => { this.lastError = reason; }, changed: () => this.reconcileCollection(),
26
+ });
27
+ this.preferenceListener = event => {
28
+ if (event.key === this.collection.preferenceKey && event.newValue === 'refused') this.collection.receiveRefusal();
29
+ };
30
+ globalThis.addEventListener?.('storage', this.preferenceListener);
31
+ if (options.propertyId && ['production','test'].includes(options.environment) && options.collectionMode) {
32
+ this.configureCollection(options.propertyId, options.environment, options.collectionMode);
33
+ this.ready = Promise.resolve();
34
+ } else {
35
+ this.ready = this.resolveConfiguration();
36
+ }
21
37
  }
22
- setConsent(granted) {
23
- if (Boolean(granted) === this.consent) return;
24
- this.consent = Boolean(granted); this.generation++;
25
- if (!this.consent) {
26
- this.controller?.abort(); clearInterval(this.timer); this.timer = null;
38
+ setConsent(granted) { this.collection.setConsent(Boolean(granted)); }
39
+ optOut() { this.collection.optOut(); }
40
+ optIn() { this.collection.optIn(); }
41
+ setCollectionMode(mode) { this.collection.setMode(mode); }
42
+ async resolveConfiguration() {
43
+ try {
44
+ const response = await (this.options.fetch ?? globalThis.fetch)(`${this.endpoint}/api/analytics/v2/config?key=${encodeURIComponent(this.options.key)}`);
45
+ if (!response.ok) throw new Error('Configuration unavailable');
46
+ const config = await response.json();
47
+ if (!config.property_id || !['production','test'].includes(config.environment) || !['automatic','consent'].includes(config.collection_mode)) throw new Error('Invalid configuration');
48
+ if (this.collection.destroyed) return;
49
+ this.configureCollection(config.property_id, config.environment, this.options.collectionMode ?? config.collection_mode);
50
+ } catch { this.lastError = 'configuration_unavailable'; }
51
+ }
52
+ configureCollection(propertyId, environment, mode) {
53
+ this.propertyId = propertyId; this.environment = environment;
54
+ this.storageKey = `founderroute:${propertyId}:${environment}:events`;
55
+ this.collection.preferenceKey = `founderroute:${propertyId}:${environment}:preference`;
56
+ const refusedHere = this.collection.refused;
57
+ this.collection.refresh();
58
+ if (refusedHere) this.collection.saveRefusal(true);
59
+ this.configurationReady = true;
60
+ this.collection.setMode(mode);
61
+ }
62
+ reconcileCollection() {
63
+ const granted = this.configurationReady && this.collection.enabled;
64
+ if (!granted && this.collection.refused) {
65
+ try { const storage = this.options.storage ?? globalThis.localStorage; storage?.removeItem(this.storageKey); storage?.removeItem(`founderroute:${this.options.key}`); }
66
+ catch { /* preference persistence reports storage failure separately */ }
67
+ }
68
+ if (granted === this.collecting) return;
69
+ this.collecting = granted; this.generation++;
70
+ if (!this.collecting) {
71
+ this.controller?.abort(); this.inFlight = null; clearInterval(this.timer); this.timer = null;
27
72
  for (const remove of this.cleanups.splice(0)) remove();
28
73
  this.queue = []; this.identity = null; this.session = null; this.user = null; this.token = null; this.account = null; this.traits = {}; this.lastPage = null;
29
- try { this.options.storage?.removeItem(this.storageKey); if (!this.options.storage) globalThis.localStorage?.removeItem(this.storageKey); } catch { /* unavailable storage is not fatal */ }
74
+ try { const storage = this.options.storage ?? globalThis.localStorage; storage?.removeItem(this.storageKey); storage?.removeItem(`founderroute:${this.options.key}`); } catch { /* unavailable storage is not fatal */ }
30
75
  return;
31
76
  }
32
77
  try {
33
- const stored = (this.options.storage ?? globalThis.localStorage)?.getItem(this.storageKey);
78
+ const storage = this.options.storage ?? globalThis.localStorage;
79
+ const stored = storage?.getItem(this.storageKey) ?? storage?.getItem(`founderroute:${this.options.key}`);
34
80
  if (stored) { const saved = JSON.parse(stored); this.queue = Array.isArray(saved.queue) ? saved.queue : []; this.identity = saved.identity; }
35
81
  } catch { this.queue = []; }
36
82
  this.identity ||= uuid(); this.lastInteraction = Date.now(); this.lastTick = Date.now();
37
- this.prune(); this.persist(); this.attach();
83
+ this.prune(); this.persist();
84
+ try { const storage = this.options.storage ?? globalThis.localStorage; if (storage?.getItem(this.storageKey)) storage.removeItem(`founderroute:${this.options.key}`); } catch { /* keep legacy delivery if migration did not persist */ }
85
+ this.attach();
38
86
  this.timer = setInterval(() => { this.engagement(); void this.flush(); }, 15000);
39
87
  this.timer?.unref?.();
40
88
  if (this.options.autoPage !== false && globalThis.location) this.page();
41
89
  void this.flush();
42
90
  }
43
91
  persist() {
44
- if (!this.consent) return;
92
+ if (!this.collecting) return;
45
93
  try { (this.options.storage ?? globalThis.localStorage)?.setItem(this.storageKey, JSON.stringify({ identity:this.identity,queue:this.queue })); }
46
94
  catch { this.lastError = "storage_unavailable"; }
47
95
  }
@@ -52,14 +100,14 @@ export class FounderRouteAnalytics {
52
100
  while (this.queue.length>MAX_EVENTS || encoder.encode(JSON.stringify(this.queue)).length>MAX_BYTES) { this.queue.shift(); this.dropped++; }
53
101
  }
54
102
  event(name, properties = {}, extra = {}) {
55
- if (!this.consent) return null;
103
+ if (!this.collecting) return null;
56
104
  const now = Date.now();
57
105
  if (!this.session || now-this.session.last >= 1800000) this.session = { id:uuid(),last:now };
58
106
  this.session.last = now;
59
107
  const event = {
60
- event_id:uuid(),protocol:1,name,kind:extra.kind ?? "custom",occurred_at:new Date(now).toISOString(),anonymous_id:this.identity,
108
+ event_id:uuid(),protocol:2,name,kind:extra.kind ?? "custom",occurred_at:new Date(now).toISOString(),anonymous_id:this.identity,
61
109
  ...(this.user ? {user_id:this.user} : {}),...(this.token ? {identity_token:this.token} : {}),...(this.account ? {account_id:this.account} : {}),
62
- session_id:this.session.id,consent:true,properties:clean(properties,this.options.allowedProperties),traits:clean(this.traits,this.options.allowedTraits),
110
+ session_id:this.session.id,...this.collection.metadata(),properties:clean(properties,this.options.allowedProperties),traits:clean(this.traits,this.options.allowedTraits),
63
111
  context:{sdk:"browser",sdk_version:VERSION,...(this.options.verificationId?{verification_id:this.options.verificationId}:{}),...this.campaignContext(),...extra.context},...(extra.outcomeId ? {outcome_id:extra.outcomeId} : {}),
64
112
  };
65
113
  if (encoder.encode(JSON.stringify(event)).length>8192) { this.rejected++; this.lastError="event_too_large"; return null; }
@@ -67,7 +115,7 @@ export class FounderRouteAnalytics {
67
115
  }
68
116
  track(name, properties = {}, options = {}) { return this.event(name,properties,{outcomeId:options.outcomeId}); }
69
117
  page(path) {
70
- if (!this.consent) return null;
118
+ if (!this.collecting) return null;
71
119
  const route = normalizePath(path ?? globalThis.location?.pathname ?? "/");
72
120
  if (route === this.lastPage) return null;
73
121
  this.engagement(); this.lastPage=route;
@@ -75,14 +123,14 @@ export class FounderRouteAnalytics {
75
123
  }
76
124
  screen(name) { return this.event("screen_view",{},{kind:"screen",context:{screen:String(name).slice(0,150)}}); }
77
125
  identify(userId, { token, traits = {} } = {}) {
78
- if (!this.consent || !userId) return;
126
+ if (!this.collecting || !userId) return;
79
127
  if (this.user && this.user !== String(userId)) this.reset();
80
128
  this.user=String(userId); this.token=token; this.traits={...traits};
81
129
  this.event("fr_identify",{},{kind:"identify"});
82
130
  }
83
- setAccount(accountId) { if (this.consent) this.account=accountId ? String(accountId) : null; }
131
+ setAccount(accountId) { if (this.collecting) this.account=accountId ? String(accountId) : null; }
84
132
  reset() {
85
- if (!this.consent) return;
133
+ if (!this.collecting) return;
86
134
  this.engagement(); this.user=null; this.token=null; this.account=null; this.traits={}; this.identity=uuid(); this.session=null; this.lastPage=null; this.persist();
87
135
  }
88
136
  campaignContext() {
@@ -95,7 +143,7 @@ export class FounderRouteAnalytics {
95
143
  return result;
96
144
  }
97
145
  engagement() {
98
- if (!this.consent) return;
146
+ if (!this.collecting) return;
99
147
  const now=Date.now(); const elapsed=Math.min(15000,Math.max(0,now-this.lastTick)); this.lastTick=now;
100
148
  if (globalThis.document?.visibilityState === "hidden" || now-this.lastInteraction>60000 || !elapsed) return;
101
149
  this.event("fr_session",{},{kind:"session",context:{active_ms:elapsed,path:this.lastPage??"/"}});
@@ -124,44 +172,52 @@ export class FounderRouteAnalytics {
124
172
  return events;
125
173
  }
126
174
  beacon() {
127
- if(!this.consent||!globalThis.navigator?.sendBeacon) return;
175
+ if(!this.collecting||!globalThis.navigator?.sendBeacon) return;
128
176
  const events=this.batch(); if(!events.length)return;
129
- try { navigator.sendBeacon(`${this.endpoint}/api/analytics/v1/collect`,new Blob([JSON.stringify({key:this.options.key,events})],{type:"text/plain"})); }
177
+ try { navigator.sendBeacon(`${this.endpoint}/api/analytics/v2/collect`,new Blob([JSON.stringify({key:this.options.key,events})],{type:"text/plain"})); }
130
178
  catch { /* retained until acknowledged by fetch */ }
131
179
  }
132
180
  async flush() {
133
- if(!this.consent||Date.now()<this.retryAt) return;
181
+ if(!this.collecting||Date.now()<this.retryAt) return;
134
182
  if(this.inFlight)return this.inFlight;
135
183
  const generation=this.generation; const events=this.batch(); if(!events.length)return;
136
184
  this.controller=new AbortController();
185
+ const delivery = {}; this.delivery = delivery;
137
186
  this.inFlight=(async()=>{
138
187
  try {
139
- const response=await (this.options.fetch??globalThis.fetch)(`${this.endpoint}/api/analytics/v1/collect`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({key:this.options.key,events}),signal:this.controller.signal});
140
- if(!this.consent||this.generation!==generation)return;
188
+ const response=await (this.options.fetch??globalThis.fetch)(`${this.endpoint}/api/analytics/v2/collect`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({key:this.options.key,events}),signal:this.controller.signal});
189
+ if(!this.collecting||this.generation!==generation)return;
141
190
  if(response.status===429||response.status>=500){this.failures++;this.retryAt=Date.now()+Math.max(Number(response.headers.get("Retry-After")??0)*1000,Math.min(300000,1000*2**Math.min(this.failures,8)));this.lastError=`delivery_${response.status}`;return;}
142
191
  const result=await response.json();
192
+ if(!this.collecting||this.generation!==generation)return;
143
193
  if(!response.ok){this.lastError=result.error??"delivery_rejected";this.retryAt=Date.now()+300000;if([400,401,403,413].includes(response.status)){const ids=new Set(events.map(e=>e.event_id));this.queue=this.queue.filter(e=>!ids.has(e.event_id));this.rejected+=ids.size;}return;}
144
194
  const acknowledged=new Set();
145
195
  for(const receipt of result.results??[]){if(["accepted","duplicate","rejected"].includes(receipt.status))acknowledged.add(receipt.event_id);if(receipt.status==="rejected"){this.rejected++;this.lastError=receipt.reason;}else this.sent++;}
146
196
  this.queue=this.queue.filter(e=>!acknowledged.has(e.event_id));this.failures=0;this.retryAt=0;
147
- }catch(error){if(error?.name!=="AbortError"){this.lastError="network_unavailable";this.failures++;this.retryAt=Date.now()+Math.min(300000,1000*2**Math.min(this.failures,8));}}
148
- finally{if(this.consent&&this.generation===generation)this.persist();this.inFlight=null;}
197
+ }catch(error){if(this.collecting&&this.generation===generation&&error?.name!=="AbortError"){this.lastError="network_unavailable";this.failures++;this.retryAt=Date.now()+Math.min(300000,1000*2**Math.min(this.failures,8));}}
198
+ finally{if(this.collecting&&this.generation===generation)this.persist();if(this.delivery===delivery)this.inFlight=null;}
149
199
  })();
150
200
  return this.inFlight;
151
201
  }
152
202
  async decorateLink(url,destinationPropertyId) {
153
- if(!this.consent)return url;
203
+ if(!this.collecting)return url;
154
204
  const destination=new URL(url,globalThis.location?.href);if(!["http:","https:"].includes(destination.protocol)||destination.username||destination.password)return url;
155
- const response=await (this.options.fetch??fetch)(`${this.endpoint}/api/analytics/v1/link`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({consent:true,key:this.options.key,anonymousId:this.identity,destinationPropertyId,destinationUrl:destination.origin})});
205
+ const response=await (this.options.fetch??fetch)(`${this.endpoint}/api/analytics/v2/link`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({...this.collection.metadata(),key:this.options.key,anonymousId:this.identity,destinationPropertyId,destinationUrl:destination.origin})});
156
206
  if(!response.ok)return url;
157
207
  const {token}=await response.json();destination.searchParams.set("fr_handoff",token);return destination.toString();
158
208
  }
159
209
  async consumeHandoff(token) {
160
- if(!this.consent)return;
161
- await (this.options.fetch??fetch)(`${this.endpoint}/api/analytics/v1/link`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({consent:true,key:this.options.key,anonymousId:this.identity,token})});
210
+ if(!this.collecting)return;
211
+ await (this.options.fetch??fetch)(`${this.endpoint}/api/analytics/v2/link`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({...this.collection.metadata(),key:this.options.key,anonymousId:this.identity,token})});
212
+ }
213
+ getDiagnostics(){return {collectionMode:this.collection.mode,consentState:this.collection.permission,optedOut:this.collection.refused,collectionEnabled:this.collecting,consent:this.collection.permission==='granted',queued:this.queue.length,dropped:this.dropped,rejected:this.rejected,acknowledged:this.sent,lastError:this.lastError,anonymousId:this.collecting?this.identity:null};}
214
+ destroy(){
215
+ this.persist(); this.collection.destroyed = true; this.collecting = false; this.generation++;
216
+ this.controller?.abort(); clearInterval(this.timer); this.timer = null;
217
+ for (const remove of this.cleanups.splice(0)) remove();
218
+ globalThis.removeEventListener?.('storage', this.preferenceListener);
219
+ if (instances.get(this.options.key) === this) instances.delete(this.options.key);
162
220
  }
163
- getDiagnostics(){return {consent:this.consent,queued:this.queue.length,dropped:this.dropped,rejected:this.rejected,acknowledged:this.sent,lastError:this.lastError,anonymousId:this.consent?this.identity:null};}
164
- destroy(){this.setConsent(false);}
165
221
  }
166
222
 
167
223
  const instances=new Map();
package/next.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import type {FounderRouteAnalytics} from './index.js';
2
+ export function FounderRoutePageViews(props:{client:FounderRouteAnalytics}):null;
package/package.json CHANGED
@@ -1,17 +1,53 @@
1
1
  {
2
2
  "name": "@founderroute/analytics",
3
- "version": "0.1.0-beta.1",
4
- "description": "Consent-first FounderRoute analytics for browsers, React, and Next.js",
3
+ "version": "1.0.0-rc.1",
4
+ "description": "FounderRoute analytics for browsers, React, and Next.js",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "types": "index.d.ts",
8
- "exports": { ".": { "types": "./index.d.ts", "default": "./index.js" }, "./react": "./react.js", "./next": "./next.js" },
9
- "files": ["*.js", "*.d.ts", "LICENSE"],
10
- "peerDependencies": { "react": ">=18 <20", "next": ">=15 <17" },
11
- "peerDependenciesMeta": { "react": { "optional": true }, "next": { "optional": true } },
8
+ "exports": {
9
+ ".": {
10
+ "types": "./index.d.ts",
11
+ "default": "./index.js"
12
+ },
13
+ "./react": {
14
+ "types": "./react.d.ts",
15
+ "default": "./react.js"
16
+ },
17
+ "./next": {
18
+ "types": "./next.d.ts",
19
+ "default": "./next.js"
20
+ }
21
+ },
22
+ "files": [
23
+ "*.js",
24
+ "*.d.ts",
25
+ "LICENSE"
26
+ ],
27
+ "peerDependencies": {
28
+ "react": ">=18 <20",
29
+ "next": ">=15 <17"
30
+ },
31
+ "peerDependenciesMeta": {
32
+ "react": {
33
+ "optional": true
34
+ },
35
+ "next": {
36
+ "optional": true
37
+ }
38
+ },
12
39
  "license": "MIT",
13
- "repository": { "type": "git", "url": "git+https://github.com/itsreed/founderroute-analytics.git", "directory": "packages/browser" },
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/itsreed/founderroute-analytics.git",
43
+ "directory": "packages/browser"
44
+ },
14
45
  "homepage": "https://github.com/itsreed/founderroute-analytics#readme",
15
- "bugs": { "url": "https://github.com/itsreed/founderroute-analytics/issues" },
16
- "publishConfig": { "access": "public", "provenance": true }
46
+ "bugs": {
47
+ "url": "https://github.com/itsreed/founderroute-analytics/issues"
48
+ },
49
+ "publishConfig": {
50
+ "access": "public",
51
+ "provenance": true
52
+ }
17
53
  }
package/react.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import type {FounderRouteAnalytics} from './index.js';
2
+ /** Applies the customer application's actual permission decision. Renders nothing. */
3
+ export function useFounderRouteConsent(client:FounderRouteAnalytics,granted:boolean):void;
package/react.js CHANGED
@@ -1,3 +1,6 @@
1
1
  "use client";
2
2
  import { useEffect } from "react";
3
- export function FounderRouteConsent({ client, granted, children }) { useEffect(()=>{client.setConsent(granted);},[client,granted]);return children??null; }
3
+ // Integrates an application-owned permission decision without rendering anything.
4
+ export function useFounderRouteConsent(client, granted) {
5
+ useEffect(() => { client.setConsent(granted); }, [client, granted]);
6
+ }