@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,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(
|
|
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]
|
|
210
|
+
console.warn("[InstallEmailGate] submit POST failed", res.status);
|
|
182
211
|
}
|
|
183
|
-
|
|
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
|
|
225
|
+
delivery,
|
|
192
226
|
});
|
|
193
227
|
trackGetStartedClick({
|
|
194
|
-
destination:
|
|
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
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|