@m13v/seo-components 0.38.3 → 0.38.5

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.38.3",
3
+ "version": "0.38.5",
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,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useState } from "react";
4
+ import { createPortal } from "react-dom";
4
5
  import { motion, AnimatePresence } from "framer-motion";
5
6
  import { trackGetStartedClick } from "../lib/track";
6
7
  import { useCapture } from "../lib/analytics-context";
@@ -82,6 +83,21 @@ export interface InstallEmailGateProps {
82
83
  sentTitle?: string;
83
84
  /** Stage 2 (sent) body copy when emailOnly is true. Receives the submitted email. */
84
85
  sentDescription?: (email: string) => React.ReactNode;
86
+ /**
87
+ * Redirect-after-submit mode. When true, the submit POST is expected to
88
+ * return JSON `{ url: string }` (e.g. a Stripe Checkout session URL). On
89
+ * success the browser is redirected to that URL instead of advancing the
90
+ * modal to "command" or "sent". Used by paid-checkout flows where the
91
+ * email gate hands off to an external billing page. When set, takes
92
+ * precedence over `emailOnly`.
93
+ */
94
+ redirectOnSuccess?: boolean;
95
+ /**
96
+ * Extra fields merged into the submit POST body alongside `{ email }`.
97
+ * Useful for passing `section`, UTMs, or product identifiers to the
98
+ * checkout endpoint.
99
+ */
100
+ submitExtras?: Record<string, unknown>;
85
101
  }
86
102
 
87
103
  type Stage = "closed" | "email" | "command" | "sent";
