@m13v/seo-components 0.39.0 → 0.41.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.39.0",
3
+ "version": "0.41.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",
@@ -24,6 +24,11 @@
24
24
  * greeting initial system bubble copy
25
25
  * replyEta "usually within 2 hours" line under greeting
26
26
  * accent tailwind color name for the bubble (default: "teal")
27
+ * userEmail pre-fill from consumer auth (Firebase/Clerk/NextAuth) so
28
+ * authed users don't get re-asked for their email. Falls back
29
+ * to the built-in prompt for anonymous users (undefined / "").
30
+ * userName pre-fill display name; used as sender_name on outgoing
31
+ * messages and forwarded to backend for personalized replies.
27
32
  */
28
33
 
29
34
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -56,6 +61,23 @@ export interface FounderChatPanelProps {
56
61
  replyEta?: string;
57
62
  /** Whether to require email before allowing the first message (default: true). */
58
63
  requireEmail?: boolean;
64
+ /**
65
+ * Optional: pre-fill the visitor's email from the consumer app's auth system,
66
+ * so already-authenticated users (Firebase, Clerk, NextAuth, etc.) don't get
67
+ * asked again. When set to a non-empty value, the email gate is skipped on
68
+ * mount and the value is also persisted to localStorage so subsequent
69
+ * anonymous sessions on the same device reuse it.
70
+ *
71
+ * Pass undefined / empty string for anonymous users, the panel will fall back
72
+ * to its built-in email prompt.
73
+ */
74
+ userEmail?: string;
75
+ /**
76
+ * Optional: pre-fill the visitor's display name. Used as the optimistic
77
+ * "sender_name" on outgoing messages and forwarded to the backend so founder
78
+ * replies can address the user by name.
79
+ */
80
+ userName?: string;
59
81
  }
60
82
 
61
83
  function capture(event: string, props?: Record<string, unknown>) {
@@ -116,6 +138,8 @@ export function FounderChatPanel({
116
138
  greeting,
117
139
  replyEta = "usually replies within a couple hours",
118
140
  requireEmail = true,
141
+ userEmail,
142
+ userName,
119
143
  }: FounderChatPanelProps) {
120
144
  const [open, setOpen] = useState(false);
121
145
  const [messages, setMessages] = useState<ApiMessage[]>([]);
@@ -141,6 +165,19 @@ export function FounderChatPanel({
141
165
  }
142
166
  }, [project]);
143
167
 
168
+ // Prefer the consumer-supplied authed email over the localStorage one, and
169
+ // re-sync whenever the prop changes (e.g. the user signs in via Google
170
+ // popup after the panel was already mounted on the page).
171
+ useEffect(() => {
172
+ if (!userEmail) return;
173
+ const trimmed = userEmail.trim();
174
+ if (!trimmed) return;
175
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) return;
176
+ setEmail(trimmed);
177
+ setEmailSubmitted(true);
178
+ setStoredEmail(trimmed);
179
+ }, [userEmail]);
180
+
144
181
  // Auto-scroll to bottom on new messages.
