@mitchdesigns/tracking 1.2.4 → 1.3.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/bin/cli.mjs ADDED
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env node
2
+ // @mitchdesigns/tracking installer
3
+ // Usage: pnpm dlx @mitchdesigns/tracking init [options]
4
+ //
5
+ // Scaffolds everything needed to use the tracking package in a Next.js project:
6
+ // - installs the package
7
+ // - .env.example tracking vars
8
+ // - transpilePackages entry in next.config.*
9
+ // - src/app/api/meta/capi/route.ts
10
+ // - src/components/meta/MetaEventTemplates.tsx
11
+ //
12
+ // Options:
13
+ // --dry-run show what would change, write nothing
14
+ // --yes, -y skip the install step's confirmation
15
+ // --no-install don't run the package manager install
16
+ // --pm <pnpm|npm|yarn> force a package manager (default: auto-detect)
17
+
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ import { execSync } from "node:child_process";
22
+
23
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
+ const PKG = "@mitchdesigns/tracking";
25
+ const TEMPLATES = path.join(__dirname, "..", "templates");
26
+
27
+ // ---- args -----------------------------------------------------------------
28
+ const argv = process.argv.slice(2);
29
+ const cmd = argv.find((a) => !a.startsWith("-")) ?? "init";
30
+ const has = (f) => argv.includes(f);
31
+ const flagVal = (f) => {
32
+ const i = argv.indexOf(f);
33
+ return i !== -1 ? argv[i + 1] : undefined;
34
+ };
35
+ const DRY = has("--dry-run");
36
+ const YES = has("--yes") || has("-y");
37
+ const NO_INSTALL = has("--no-install");
38
+
39
+ // ---- tiny logger ----------------------------------------------------------
40
+ const c = {
41
+ g: (s) => `\x1b[32m${s}\x1b[0m`,
42
+ y: (s) => `\x1b[33m${s}\x1b[0m`,
43
+ r: (s) => `\x1b[31m${s}\x1b[0m`,
44
+ d: (s) => `\x1b[2m${s}\x1b[0m`,
45
+ b: (s) => `\x1b[1m${s}\x1b[0m`,
46
+ };
47
+ const done = (m) => console.log(` ${c.g("✔")} ${m}`);
48
+ const skip = (m) => console.log(` ${c.d("•")} ${c.d(m)}`);
49
+ const warn = (m) => console.log(` ${c.y("!")} ${m}`);
50
+ const note = (m) => console.log(` ${c.d("→")} ${c.d(m)}`);
51
+
52
+ const root = process.cwd();
53
+
54
+ function write(rel, contents, { merge } = {}) {
55
+ const abs = path.join(root, rel);
56
+ if (fs.existsSync(abs)) {
57
+ const existing = fs.readFileSync(abs, "utf8");
58
+ if (merge) {
59
+ const merged = merge(existing);
60
+ if (merged == null) return skip(`${rel} already configured`);
61
+ if (DRY) return warn(`would update ${rel}`);
62
+ fs.writeFileSync(abs, merged);
63
+ return done(`updated ${rel}`);
64
+ }
65
+ return skip(`${rel} exists — left untouched`);
66
+ }
67
+ if (DRY) return warn(`would create ${rel}`);
68
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
69
+ fs.writeFileSync(abs, contents);
70
+ done(`created ${rel}`);
71
+ }
72
+
73
+ function detectPM() {
74
+ const forced = flagVal("--pm");
75
+ if (forced) return forced;
76
+ if (fs.existsSync(path.join(root, "pnpm-lock.yaml"))) return "pnpm";
77
+ if (fs.existsSync(path.join(root, "yarn.lock"))) return "yarn";
78
+ if (fs.existsSync(path.join(root, "package-lock.json"))) return "npm";
79
+ return "pnpm";
80
+ }
81
+
82
+ function srcPrefix() {
83
+ // honor src/ vs app/ at repo root
84
+ return fs.existsSync(path.join(root, "src")) ? "src" : "";
85
+ }
86
+
87
+ // ---- steps ----------------------------------------------------------------
88
+
89
+ function checkProject() {
90
+ if (!fs.existsSync(path.join(root, "package.json"))) {
91
+ console.error(c.r(`No package.json in ${root}. Run this from your project root.`));
92
+ process.exit(1);
93
+ }
94
+ }
95
+
96
+ function installPackage(pm) {
97
+ if (NO_INSTALL) return skip("skipped install (--no-install)");
98
+ const cmds = {
99
+ pnpm: `pnpm add ${PKG}`,
100
+ npm: `npm install ${PKG}`,
101
+ yarn: `yarn add ${PKG}`,
102
+ };
103
+ const command = cmds[pm] ?? cmds.pnpm;
104
+ if (DRY) return warn(`would run: ${command}`);
105
+ console.log(` ${c.d("$")} ${command}`);
106
+ try {
107
+ execSync(command, { stdio: "inherit", cwd: root });
108
+ done(`installed ${PKG}`);
109
+ } catch {
110
+ warn(`install failed — run \`${command}\` manually`);
111
+ }
112
+ }
113
+
114
+ function ensureEnv() {
115
+ const stub = fs.readFileSync(path.join(TEMPLATES, "env.example"), "utf8");
116
+ write(".env.example", stub, {
117
+ merge: (existing) =>
118
+ existing.includes("NEXT_PUBLIC_META_PIXEL_ID") ? null : `${existing.trimEnd()}\n\n${stub}`,
119
+ });
120
+ }
121
+
122
+ function patchNextConfig() {
123
+ const candidates = ["next.config.mjs", "next.config.js", "next.config.ts"];
124
+ const file = candidates.find((f) => fs.existsSync(path.join(root, f)));
125
+ if (!file) {
126
+ warn("no next.config.* found — add transpilePackages manually:");
127
+ note(`transpilePackages: ["${PKG}"]`);
128
+ return;
129
+ }
130
+ const abs = path.join(root, file);
131
+ let src = fs.readFileSync(abs, "utf8");
132
+
133
+ if (src.includes(PKG)) return skip(`${file} already lists ${PKG}`);
134
+
135
+ if (/transpilePackages\s*:\s*\[/.test(src)) {
136
+ // append to the existing array
137
+ src = src.replace(/transpilePackages\s*:\s*\[/, (m) => `${m}"${PKG}", `);
138
+ } else {
139
+ // insert a new key into the first config object literal
140
+ const m = src.match(/(\b(?:const|let|var)\s+\w+\s*(?::[^=]+)?=\s*\{)/);
141
+ if (!m) {
142
+ warn(`couldn't auto-edit ${file} — add transpilePackages manually:`);
143
+ note(`transpilePackages: ["${PKG}"],`);
144
+ return;
145
+ }
146
+ src = src.replace(m[0], `${m[0]}\n transpilePackages: ["${PKG}"],`);
147
+ }
148
+ if (DRY) return warn(`would update ${file}`);
149
+ fs.writeFileSync(abs, src);
150
+ done(`added transpilePackages to ${file}`);
151
+ }
152
+
153
+ function scaffoldFiles() {
154
+ const sp = srcPrefix();
155
+ const routeDir = path.join(sp, "app", "api", "meta", "capi");
156
+ write(
157
+ path.join(routeDir, "route.ts"),
158
+ fs.readFileSync(path.join(TEMPLATES, "capi-route.ts"), "utf8"),
159
+ );
160
+ write(
161
+ path.join(sp, "components", "meta", "MetaEventTemplates.tsx"),
162
+ fs.readFileSync(path.join(TEMPLATES, "MetaEventTemplates.tsx"), "utf8"),
163
+ );
164
+ }
165
+
166
+ function finalNotes() {
167
+ console.log("");
168
+ console.log(c.b("Almost done — two manual steps:"));
169
+ console.log("");
170
+ console.log(` 1. Fill in your IDs/tokens in ${c.b(".env")} (see .env.example).`);
171
+ console.log(` 2. Mount the pixel in your root layout (inside <body>):`);
172
+ console.log(
173
+ c.d(
174
+ [
175
+ ``,
176
+ ` import { MetaPixel } from "${PKG}";`,
177
+ ` import { Suspense } from "react";`,
178
+ ``,
179
+ ` {process.env.NODE_ENV === "production" &&`,
180
+ ` process.env.NEXT_PUBLIC_META_PIXEL_ID && (`,
181
+ ` <Suspense><MetaPixel /></Suspense>`,
182
+ ` )}`,
183
+ ``,
184
+ ].join("\n"),
185
+ ),
186
+ );
187
+ console.log(c.d(` MetaEventTemplates.tsx takes user/cart/products as props — wire them to your own store/auth where you render each component.`));
188
+ console.log("");
189
+ console.log(c.g("Tracking is wired up. 🎯"));
190
+ }
191
+
192
+ // ---- run ------------------------------------------------------------------
193
+ if (cmd !== "init") {
194
+ console.log(`Unknown command "${cmd}". Usage: ${PKG} init [--dry-run] [--yes] [--no-install] [--pm <pm>]`);
195
+ process.exit(1);
196
+ }
197
+
198
+ console.log(c.b(`\n${PKG} → installing into ${path.basename(root)}\n`));
199
+ if (DRY) warn("dry run — no files will be written\n");
200
+
201
+ checkProject();
202
+ const pm = detectPM();
203
+ note(`package manager: ${pm}`);
204
+ installPackage(pm);
205
+ ensureEnv();
206
+ patchNextConfig();
207
+ scaffoldFiles();
208
+ finalNotes();
package/package.json CHANGED
@@ -1,13 +1,22 @@
1
1
  {
2
2
  "name": "@mitchdesigns/tracking",
3
- "version": "1.2.4",
4
- "description": "Tracking package for Next.js Meta Pixel, Meta CAPI, and Google Tag Manager hooks and components",
3
+ "version": "1.3.0",
4
+ "description": "Meta Pixel + CAPI + GTM tracking for Next.js, with a one-command project installer.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/ojja/titans-tracking.git"
8
+ },
5
9
  "license": "UNLICENSED",
6
- "files": [
7
- "src"
8
- ],
9
10
  "main": "./src/index.ts",
10
11
  "types": "./src/index.ts",
12
+ "bin": {
13
+ "mitch-tracking": "bin/cli.mjs"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "bin",
18
+ "templates"
19
+ ],
11
20
  "exports": {
12
21
  ".": {
13
22
  "types": "./src/index.ts",
@@ -0,0 +1,247 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+ import { useMetaTracking } from "@mitchdesigns/tracking";
5
+
6
+ /**
7
+ * Drop-in tracking components.
8
+ *
9
+ * These are intentionally framework-agnostic: they take all data (user, cart,
10
+ * products) as props rather than reaching into a specific store or auth layer.
11
+ * Wire them to your own data source where you render them, e.g.:
12
+ *
13
+ * const user = useCurrentUser(); // your auth
14
+ * const cartItems = useCart(); // your store
15
+ * <InitiateCheckoutTracking cartItems={cartItems} user={user} />
16
+ *
17
+ * `user` is optional — when provided it is forwarded to the CAPI for better
18
+ * match quality. `currency` defaults to "USD"; override per component or set a
19
+ * project-wide default below.
20
+ */
21
+
22
+ const DEFAULT_CURRENCY = "USD";
23
+
24
+ // Loose shapes — adapt to your domain models as needed.
25
+ type AnyUser = Record<string, any> | null | undefined;
26
+
27
+ type CartItem = {
28
+ id?: string | number;
29
+ product_id?: string | number;
30
+ sku?: string;
31
+ price: number;
32
+ quantity: number;
33
+ [key: string]: any;
34
+ };
35
+
36
+ type Product = {
37
+ id?: string | number;
38
+ sku?: string;
39
+ name?: string | { en?: string };
40
+ price?: number;
41
+ categories?: { name?: string }[];
42
+ [key: string]: any;
43
+ };
44
+
45
+ type OrderItem = {
46
+ id?: string | number;
47
+ product_id?: string | number;
48
+ price?: number;
49
+ total_amount?: number;
50
+ quantity?: number;
51
+ [key: string]: any;
52
+ };
53
+
54
+ const productName = (name: Product["name"]) =>
55
+ typeof name === "object" ? name?.en : name;
56
+
57
+ const itemId = (item: { sku?: string; product_id?: string | number; id?: string | number }) =>
58
+ item.sku?.toString() || item.product_id?.toString() || item.id?.toString() || "unknown";
59
+
60
+ /**
61
+ * Drop inside the product page provider/context.
62
+ * Fires ViewContent once per product — guarded against re-renders (e.g. after add to cart).
63
+ */
64
+ export const ProductViewTracking = ({
65
+ product,
66
+ user,
67
+ currency = DEFAULT_CURRENCY,
68
+ }: {
69
+ product: Product | null | undefined;
70
+ user?: AnyUser;
71
+ currency?: string;
72
+ }) => {
73
+ const { trackViewContent } = useMetaTracking(user);
74
+ const trackedId = useRef<string | null>(null);
75
+
76
+ useEffect(() => {
77
+ if (!product) return;
78
+ const productId = product.sku || product.id?.toString();
79
+ if (!productId || trackedId.current === productId) return;
80
+ trackedId.current = productId;
81
+ trackViewContent({
82
+ content_ids: [productId],
83
+ content_name: productName(product.name),
84
+ content_category: product.categories?.[0]?.name,
85
+ value: product.price,
86
+ currency,
87
+ });
88
+ }, [product, user, currency, trackViewContent]);
89
+
90
+ return null;
91
+ };
92
+
93
+ /**
94
+ * Drop at the top of the checkout page.
95
+ * Fires InitiateCheckout once on mount.
96
+ */
97
+ export const InitiateCheckoutTracking = ({
98
+ cartItems,
99
+ user,
100
+ currency = DEFAULT_CURRENCY,
101
+ }: {
102
+ cartItems: CartItem[];
103
+ user?: AnyUser;
104
+ currency?: string;
105
+ }) => {
106
+ const { trackInitiateCheckout } = useMetaTracking(user);
107
+
108
+ useEffect(() => {
109
+ if (!cartItems || cartItems.length === 0) return;
110
+ const total = cartItems.reduce((acc, item) => acc + item.price * item.quantity, 0);
111
+ trackInitiateCheckout({
112
+ content_ids: cartItems.map(itemId),
113
+ contents: cartItems.map((item) => ({
114
+ id: itemId(item),
115
+ quantity: item.quantity,
116
+ item_price: item.price,
117
+ })),
118
+ value: total,
119
+ currency,
120
+ });
121
+ // eslint-disable-next-line react-hooks/exhaustive-deps
122
+ }, []); // only on mount
123
+
124
+ return null;
125
+ };
126
+
127
+ /**
128
+ * Drop inside the shop/category page controller.
129
+ * Fires ViewCategory when products load, and again if category or count changes.
130
+ */
131
+ export const CategoryViewTracking = ({
132
+ products,
133
+ shoppingCategory,
134
+ categoryNameEn,
135
+ user,
136
+ }: {
137
+ products: Product[] | undefined;
138
+ shoppingCategory?: string;
139
+ categoryNameEn?: string;
140
+ user?: AnyUser;
141
+ }) => {
142
+ const { trackViewCategory } = useMetaTracking(user);
143
+ const lastTrackedCategory = useRef<string | null>(null);
144
+ const lastTrackedCount = useRef<number>(0);
145
+
146
+ useEffect(() => {
147
+ if (!products || products.length === 0) return;
148
+ const currentCount = products.length;
149
+ const normalizedCategory = shoppingCategory || null;
150
+ if (
151
+ lastTrackedCategory.current === normalizedCategory &&
152
+ lastTrackedCount.current === currentCount
153
+ ) return;
154
+ const currentIds = products.slice(0, 30).map((p) => p.sku || p.id?.toString() || "unknown");
155
+ trackViewCategory({
156
+ content_category: categoryNameEn || shoppingCategory || "unknown",
157
+ content_ids: currentIds,
158
+ });
159
+ lastTrackedCategory.current = normalizedCategory;
160
+ lastTrackedCount.current = currentCount;
161
+ }, [products, shoppingCategory, categoryNameEn, trackViewCategory]);
162
+
163
+ return null;
164
+ };
165
+
166
+ /**
167
+ * Drop on the thank-you / order confirmation page.
168
+ * Fires Purchase — uses orderNumber as the deduplication key.
169
+ */
170
+ export const PurchaseTracking = ({
171
+ orderNumber,
172
+ items,
173
+ total,
174
+ user,
175
+ currency = DEFAULT_CURRENCY,
176
+ }: {
177
+ orderNumber: string;
178
+ items: OrderItem[];
179
+ total: number;
180
+ user?: AnyUser;
181
+ currency?: string;
182
+ }) => {
183
+ const { trackPurchase } = useMetaTracking(user);
184
+
185
+ useEffect(() => {
186
+ if (!items || items.length === 0) return;
187
+ trackPurchase({
188
+ content_ids: items.map(itemId),
189
+ contents: items.map((item) => {
190
+ const quantity = Number(item.quantity) || 1;
191
+ const itemPrice =
192
+ Number(item.price) ||
193
+ (item.total_amount ? Number(item.total_amount) / quantity : 0) ||
194
+ total / items.length ||
195
+ 0;
196
+ return {
197
+ id: itemId(item),
198
+ quantity,
199
+ item_price: Number(itemPrice.toFixed(2)),
200
+ };
201
+ }),
202
+ value: Number(total.toFixed(2)),
203
+ currency,
204
+ numItems: items.reduce((acc, item) => acc + (Number(item.quantity) || 1), 0),
205
+ orderId: orderNumber,
206
+ });
207
+ }, [orderNumber, items, total, user, currency, trackPurchase]);
208
+
209
+ return null;
210
+ };
211
+
212
+ /**
213
+ * Drop inside the checkout stepper.
214
+ * Fires AddPaymentInfo when `activeStep` reaches `triggerStep` (default 2).
215
+ */
216
+ export const AddPaymentInfoTracking = ({
217
+ activeStep,
218
+ cartItems,
219
+ user,
220
+ triggerStep = 2,
221
+ currency = DEFAULT_CURRENCY,
222
+ }: {
223
+ activeStep: number;
224
+ cartItems: CartItem[];
225
+ user?: AnyUser;
226
+ triggerStep?: number;
227
+ currency?: string;
228
+ }) => {
229
+ const { trackAddPaymentInfo } = useMetaTracking(user);
230
+
231
+ useEffect(() => {
232
+ if (activeStep !== triggerStep || !cartItems || cartItems.length === 0) return;
233
+ const total = cartItems.reduce((acc, item) => acc + item.price * item.quantity, 0);
234
+ trackAddPaymentInfo({
235
+ content_ids: cartItems.map(itemId),
236
+ contents: cartItems.map((item) => ({
237
+ id: itemId(item),
238
+ quantity: item.quantity,
239
+ item_price: item.price,
240
+ })),
241
+ value: total,
242
+ currency,
243
+ });
244
+ }, [activeStep, triggerStep, cartItems, user, currency, trackAddPaymentInfo]);
245
+
246
+ return null;
247
+ };
@@ -0,0 +1,2 @@
1
+ export const runtime = "edge";
2
+ export { POST } from "@mitchdesigns/tracking/server";
@@ -0,0 +1,9 @@
1
+ # --- @mitchdesigns/tracking ---
2
+ # Meta Pixel + Conversions API
3
+ NEXT_PUBLIC_META_PIXEL_ID=your_pixel_id
4
+ META_ACCESS_TOKEN=your_capi_access_token
5
+ # Remove before production:
6
+ NEXT_PUBLIC_META_TEST_EVENT_CODE=TESTxxxxx
7
+
8
+ # Google Tag Manager (optional)
9
+ NEXT_PUBLIC_GTM_ID=GTM-XXXXXXX