@m13v/seo-components 0.37.0 → 0.38.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/seo-components",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "scripts": {
5
5
  "build:css": "tailwind -i src/_build.css -o dist/styles.css --minify",
6
6
  "lint:mobile-spans": "node scripts/lint-mobile-spans.mjs",
@@ -1,4 +1,5 @@
1
1
  import { NextRequest } from "next/server";
2
+ import { capturePostHogServer } from "./posthog-capture";
2
3
 
3
4
  /* ------------------------------------------------------------------ */
4
5
  /* Default welcome email template (light theme, teal brand) */
@@ -116,12 +117,24 @@ export interface NewsletterConfig {
116
117
  * Receives the subscriber email and should return an HTML string.
117
118
  */
118
119
  welcomeHtml?: (email: string) => string;
120
+ /**
121
+ * Optional plain-text MIME part for the welcome email. Useful when the
122
+ * email body contains commands or URLs that whitespace-collapse when
123
+ * Resend auto-extracts text from HTML (claude-meter, assrt, etc.).
124
+ */
125
+ welcomeText?: (email: string) => string;
119
126
  /**
120
127
  * Optional: log the signup to a database.
121
128
  * Called after the email is sent successfully.
122
129
  * Receives the subscriber email and the Resend email ID.
123
130
  */
124
131
  onSignup?: (email: string, resendEmailId: string | null) => Promise<void>;
132
+ /**
133
+ * Short slug identifying the site (e.g. "fazm", "mediar", "claude-meter").
134
+ * Attached to the server-side PostHog `newsletter_subscribed_server` event
135
+ * so the dashboard can bucket signups by site even if `$host` is blocked.
136
+ */
137
+ site?: string;
125
138
  }
126
139
 
127
140
 
@@ -139,7 +152,9 @@ export function createNewsletterHandler(config: NewsletterConfig) {
139
152
  apiKeyEnv = "RESEND_API_KEY",
140
153
  welcomeSubject,
141
154
  welcomeHtml,
155
+ welcomeText,
142
156
  onSignup,
157
+ site,
143
158
  } = config;
144
159
 