145
182
  useEffect(() => {
146
183
  if (!scrollRef.current) return;
@@ -188,7 +225,7 @@ export function FounderChatPanel({
188
225
  const optimistic: ApiMessage = {
189
226
  id: -Date.now(),
190
227
  sender: "visitor",
191
- sender_name: email || "you",
228
+ sender_name: userName || email || "you",
192
229
  text,
193
230
  createdAt: new Date().toISOString(),
194
231
  };
@@ -202,6 +239,10 @@ export function FounderChatPanel({
202
239
  threadId: threadId || undefined,
203
240
  text,
204
241
  email: email || undefined,
242
+ // Forwarded to the backend so the visitor row gets a real display
243
+ // name (web_chat_threads.visitor_name); the route handler reads this
244
+ // as `name` per /api/web-chat/send/route.ts.
245
+ name: userName || undefined,
205
246
  pageUrl: typeof window !== "undefined" ? window.location.href : undefined,
206
247
  referrer: typeof document !== "undefined" ? document.referrer || undefined : undefined,
207
248
  };
@@ -229,7 +270,7 @@ export function FounderChatPanel({
229
270
  } finally {
230
271
  setSending(false);
231
272
  }
232
- }, [apiOrigin, draft, email, emailSubmitted, fetchThread, project, requireEmail, sending, threadId]);
273
+ }, [apiOrigin, draft, email, emailSubmitted, fetchThread, project, requireEmail, sending, threadId, userName]);
233
274
 
234
275
  const onSubmitEmail = useCallback(() => {
235
276
  const trimmed = email.trim();
@@ -145,7 +145,7 @@ export function createDownloadHandler(config: DownloadHandlerConfig) {
145
145
  } catch {
146
146
  host = undefined;
147
147
  }
148
- void capturePostHogServer({
148
+ await capturePostHogServer({
149
149
  event: posthogEvent,
150
150
  distinctId: email,
151
151
  host,
@@ -255,17 +255,17 @@ export function createNewsletterHandler(config: NewsletterConfig) {
255
255
  }
256
256
 
257
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.
258
+ // Awaited (not `void`) so the fetch completes before the response is
259
+ // returned. On Cloud Run / serverless runtimes the container can be
260
+ // frozen the moment the response is sent, killing fire-and-forget
261
+ // fetches. Adds ~50-150ms latency, worth it for reliable funnel data.
262
262
  let host: string | undefined;
263
263
  try {
264
264
  host = req.headers.get("host") || new URL(siteUrl).hostname;
265
265
  } catch {
266
266
  host = undefined;
267
267
  }
268
- void capturePostHogServer({
268
+ await capturePostHogServer({
269
269
  event: "newsletter_subscribed_server",
270
270
  distinctId: email,
271
271
  host,
@@ -1,4 +1,5 @@
1
1
  import { NextRequest } from "next/server";
2
+ import { capturePostHogServer } from "./posthog-capture";
2
3
 
3
4
  /* ------------------------------------------------------------------ */
4
5
  /* Resend Inbound webhook handler */
@@ -103,6 +104,26 @@ export interface ResendInboundConfig {
103
104
  resendId: string;
104
105
  timestamp: string;
105
106
  }) => Promise<void>;
107
+ /**
108
+ * Optional: fire server-side PostHog events for delivery lifecycle
109
+ * (`email_sent_server`, `email_delivered_server`, `email_opened_server`,
110
+ * `email_clicked_server`, `email_bounced_server`, `email_complained_server`,
111
+ * `email_delayed_server`). When set, the webhook receives ALL delivery
112
+ * events for the connected Resend workspace, so use `fromDomainFilter` to
113
+ * scope to this site only.
114
+ */
115
+ posthog?: {
116
+ /** Site slug attached to the PostHog event (e.g. "claude-meter"). */
117
+ site: string;
118
+ /**
119
+ * Only fire PostHog events when the email's `from` address ends in this
120
+ * domain (e.g. "claude-meter.com"). Critical because Resend webhooks are
121
+ * registered at workspace level: every delivery event for every domain
122
+ * in the account hits every webhook. Without this filter, claude-meter's
123
+ * PostHog gets polluted by fazm / mediar / vipassana delivery events.
124
+ */
125
+ fromDomainFilter: string;
126
+ };
106
127
  /**
107
128
  * Skip forwarding (only persist + return 200). Useful for tests, or for
108
129
  * sites where the DB log is the only consumer.
@@ -145,6 +166,7 @@ export function createResendInboundHandler(config: ResendInboundConfig) {
145
166
  apiKeyEnv = "RESEND_API_KEY",
146
167
  onInbound,
147
168
  onDeliveryEvent,
169
+ posthog,
148
170
  skipForward = false,
149
171
  ignoredSenders = DEFAULT_IGNORED_SENDERS,
150
172
  ignoredSubjects = DEFAULT_IGNORED_SUBJECTS,
@@ -220,18 +242,55 @@ export function createResendInboundHandler(config: ResendInboundConfig) {
220
242
  }
221
243
  }
222
244
 
223
- async function handleDelivery(payload: ResendInboundPayload) {
245
+ async function handleDelivery(payload: ResendInboundPayload, host?: string) {
224
246
  const status = DELIVERY_STATUS_MAP[payload.type];
225
- if (!status || !onDeliveryEvent) return;
226
- try {
227
- await onDeliveryEvent({
228
- type: payload.type,
229
- status,
230
- resendId: payload.data.email_id,
231
- timestamp: payload.created_at,
247
+ if (!status) return;
248
+
249
+ if (onDeliveryEvent) {
250
+ try {
251
+ await onDeliveryEvent({
252
+ type: payload.type,
253
+ status,
254
+ resendId: payload.data.email_id,
255
+ timestamp: payload.created_at,
256
+ });
257
+ } catch (err) {
258
+ console.error(`${tag} onDeliveryEvent error`, err);
259
+ }
260
+ }
261
+
262
+ // Optional PostHog fire — only when configured AND the email originated
263
+ // from our domain (Resend webhooks are workspace-level, so filtering is
264
+ // mandatory to avoid cross-site pollution).
265
+ if (posthog) {
266
+ const fromAddr = parseEmail(payload.data.from || "");
267
+ const matchesDomain = fromAddr.endsWith(`@${posthog.fromDomainFilter.toLowerCase()}`);
268
+ if (!matchesDomain) {
269
+ console.log(
270
+ `${tag} posthog skip, from domain mismatch:`,
271
+ fromAddr,
272
+ "expected:",
273
+ posthog.fromDomainFilter,
274
+ );
275
+ return;
276
+ }
277
+ const recipient = payload.data.to?.[0]?.toLowerCase() || payload.data.email_id;
278
+ await capturePostHogServer({
279
+ event: `email_${status}_server`,
280
+ distinctId: recipient,
281
+ host,
282
+ properties: {
283
+ email: recipient,
284
+ site: posthog.site,
285
+ resend_email_id: payload.data.email_id,
286
+ type: payload.type,
287
+ status,
288
+ subject: payload.data.subject || null,
289
+ from: fromAddr,
290
+ timestamp: payload.created_at,
291
+ component: "createResendInboundHandler",
292
+ },
232
293
  });
233
- } catch (err) {
234
- console.error(`${tag} onDeliveryEvent error`, err);
235
294
  }
236
295
  }
237
296
 
@@ -257,11 +316,18 @@ export function createResendInboundHandler(config: ResendInboundConfig) {
257
316
 
258
317
  console.log(`${tag}`, payload.type, payload.data?.email_id);
259
318
 
319
+ let host: string | undefined;
320
+ try {
321
+ host = req.headers.get("host") || undefined;
322
+ } catch {
323
+ host = undefined;
324
+ }
325
+
260
326
  try {
261
327
  if (payload.type === "email.received") {
262
328
  await handleInbound(payload, apiKey);
263
329
  } else if (DELIVERY_STATUS_MAP[payload.type]) {
264
- await handleDelivery(payload);
330
+ await handleDelivery(payload, host);
265
331
  }
266
332
  } catch (err) {
267
333
  console.error(`${tag} handler error`, err);