@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 +1 -1
- package/src/lib/newsletter-route.ts +42 -0
- package/src/lib/posthog-capture.ts +100 -0
- package/src/server.ts +2 -0
package/package.json
CHANGED
|
@@ -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
|
|