@@ -108,6 +124,8 @@ export function InstallEmailGate({
108
124
  emailOnly = false,
109
125
  sentTitle = "Check your inbox",
110
126
  sentDescription,
127
+ redirectOnSuccess = false,
128
+ submitExtras,
111
129
  }: InstallEmailGateProps) {
112
130
  const capture = useCapture();
113
131
  const [stage, setStage] = useState<Stage>("closed");
@@ -116,6 +134,15 @@ export function InstallEmailGate({
116
134
  const [submitting, setSubmitting] = useState(false);
117
135
  const [error, setError] = useState("");
118
136
  const [copied, setCopied] = useState<"command" | "config" | null>(null);
137
+ // Mounted flag so we can portal the modal to document.body on the client
138
+ // without breaking SSR hydration. The modal escapes any transformed parent
139
+ // (e.g. .reveal-up) that would otherwise trap position: fixed inside that
140
+ // ancestor's stacking context, allowing clicks to leak through to the
141
+ // underlying page.
142
+ const [mounted, setMounted] = useState(false);
143
+ useEffect(() => {
144
+ setMounted(true);
145
+ }, []);
119
146
 
120
147
  useEffect(() => {
121
148
  if (stage === "closed") return;
@@ -166,10 +193,12 @@ export function InstallEmailGate({
166
193
  setSubmitting(true);
167
194
  setError("");
168
195
  try {
196
+ const body: Record<string, unknown> = { email: trimmed };
197
+ if (submitExtras) Object.assign(body, submitExtras);
169
198
  const res = await fetch(newsletterPath, {
170
199
  method: "POST",
171
200
  headers: { "Content-Type": "application/json" },
172
- body: JSON.stringify({ email: trimmed }),
201
+ body: JSON.stringify(body),
173
202
  });
174
203
  if (res.status >= 400 && res.status < 500) {
175
204
  const data = await res.json().catch(() => ({}));
@@ -178,9 +207,14 @@ export function InstallEmailGate({
178
207
  return;
179
208
  }
180
209
  if (!res.ok) {
181
- console.warn("[InstallEmailGate] newsletter POST failed", res.status);
210
+ console.warn("[InstallEmailGate] submit POST failed", res.status);
182
211
  }
183
- if (remember) markInstallEmailCaptured(trimmed, storageKey);
212
+ const delivery = redirectOnSuccess
213
+ ? "redirect"
214
+ : emailOnly
215
+ ? "email_only"
216
+ : "page_reveal";
217
+ if (remember && !redirectOnSuccess) markInstallEmailCaptured(trimmed, storageKey);
184
218
  capture("newsletter_subscribed", {
185
219
  component: "InstallEmailGate",
186
220
  email: trimmed,
@@ -188,25 +222,51 @@ export function InstallEmailGate({
188
222
  site,
189
223
  section,
190
224
  source: "install_gate",
191
- delivery: emailOnly ? "email_only" : "page_reveal",
225
+ delivery,
192
226
  });
193
227
  trackGetStartedClick({
194
- destination: emailOnly ? "email:install" : "modal:command",
228
+ destination: redirectOnSuccess
229
+ ? "redirect:checkout"
230
+ : emailOnly
231
+ ? "email:install"
232
+ : "modal:command",
195
233
  site,
196
234
  section,
197
235
  text: "email-submitted",
198
236
  component: "InstallEmailGate",
199
- extra: { email: trimmed, delivery: emailOnly ? "email_only" : "page_reveal" },
237
+ extra: { email: trimmed, delivery },
200
238
  });
239
+
240
+ if (redirectOnSuccess) {
241
+ const data = (await res.json().catch(() => ({}))) as { url?: string };
242
+ if (data.url && typeof window !== "undefined") {
243
+ window.location.href = data.url;
244
+ // Keep the modal in the submitting state while the browser navigates
245
+ // away so the user does not see a flash of stale UI.
246
+ return;
247
+ }
248
+ setError("No checkout URL returned. Try again.");
249
+ setSubmitting(false);
250
+ return;
251
+ }
252
+
201
253
  setSubmittedEmail(trimmed);
202
254
  setStage(emailOnly ? "sent" : "command");
203
255
  } catch (err) {
204
- console.warn("[InstallEmailGate] newsletter POST network error", err);
256
+ console.warn("[InstallEmailGate] submit POST network error", err);
257
+ if (redirectOnSuccess) {
258
+ setError("Network error. Try again.");
259
+ setSubmitting(false);
260
+ return;
261
+ }
205
262
  if (remember) markInstallEmailCaptured(trimmed, storageKey);
206
263
  setSubmittedEmail(trimmed);
207
264
  setStage(emailOnly ? "sent" : "command");
208
265
  } finally {
209
- setSubmitting(false);
266
+ // For redirect mode we returned early above on success; the browser
267
+ // is navigating away so we intentionally do not flip submitting back
268
+ // to false. For other modes, the stage transition is enough.
269
+ if (!redirectOnSuccess) setSubmitting(false);
210
270
  }
211
271
  };
212
272
 
@@ -236,28 +296,8 @@ export function InstallEmailGate({
236
296
  ? "group inline-flex h-11 items-center gap-2 rounded-md bg-zinc-900 px-5 text-sm font-medium text-white transition-colors hover:bg-zinc-800"
237
297
  : "inline-flex items-center justify-center rounded-md border border-zinc-300 bg-white px-5 py-2.5 text-sm font-medium text-zinc-800 transition-colors hover:border-zinc-400";
238
298
 
239
- return (
240
- <>
241
- {renderTrigger ? (
242
- renderTrigger({ onClick: onOpen })
243
- ) : (
244
- <button type="button" onClick={onOpen} className={`${buttonClass} ${className}`.trim()}>
245
- {label}
246
- {variant === "primary" && (
247
- <svg
248
- className="h-4 w-4 transition-transform group-hover:translate-x-0.5"
249
- fill="none"
250
- viewBox="0 0 24 24"
251
- stroke="currentColor"
252
- strokeWidth={2}
253
- >
254
- <path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
255
- </svg>
256
- )}
257
- </button>
258
- )}
259
-
260
- <AnimatePresence>
299
+ const overlay = (
300
+ <AnimatePresence>
261
301
  {stage !== "closed" && (
262
302
  <motion.div
263
303
  key="backdrop"
@@ -455,6 +495,35 @@ export function InstallEmailGate({
455
495
  </motion.div>
456
496
  )}
457
497
  </AnimatePresence>
498
+ );
499
+
500
+ return (
501
+ <>
502
+ {renderTrigger ? (
503
+ renderTrigger({ onClick: onOpen })
504
+ ) : (
505
+ <button type="button" onClick={onOpen} className={`${buttonClass} ${className}`.trim()}>
506
+ {label}
507
+ {variant === "primary" && (
508
+ <svg
509
+ className="h-4 w-4 transition-transform group-hover:translate-x-0.5"
510
+ fill="none"
511
+ viewBox="0 0 24 24"
512
+ stroke="currentColor"
513
+ strokeWidth={2}
514
+ >
515
+ <path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
516
+ </svg>
517
+ )}
518
+ </button>
519
+ )}
520
+ {/* Portal the modal to document.body so a transformed ancestor (e.g.
521
+ `.reveal-up` with `transform: translateY`) can't trap position:fixed
522
+ inside its own stacking context, which would let clicks leak through
523
+ to elements layered on top of the modal at viewport level. */}
524
+ {mounted && typeof document !== "undefined"
525
+ ? createPortal(overlay, document.body)
526
+ : null}
458
527
  </>
459
528
  );
460
529
  }