145
160
  return async function POST(req: NextRequest) {
@@ -204,6 +219,8 @@ export function createNewsletterHandler(config: NewsletterConfig) {
204
219
 
205
220
  let resendEmailId: string | null = null;
206
221
 
222
+ const text = welcomeText ? welcomeText(email) : undefined;
223
+
207
224
  const emailRes = await fetch("https://api.resend.com/emails", {
208
225
  method: "POST",
209
226
  headers: {
@@ -215,6 +232,7 @@ export function createNewsletterHandler(config: NewsletterConfig) {
215
232
  to: email,
216
233
  subject,
217
234
  html,
235
+ ...(text ? { text } : {}),
218
236
  }),
219
237
  });
220
238
 
@@ -236,6 +254,30 @@ export function createNewsletterHandler(config: NewsletterConfig) {
236
254
  }
237
255
  }
238
256
 
257
+ // 4. Server-side PostHog capture (ground truth, not ad-blocked).
258
+ // Fires once per successful signup. The dashboard's funnel_per_day.py
259
+ // counts `newsletter_subscribed_server` for the "Email Signups" column.
260
+ // Distinct from the client-side `newsletter_subscribed` event so we
261
+ // don't double-count when both fire.
262
+ let host: string | undefined;
263
+ try {
264
+ host = req.headers.get("host") || new URL(siteUrl).hostname;
265
+ } catch {
266
+ host = undefined;
267
+ }
268
+ void capturePostHogServer({
269
+ event: "newsletter_subscribed_server",
270
+ distinctId: email,
271
+ host,
272
+ properties: {
273
+ email,
274
+ site: site || brand.toLowerCase(),
275
+ brand,
276
+ resend_email_id: resendEmailId,
277
+ component: "createNewsletterHandler",
278
+ },
279
+ });
280
+
239
281
  return new Response(
240
282
  JSON.stringify({ success: true }),
241
283
  { status: 200, headers: { "content-type": "application/json" } },
@@ -0,0 +1,100 @@
1
+ // Server-side PostHog capture helper.
2
+ //
3
+ // PostHog's client-side `posthog.capture()` is killed by ad-blockers (uBlock,
4
+ // Brave shields, Privacy Badger), DNT, and any privacy extension that drops
5
+ // the /e/ ingest call. That makes any client-only metric (newsletter signups,
6
+ // download clicks, schedule clicks) lossy by 30 to 50 percent against the
7
+ // real server-truth count.
8
+ //
9
+ // Use this helper from API routes that already have server-side ground truth
10
+ // (a Resend send succeeded, a Cal.com webhook fired, a paid checkout closed)
11
+ // to fire a parallel PostHog event from the server. Server captures land on
12
+ // the same `events` table, so the dashboard's existing HogQL queries pick
13
+ // them up the moment the event clause is updated.
14
+ //
15
+ // Convention: name server events with a `_server` suffix so they never
16
+ // collide with the client event of the same intent. The dashboard chooses
17
+ // which one to count; both can coexist for transition windows.
18
+
19
+ interface CaptureOptions {
20
+ /** Event name. Convention: suffix with `_server` (e.g. "newsletter_subscribed_server"). */
21
+ event: string;
22
+ /**
23
+ * Stable identifier for the user. For email-gated flows this is the
24
+ * lowercased email so PostHog stitches with the client-side identify(email)
25
+ * that fires once the welcome email click drops them back on the site.
26
+ */
27
+ distinctId: string;
28
+ /**
29
+ * Event properties. The helper auto-merges `$host` (so the dashboard's
30
+ * `properties.$host IN (...)` domain filter matches) and a few standard
31
+ * keys, but anything in here wins over the auto-merged values.
32
+ */
33
+ properties?: Record<string, unknown>;
34
+ /**
35
+ * Hostname of the request. Pass `req.headers.get("host")` from a Next.js
36
+ * Route Handler. Used to populate `$host` so events bucket by domain in
37
+ * the dashboard. Falls back to a config default if omitted.
38
+ */
39
+ host?: string;
40
+ /**
41
+ * PostHog write key. Defaults to `process.env.NEXT_PUBLIC_POSTHOG_KEY`,
42
+ * then `process.env.POSTHOG_KEY`. The "public" key is the project's ingest
43
+ * key; it's safe to use server-side because it's already shipped to every
44
+ * browser.
45
+ */
46
+ apiKey?: string;
47
+ /**
48
+ * PostHog ingest host. Defaults to `process.env.NEXT_PUBLIC_POSTHOG_HOST`,
49
+ * then https://us.i.posthog.com.
50
+ */
51
+ apiHost?: string;
52
+ }
53
+
54
+ /**
55
+ * Fire a server-side PostHog capture. Never throws; logs and returns false
56
+ * on any failure so a tracking miss can't take down the calling route.
57
+ *
58
+ * Returns true on a 2xx ingest response, false otherwise.
59
+ */
60
+ export async function capturePostHogServer(opts: CaptureOptions): Promise<boolean> {
61
+ const apiKey = opts.apiKey || process.env.NEXT_PUBLIC_POSTHOG_KEY || process.env.POSTHOG_KEY;
62
+ if (!apiKey) {
63
+ console.warn("[posthog-server] no PostHog key in env; skipping", opts.event);
64
+ return false;
65
+ }
66
+ const apiHost =
67
+ opts.apiHost || process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com";
68
+
69
+ const properties: Record<string, unknown> = {
70
+ ...(opts.host ? { $host: opts.host } : {}),
71
+ source: "server",
72
+ ...(opts.properties || {}),
73
+ };
74
+
75
+ try {
76
+ const res = await fetch(`${apiHost.replace(/\/$/, "")}/i/v0/e/`, {
77
+ method: "POST",
78
+ headers: { "Content-Type": "application/json" },
79
+ body: JSON.stringify({
80
+ api_key: apiKey,
81
+ event: opts.event,
82
+ distinct_id: opts.distinctId,
83
+ properties,
84
+ timestamp: new Date().toISOString(),
85
+ }),
86
+ });
87
+ if (!res.ok) {
88
+ const detail = await res.text().catch(() => "");
89
+ console.warn(
90
+ `[posthog-server] capture ${opts.event} returned ${res.status}:`,
91
+ detail.slice(0, 200),
92
+ );
93
+ return false;
94
+ }
95
+ return true;
96
+ } catch (err) {
97
+ console.warn(`[posthog-server] capture ${opts.event} threw:`, err);
98
+ return false;
99
+ }
100
+ }
package/src/server.ts CHANGED
@@ -37,6 +37,8 @@ export { getSupabaseAdmin } from "./lib/supabase-admin";
37
37
  export { createNewsletterHandler } from "./lib/newsletter-route";
38
38
  export type { NewsletterConfig } from "./lib/newsletter-route";
39
39
 
40
+ export { capturePostHogServer } from "./lib/posthog-capture";
41
+
40
42
  export { createBookCallHandler } from "./lib/book-call-route";
41
43
  export type { BookCallConfig } from "./lib/book-call-route";
42
44