react-marketing-tools 1.0.2 → 1.1.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 +21 -9
- package/dist/server/aiCrawlers.d.ts +3 -1
- package/dist/server/measurementProtocol.d.ts +20 -5
- package/dist/server.js +77 -63
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -1,19 +1,26 @@
|
|
|
1
1
|
# React Marketing Tools
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/react-marketing-tools)
|
|
4
|
-
[](https://www.npmjs.com/package/react-marketing-tools)
|
|
5
|
-
[](https://bundlejs.com/?q=react-marketing-tools)
|
|
6
|
-
[](https://www.npmjs.com/package/react-marketing-tools)
|
|
7
|
-
[](https://www.npmjs.com/package/react-marketing-tools)
|
|
8
|
-
[](./LICENSE)
|
|
9
|
-
|
|
10
3
|
[](https://github.com/bronz3beard/react-marketing-tools/actions/workflows/ci.yml)
|
|
11
4
|
[](https://github.com/bronz3beard/react-marketing-tools/actions/workflows/codeql.yml)
|
|
12
5
|
[](https://scorecard.dev/viewer/?uri=github.com/bronz3beard/react-marketing-tools)
|
|
13
6
|
[](https://www.bestpractices.dev/projects/14943)
|
|
14
|
-
[](https://www.bestpractices.dev/projects/14943)
|
|
8
|
+
[](https://github.com/bronz3beard/react-marketing-tools/blob/main/docs/releasing.md#verifying-a-release)
|
|
15
9
|
|
|
10
|
+
[](./LICENSE)
|
|
11
|
+
[](https://www.npmjs.com/package/react-marketing-tools)
|
|
12
|
+
[](https://www.npmjs.com/package/react-marketing-tools)
|
|
13
|
+
[](https://www.npmjs.com/package/react-marketing-tools)
|
|
14
|
+
[](https://bundlejs.com/?q=react-marketing-tools)
|
|
15
|
+
[](https://www.npmjs.com/package/react-marketing-tools)
|
|
16
|
+
[](https://www.npmjs.com/package/react-marketing-tools)
|
|
17
|
+
[](https://www.npmjs.com/package/react-marketing-tools?activeTab=dependencies)
|
|
18
|
+
|
|
19
|
+
[](https://github.com/bronz3beard/react-marketing-tools/blob/main/docs/README.md)
|
|
20
|
+
[](https://github.com/bronz3beard/react-marketing-tools/blob/main/docs/consent.md)
|
|
21
|
+
[](https://bronz3beard.github.io/react-marketing-tools/)
|
|
16
22
|
[](https://github.com/bronz3beard/react-marketing-tools/blob/main/docs/agent-setup.md)
|
|
23
|
+
[](https://github.com/bronz3beard/react-marketing-tools/commits/main)
|
|
17
24
|
[](https://github.com/bronz3beard/react-marketing-tools/discussions)
|
|
18
25
|
[](https://github.com/bronz3beard/react-marketing-tools/blob/main/CONTRIBUTING.md)
|
|
19
26
|
|
|
@@ -87,7 +94,8 @@ You only configure the services you use. Many teams start with Tag Manager alone
|
|
|
87
94
|
npm install react-marketing-tools
|
|
88
95
|
```
|
|
89
96
|
|
|
90
|
-
Works with React 18 and 19.
|
|
97
|
+
Works with React 18 and 19. React is an optional peer: only the main `react-marketing-tools` entry uses it, so a
|
|
98
|
+
backend that imports just `react-marketing-tools/server` or `react-marketing-tools/core` doesn't install it. The package is published as ES modules, the `import` style of JavaScript, so it works in
|
|
91
99
|
every current bundler and in Node.js 22.12 or later; it can't be loaded with `require()`.
|
|
92
100
|
|
|
93
101
|
## Set this up with an AI assistant
|
|
@@ -329,6 +337,10 @@ await sendMeasurementProtocolEvent({
|
|
|
329
337
|
})
|
|
330
338
|
```
|
|
331
339
|
|
|
340
|
+
Events from an iOS or Android app go to its GA4 app stream the same way, with `firebaseAppId` and `appInstanceId` in
|
|
341
|
+
place of `measurementId` and `clientId`; see
|
|
342
|
+
[Events from a mobile app](https://github.com/bronz3beard/react-marketing-tools/blob/main/docs/measurement-protocol.md#events-from-a-mobile-app).
|
|
343
|
+
|
|
332
344
|
The same import has `sendConversionsApiEvent` for Meta, which scrambles customer details into hashes the way Meta
|
|
333
345
|
requires before sending them.
|
|
334
346
|
|
|
@@ -16,7 +16,9 @@ export type AiAgents = Record<string, string[]>;
|
|
|
16
16
|
export type AiCrawlerReporting = 'separate-property' | {
|
|
17
17
|
trafficType: string;
|
|
18
18
|
};
|
|
19
|
-
export type AiCrawlerOptions = Omit<MeasurementProtocolOptions,
|
|
19
|
+
export type AiCrawlerOptions = Omit<Extract<MeasurementProtocolOptions, {
|
|
20
|
+
measurementId: string;
|
|
21
|
+
}>, 'consent'> & {
|
|
20
22
|
/** Your label for the crawler, from `matchAiAgent()`. */
|
|
21
23
|
agent: string;
|
|
22
24
|
keepSeparate: AiCrawlerReporting;
|
|
@@ -3,13 +3,27 @@ export type MeasurementProtocolEvent = {
|
|
|
3
3
|
name: string;
|
|
4
4
|
params?: EventParams;
|
|
5
5
|
};
|
|
6
|
-
|
|
6
|
+
/** A GA4 web stream: events join the visitor's browser client. */
|
|
7
|
+
type WebStream = {
|
|
7
8
|
measurementId: string;
|
|
8
|
-
/** Keep it on your server: Google says the API secret must not be exposed in client-side code. */
|
|
9
|
-
apiSecret: string;
|
|
10
9
|
/** The visitor's GA4 client ID, from the `_ga` cookie (see `readGa4Cookies`). */
|
|
11
10
|
clientId: string;
|
|
12
|
-
|
|
11
|
+
firebaseAppId?: never;
|
|
12
|
+
appInstanceId?: never;
|
|
13
|
+
};
|
|
14
|
+
/** A GA4 app stream (iOS or Android, through Firebase): events join the app installation. */
|
|
15
|
+
type AppStream = {
|
|
16
|
+
/** The Firebase App ID, e.g. `1:1234567890:android:321abc456def7890`, shown on the app stream's details. */
|
|
17
|
+
firebaseAppId: string;
|
|
18
|
+
/** The installation's ID from the Firebase SDK: `getAppInstanceId()` on Android, `appInstanceID()` on iOS. */
|
|
19
|
+
appInstanceId: string;
|
|
20
|
+
measurementId?: never;
|
|
21
|
+
clientId?: never;
|
|
22
|
+
};
|
|
23
|
+
export type MeasurementProtocolOptions = (WebStream | AppStream) & {
|
|
24
|
+
/** Created on the same stream. Keep it on your server: Google says it must not be exposed in client-side code. */
|
|
25
|
+
apiSecret: string;
|
|
26
|
+
/** The GA4 session ID, from the `_ga_<stream>` cookie or the Firebase SDK, so events join that session. */
|
|
13
27
|
sessionId?: string;
|
|
14
28
|
userId?: string;
|
|
15
29
|
/** Up to 25 events. Each gets `session_id` and `engagement_time_msec` (1 ms unless the params set it). */
|
|
@@ -30,5 +44,6 @@ export type MeasurementProtocolResult = {
|
|
|
30
44
|
warnings: string[];
|
|
31
45
|
validationMessages?: unknown[];
|
|
32
46
|
};
|
|
33
|
-
/** Sends events to GA4 from your server, e.g. a purchase confirmed by a payment webhook. */
|
|
47
|
+
/** Sends events to GA4 from your server, e.g. a purchase confirmed by a payment webhook or an event relayed from an app. */
|
|
34
48
|
export declare const sendMeasurementProtocolEvent: (options: MeasurementProtocolOptions) => Promise<MeasurementProtocolResult>;
|
|
49
|
+
export {};
|
package/dist/server.js
CHANGED
|
@@ -81,17 +81,23 @@ var a = async (e) => {
|
|
|
81
81
|
}, y = {
|
|
82
82
|
global: "https://www.google-analytics.com",
|
|
83
83
|
eu: "https://region1.google-analytics.com"
|
|
84
|
-
}, b = /^G-[A-Z0-9]+$/, x = 25,
|
|
85
|
-
if (!
|
|
86
|
-
if (!t) throw
|
|
87
|
-
|
|
88
|
-
if (
|
|
89
|
-
if (
|
|
90
|
-
|
|
84
|
+
}, b = /^G-[A-Z0-9]+$/, x = /^\d+:\d+:(android|ios):[0-9a-f]+$/, S = /^[0-9a-f]{32}$/i, C = 25, w = (e) => /* @__PURE__ */ TypeError(`[react-marketing-tools] sendMeasurementProtocolEvent: ${e}`), T = ({ firebaseAppId: e, appInstanceId: t }) => {
|
|
85
|
+
if (!x.test(e ?? "")) throw w(`firebaseAppId must look like "1:1234567890:android:321abc456def7890" (received ${JSON.stringify(e)})`);
|
|
86
|
+
if (!S.test(t ?? "")) throw w("appInstanceId must be the 32-character ID from the Firebase SDK (getAppInstanceId() on Android, appInstanceID() on iOS)");
|
|
87
|
+
}, E = ({ measurementId: e, clientId: t }) => {
|
|
88
|
+
if (!b.test(e ?? "")) throw w(`measurementId must look like "G-XXXXXXX" (received ${JSON.stringify(e)})`);
|
|
89
|
+
if (!t) throw w("clientId is required; read it from the _ga cookie with readGa4Cookies()");
|
|
90
|
+
}, D = (e) => {
|
|
91
|
+
let { apiSecret: t, userId: i, events: a } = e, o = e.firebaseAppId !== void 0 || e.appInstanceId !== void 0, s = e.measurementId !== void 0 || e.clientId !== void 0;
|
|
92
|
+
if (o && s) throw w("pass measurementId and clientId for a web stream, or firebaseAppId and appInstanceId for an app stream, not both");
|
|
93
|
+
if (o ? T(e) : E(e), !t) throw w("apiSecret is required");
|
|
94
|
+
if (i !== void 0 && (!i || n(i))) throw w("userId must be your own identifier, not personal data such as an email address");
|
|
95
|
+
if (a.length === 0 || a.length > C) throw w(`send between 1 and ${C} events per request (received ${a.length})`);
|
|
96
|
+
for (let { name: e } of a) {
|
|
91
97
|
let t = r(e);
|
|
92
|
-
if (t) throw
|
|
98
|
+
if (t) throw w(t);
|
|
93
99
|
}
|
|
94
|
-
},
|
|
100
|
+
}, O = ({ event: { name: n, params: r = {} }, sessionId: i, warnings: a }) => {
|
|
95
101
|
for (let e of t(r)) a.push(`event "${n}" ${e}`);
|
|
96
102
|
let { params: o, redactedKeys: s } = e(r);
|
|
97
103
|
return s.length > 0 && a.push(`event "${n}": personal data redacted from ${s.join(", ")}`), {
|
|
@@ -102,48 +108,56 @@ var a = async (e) => {
|
|
|
102
108
|
...o
|
|
103
109
|
}
|
|
104
110
|
};
|
|
105
|
-
},
|
|
111
|
+
}, k = ({ adUserData: e, adPersonalization: t }) => ({
|
|
106
112
|
...e && { ad_user_data: e.toUpperCase() },
|
|
107
113
|
...t && { ad_personalization: t.toUpperCase() }
|
|
108
|
-
}),
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
114
|
+
}), A = (e) => e.firebaseAppId === void 0 ? {
|
|
115
|
+
param: "measurement_id",
|
|
116
|
+
id: e.measurementId,
|
|
117
|
+
body: { client_id: e.clientId }
|
|
118
|
+
} : {
|
|
119
|
+
param: "firebase_app_id",
|
|
120
|
+
id: e.firebaseAppId,
|
|
121
|
+
body: { app_instance_id: e.appInstanceId }
|
|
122
|
+
}, j = async (e) => {
|
|
123
|
+
D(e);
|
|
124
|
+
let { apiSecret: t, sessionId: n, userId: r, events: i, consent: a, validate: o = !1, region: s = "global" } = e, c = A(e), l = [], u = {
|
|
125
|
+
...c.body,
|
|
126
|
+
...r && { user_id: r },
|
|
127
|
+
...a && { consent: k(a) },
|
|
128
|
+
events: i.map((e) => O({
|
|
115
129
|
event: e,
|
|
116
|
-
sessionId:
|
|
117
|
-
warnings:
|
|
130
|
+
sessionId: n,
|
|
131
|
+
warnings: l
|
|
118
132
|
}))
|
|
119
|
-
},
|
|
120
|
-
|
|
121
|
-
let
|
|
133
|
+
}, d = new URL(o ? "/debug/mp/collect" : "/mp/collect", y[s]);
|
|
134
|
+
d.searchParams.set(c.param, c.id), d.searchParams.set("api_secret", t);
|
|
135
|
+
let f = await fetch(d, {
|
|
122
136
|
method: "POST",
|
|
123
137
|
headers: { "content-type": "application/json" },
|
|
124
|
-
body: JSON.stringify(
|
|
138
|
+
body: JSON.stringify(u)
|
|
125
139
|
});
|
|
126
|
-
if (!
|
|
127
|
-
ok:
|
|
128
|
-
status:
|
|
129
|
-
warnings:
|
|
140
|
+
if (!o) return {
|
|
141
|
+
ok: f.ok,
|
|
142
|
+
status: f.status,
|
|
143
|
+
warnings: l
|
|
130
144
|
};
|
|
131
|
-
let { validationMessages:
|
|
145
|
+
let { validationMessages: p = [] } = await f.json().catch(() => ({}));
|
|
132
146
|
return {
|
|
133
|
-
ok:
|
|
134
|
-
status:
|
|
135
|
-
warnings:
|
|
136
|
-
validationMessages:
|
|
147
|
+
ok: f.ok && p.length === 0,
|
|
148
|
+
status: f.status,
|
|
149
|
+
warnings: l,
|
|
150
|
+
validationMessages: p
|
|
137
151
|
};
|
|
138
|
-
},
|
|
152
|
+
}, M = ({ userAgent: e, agents: t }) => {
|
|
139
153
|
let n = e.toLowerCase();
|
|
140
154
|
return Object.entries(t).find(([, e]) => e.some((e) => n.includes(e.toLowerCase())))?.[0];
|
|
141
|
-
},
|
|
155
|
+
}, N = (e) => /* @__PURE__ */ TypeError(`[react-marketing-tools] sendAiCrawlerEvent: ${e}`), P = ["traffic_type", "ai_agent"], F = async (e) => {
|
|
142
156
|
let { agent: t, keepSeparate: n, events: r, ...i } = e;
|
|
143
|
-
if (!t) throw
|
|
157
|
+
if (!t) throw N("agent is required");
|
|
144
158
|
let a = n === "separate-property" ? "ai_crawler" : n?.trafficType;
|
|
145
|
-
if (!a) throw
|
|
146
|
-
let o = r.flatMap((e) =>
|
|
159
|
+
if (!a) throw N("keepSeparate is required: 'separate-property' when measurementId is a crawler-only property, or { trafficType } matching a GA4 data filter. Crawler hits must never land in a visitor property untagged.");
|
|
160
|
+
let o = r.flatMap((e) => P.filter((t) => e.params?.[t] !== void 0).map((t) => `event "${e.name}" param "${t}" was replaced: sendAiCrawlerEvent sets it`)), s = await j({
|
|
147
161
|
...i,
|
|
148
162
|
events: r.map((e) => ({
|
|
149
163
|
...e,
|
|
@@ -158,7 +172,7 @@ var a = async (e) => {
|
|
|
158
172
|
...s,
|
|
159
173
|
warnings: [...o, ...s.warnings]
|
|
160
174
|
};
|
|
161
|
-
},
|
|
175
|
+
}, I = 16384, L = /^\d+$/, R = [
|
|
162
176
|
"externalId",
|
|
163
177
|
"email",
|
|
164
178
|
"phone",
|
|
@@ -166,44 +180,44 @@ var a = async (e) => {
|
|
|
166
180
|
"lastName",
|
|
167
181
|
"fbc",
|
|
168
182
|
"fbp"
|
|
169
|
-
],
|
|
170
|
-
if (e.length === 0) throw
|
|
171
|
-
for (let t of e) if (!URL.canParse(t) || new URL(t).origin !== t) throw
|
|
172
|
-
if (!
|
|
173
|
-
if (!t.accessToken) throw
|
|
174
|
-
},
|
|
175
|
-
if (Number(e.headers.get("content-length")) >
|
|
183
|
+
], z = (e) => /* @__PURE__ */ TypeError(`[react-marketing-tools] createTrackHandler: ${e}`), B = ({ allowedOrigins: e, meta: t }) => {
|
|
184
|
+
if (e.length === 0) throw z("allowedOrigins needs at least one origin, e.g. 'https://shop.example.com'");
|
|
185
|
+
for (let t of e) if (!URL.canParse(t) || new URL(t).origin !== t) throw z(`allowedOrigins entries must be origins such as 'https://shop.example.com', without a path or trailing slash (received ${JSON.stringify(t)})`);
|
|
186
|
+
if (!L.test(t.pixelId)) throw z(`meta.pixelId must be the numeric pixel ID (received ${JSON.stringify(t.pixelId)})`);
|
|
187
|
+
if (!t.accessToken) throw z("meta.accessToken is required");
|
|
188
|
+
}, V = (e, t) => new Response(t, { status: e }), H = async (e) => {
|
|
189
|
+
if (Number(e.headers.get("content-length")) > I) return;
|
|
176
190
|
if (!e.body) return "";
|
|
177
191
|
let t = e.body.getReader(), n = new TextDecoder(), r = "", i = 0;
|
|
178
192
|
for (;;) {
|
|
179
193
|
let { done: e, value: a } = await t.read();
|
|
180
194
|
if (e) return r + n.decode();
|
|
181
|
-
if (i += a.byteLength, i >
|
|
195
|
+
if (i += a.byteLength, i > I) {
|
|
182
196
|
await t.cancel();
|
|
183
197
|
return;
|
|
184
198
|
}
|
|
185
199
|
r += n.decode(a, { stream: !0 });
|
|
186
200
|
}
|
|
187
|
-
},
|
|
201
|
+
}, U = (e) => typeof e == "object" && !!e && !Array.isArray(e), W = (e) => typeof e == "string" && e.length > 0, G = (e) => typeof e == "string" && URL.canParse(e) && ["http:", "https:"].includes(new URL(e).protocol), K = (e) => {
|
|
188
202
|
let t;
|
|
189
203
|
try {
|
|
190
204
|
t = JSON.parse(e);
|
|
191
205
|
} catch {
|
|
192
206
|
return;
|
|
193
207
|
}
|
|
194
|
-
if (!
|
|
208
|
+
if (!U(t) || t.v !== 1) return;
|
|
195
209
|
let { eventName: n, eventId: r, customData: i, eventSourceUrl: a, consent: o, userData: s } = t;
|
|
196
|
-
if (
|
|
210
|
+
if (W(n) && W(r) && U(i) && G(a) && U(o) && (o.adUserData === "granted" || o.adUserData === "denied") && U(s) && !R.some((e) => s[e] !== void 0 && typeof s[e] != "string")) return {
|
|
197
211
|
v: 1,
|
|
198
212
|
eventName: n,
|
|
199
213
|
eventId: r,
|
|
200
214
|
customData: i,
|
|
201
215
|
eventSourceUrl: a,
|
|
202
216
|
consent: { adUserData: o.adUserData },
|
|
203
|
-
userData: Object.fromEntries(
|
|
217
|
+
userData: Object.fromEntries(R.filter((e) => s[e] !== void 0).map((e) => [e, s[e]]))
|
|
204
218
|
};
|
|
205
|
-
},
|
|
206
|
-
|
|
219
|
+
}, q = (e) => {
|
|
220
|
+
B(e);
|
|
207
221
|
let { meta: t, onError: n = console.error } = e, r = new Set(e.allowedOrigins);
|
|
208
222
|
return async (e) => {
|
|
209
223
|
if (e.method !== "POST") return new Response("Use POST", {
|
|
@@ -211,14 +225,14 @@ var a = async (e) => {
|
|
|
211
225
|
headers: { allow: "POST" }
|
|
212
226
|
});
|
|
213
227
|
let i = e.headers.get("origin");
|
|
214
|
-
if (!i || !r.has(i)) return
|
|
215
|
-
let a = await
|
|
216
|
-
if (a === void 0) return
|
|
217
|
-
let o =
|
|
218
|
-
if (!o) return
|
|
228
|
+
if (!i || !r.has(i)) return V(403, "Origin not allowed");
|
|
229
|
+
let a = await H(e);
|
|
230
|
+
if (a === void 0) return V(413, "Body too large");
|
|
231
|
+
let o = K(a);
|
|
232
|
+
if (!o) return V(400, "Invalid event");
|
|
219
233
|
let s = e.headers.get("user-agent");
|
|
220
|
-
if (!s) return
|
|
221
|
-
if (o.consent.adUserData !== "granted") return
|
|
234
|
+
if (!s) return V(400, "Missing User-Agent");
|
|
235
|
+
if (o.consent.adUserData !== "granted") return V(204);
|
|
222
236
|
try {
|
|
223
237
|
let r = await v({
|
|
224
238
|
...t,
|
|
@@ -234,14 +248,14 @@ var a = async (e) => {
|
|
|
234
248
|
customData: o.customData
|
|
235
249
|
}]
|
|
236
250
|
});
|
|
237
|
-
if (r.ok) return
|
|
251
|
+
if (r.ok) return V(204);
|
|
238
252
|
n(Error(`[react-marketing-tools] createTrackHandler: Meta rejected "${o.eventName}" (HTTP ${r.status})`, { cause: r.body }));
|
|
239
253
|
} catch (e) {
|
|
240
254
|
n(Error(`[react-marketing-tools] createTrackHandler: could not send "${o.eventName}" to Meta`, { cause: e }));
|
|
241
255
|
}
|
|
242
|
-
return
|
|
256
|
+
return V(502, "Meta did not accept the event");
|
|
243
257
|
};
|
|
244
|
-
},
|
|
258
|
+
}, J = ({ cookieHeader: e, measurementId: t }) => {
|
|
245
259
|
let n = /^GA\d\.\d\.(\d+\.\d+)$/.exec(i(e, "_ga") ?? "")?.[1], r = i(e, `_ga_${t.replace(/^G-/, "")}`) ?? "", a = /^GS1\.\d\.(\d+)\./.exec(r)?.[1] ?? /^GS2\.\d\.s(\d+)/.exec(r)?.[1];
|
|
246
260
|
return {
|
|
247
261
|
...n && { clientId: n },
|
|
@@ -249,4 +263,4 @@ var a = async (e) => {
|
|
|
249
263
|
};
|
|
250
264
|
};
|
|
251
265
|
//#endregion
|
|
252
|
-
export { f as META_GRAPH_API_VERSION,
|
|
266
|
+
export { f as META_GRAPH_API_VERSION, q as createTrackHandler, M as matchAiAgent, J as readGa4Cookies, F as sendAiCrawlerEvent, v as sendConversionsApiEvent, j as sendMeasurementProtocolEvent };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-marketing-tools",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.0
|
|
4
|
+
"version": "1.1.0",
|
|
5
5
|
"description": "One track() call for Google Tag Manager, Google Analytics 4 and the Meta Pixel, with Consent Mode v2, UTM attribution and personal-data redaction built in. React bindings included.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "bronz3beard <exempli.gratia.webdesign@gmail.com> (https://www.heyrory.com/)",
|
|
@@ -101,6 +101,9 @@
|
|
|
101
101
|
"@fingerprintjs/fingerprintjs": {
|
|
102
102
|
"optional": true
|
|
103
103
|
},
|
|
104
|
+
"react": {
|
|
105
|
+
"optional": true
|
|
106
|
+
},
|
|
104
107
|
"web-vitals": {
|
|
105
108
|
"optional": true
|
|
106
109
|
}
|
|
@@ -113,6 +116,7 @@
|
|
|
113
116
|
"@types/react": "^19.3.0",
|
|
114
117
|
"@types/react-dom": "^19.3.0",
|
|
115
118
|
"@typescript/native": "npm:typescript@^7.0.2",
|
|
119
|
+
"@vitest/coverage-v8": "^5.0.1",
|
|
116
120
|
"eslint": "^10.11.0",
|
|
117
121
|
"eslint-config-prettier": "^10.1.8",
|
|
118
122
|
"eslint-plugin-react-hooks": "^7.1.1",
|