@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
|
@@ -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();
|
|
@@ -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
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
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
|
-
|
|
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
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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);
|