Repository navigation
Expand file tree
/
Copy pathqawolf-signup-handler.js
More file actions
236 lines (213 loc) · 7.72 KB
/
Copy pathqawolf-signup-handler.js
File metadata and controls
236 lines (213 loc) · 7.72 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
/*
Verbatim handler from the design team, unmodified.
Uses the default platformUrl ('https://app.qawolf.com') since
document.currentScript.dataset won't be set when Mintlify includes this
as an external file rather than an inline tag with data-platform-url — the
fallback in the code already covers this, no changes needed here.
Requires:
1. CORS on app.qawolf.com's /api/trpc/* allows the docs origin.
2. window.qawAttribution is loaded (or safely stubbed) on the docs domain.
Until then every submission fails with the same generic "Failed to
connect to the server" message, which looks identical regardless of which
of the two is actually missing.
*/
function getPosthogIds() {
const properties = readPosthogPersistedProperties();
if (!properties) return {};
// PostHog's persisted $sesid is [lastActivityTimestamp, sessionId, sessionStartTimestamp].
const sesid = properties['$sesid'];
const posthogSessionId = Array.isArray(sesid) && typeof sesid[1] === 'string' ? sesid[1] : undefined;
const posthogDistinctId = typeof properties.distinct_id === 'string' ? properties.distinct_id : undefined;
return {
posthogDistinctId,
posthogSessionId,
};
}
const platformUrl = document.currentScript?.dataset.platformUrl || 'https://app.qawolf.com';
const requestTimeoutMs = 5000;
const genericError = 'Something went wrong. Please try again.';
const invalidEmail = 'Please enter a valid email address.';
function renderFatalBanner(message) {
const banner = document.createElement('div');
banner.textContent = `Signup handler broken: ${message}`;
banner.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:2147483647;' + 'background:#b00020;color:#fff;padding:12px 16px;' + 'font:14px/1.4 system-ui,sans-serif;text-align:center;';
document.body.appendChild(banner);
}
async function postTrpc(procedure, payload, signal) {
const response = await fetch(`${platformUrl}/api/trpc/${procedure}`, {
body: JSON.stringify({ json: payload }),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
signal,
});
if (!response.ok) {
const errorBody = await response.json().catch(() => undefined);
return {
type: 'http-error',
status: response.status,
body: errorBody,
message: errorBody?.error?.json?.message,
};
}
const body = await response.json();
const result = body?.result?.data?.json;
if (!result || !result.outcome) {
return { type: 'bad-shape', body };
}
return { type: 'ok', result };
}
function navigate(url) {
window.location.href = url;
}
// Navigates if maybeUrl is a valid same-origin URL; returns an error message otherwise.
function navigateToSafeUrl(maybeUrl) {
if (typeof maybeUrl !== 'string' || !maybeUrl) return genericError;
let target;
try {
target = new URL(maybeUrl, platformUrl);
} catch (error) {
return genericError;
}
if (target.origin !== new URL(platformUrl).origin) return genericError;
navigate(target.href);
return undefined;
}
function handleSignUpOutcome(result) {
switch (result.outcome) {
case "code-sent":
case "sso-required":
return navigateToSafeUrl(result.redirectUrl);
case 'proceed-to-signin':
return navigateToSafeUrl(result.signInUrl);
case 'temporarily-disabled':
navigate('/book-a-demo');
return undefined;
case 'invalid-email':
switch (result.reason) {
case 'malformed':
return invalidEmail;
case 'public-domain':
return 'Please use your work email address.';
case 'blocked':
return 'Please use a different email address to sign up.';
}
default:
return genericError;
}
}
function handleBookADemoOutcome(result) {
switch (result.outcome) {
case 'ok':
return navigateToSafeUrl(result.redirectTo);
case 'invalid-email':
return invalidEmail;
default:
return genericError;
}
}
function buildPayload(email) {
return {
email,
attribution: window.qawAttribution.getAttribution(),
...getPosthogIds(),
};
}
const FORM_KINDS = [
{
formSelector: '[data-signup-form]',
emailSelector: '[data-signup-email]',
feedbackSelector: '[data-signup-feedback]',
procedure: 'auth.submitSignUpEmail',
handleOutcome: handleSignUpOutcome,
submitLabel: 'Get started',
},
{
formSelector: '[data-book-a-demo-form]',
emailSelector: '[data-book-a-demo-email]',
feedbackSelector: '[data-book-a-demo-feedback]',
procedure: 'acquisition.submitBookADemoForm',
handleOutcome: handleBookADemoOutcome,
submitLabel: 'Book a demo',
},
];
// Single delegated listener, attached once, here, synchronously — before
// Mintlify's React app has rendered any page content. Doesn't matter when
// (or how many times) a matching form gets added to the DOM afterward;
// this only inspects the event's target at the moment something actually
// gets submitted.
document.addEventListener(
'submit',
async (event) => {
const form = event.target;
if (!(form instanceof HTMLFormElement)) return;
const kind = FORM_KINDS.find((k) => form.matches(k.formSelector));
if (!kind) return;
event.preventDefault();
event.stopImmediatePropagation();
const emailInput = form.querySelector(kind.emailSelector);
if (!emailInput) {
renderFatalBanner(`missing ${kind.emailSelector} inside ${kind.formSelector}`);
return;
}
const feedbackEl = form.querySelector(kind.feedbackSelector);
if (!feedbackEl) {
renderFatalBanner(`missing ${kind.feedbackSelector} inside ${kind.formSelector}`);
return;
}
const submitButton = form.querySelector('button[type=submit], input[type=submit]');
if (!submitButton) {
renderFatalBanner(`missing submit button inside ${kind.formSelector}`);
return;
}
if (submitButton.disabled) return;
feedbackEl.textContent = '';
feedbackEl.style.display = 'none';
submitButton.disabled = true;
submitButton.classList.add('is-loading');
submitButton.value = 'Redirecting...';
const resetButton = () => {
submitButton.disabled = false;
submitButton.classList.remove('is-loading');
submitButton.value = kind.submitLabel;
};
const fail = (message) => {
feedbackEl.textContent = message;
feedbackEl.style.display = 'block';
resetButton();
};
const email = (emailInput.value || '').trim();
try {
const outcome = await postTrpc(kind.procedure, buildPayload(email), AbortSignal.timeout(requestTimeoutMs));
if (outcome.type === 'http-error' || outcome.type === 'bad-shape') {
fail(genericError);
return;
}
const errorMessage = kind.handleOutcome(outcome.result, email);
if (errorMessage) fail(errorMessage);
} catch (error) {
if (error?.name === 'TimeoutError') {
fail('Request timed out. Please try again.');
} else if (error?.name === 'AbortError') {
// Expected on page unload / navigation. Ignore silently.
} else {
fail('Failed to connect to the server. Please try again.');
}
}
},
true,
);
// Resets any matching button's loading state when a page is restored from
// bfcache (e.g. browser back button) mid-submission. Re-queries fresh each
// time rather than relying on a reference captured at bind-time, since
// delegation doesn't hold onto specific form/button elements ahead of time.
window.addEventListener('pageshow', () => {
FORM_KINDS.forEach((kind) => {
document.querySelectorAll(kind.formSelector).forEach((form) => {
const submitButton = form.querySelector('button[type=submit], input[type=submit]');
if (!submitButton) return;
submitButton.disabled = false;
submitButton.classList.remove('is-loading');
submitButton.value = kind.submitLabel;
});
});
});