@realuptime/errors 0.4.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/LICENSE +21 -0
- package/README.md +115 -0
- package/buffer.ts +52 -0
- package/client.ts +181 -0
- package/custom-scrub.ts +271 -0
- package/dist/buffer.d.ts +25 -0
- package/dist/buffer.js +42 -0
- package/dist/client.d.ts +53 -0
- package/dist/client.js +151 -0
- package/dist/custom-scrub.d.ts +48 -0
- package/dist/custom-scrub.js +262 -0
- package/dist/feedback.d.ts +173 -0
- package/dist/feedback.js +465 -0
- package/dist/index.d.ts +154 -0
- package/dist/index.js +516 -0
- package/dist/scrub.d.ts +60 -0
- package/dist/scrub.js +172 -0
- package/dist/sourcemaps.d.ts +142 -0
- package/dist/sourcemaps.js +262 -0
- package/dist/types.d.ts +250 -0
- package/dist/types.js +79 -0
- package/dist/vite.d.ts +76 -0
- package/dist/vite.js +75 -0
- package/dist/webpack.d.ts +38 -0
- package/dist/webpack.js +15 -0
- package/feedback.ts +617 -0
- package/index.ts +617 -0
- package/package.json +62 -0
- package/scrub.ts +181 -0
- package/sourcemaps.ts +318 -0
- package/types.ts +293 -0
- package/vite.ts +155 -0
- package/webpack.ts +46 -0
package/feedback.ts
ADDED
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
import { scrubString } from "./scrub.ts";
|
|
2
|
+
import {
|
|
3
|
+
MAX_FEEDBACK_COMMENTS_LENGTH,
|
|
4
|
+
MAX_FEEDBACK_EMAIL_LENGTH,
|
|
5
|
+
MAX_FEEDBACK_NAME_LENGTH,
|
|
6
|
+
type WireFeedback,
|
|
7
|
+
} from "./types.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The user feedback dialog (REA-257): a tiny, dependency-free "tell us what
|
|
11
|
+
* happened" form a browser app can show after an error, posting the user's
|
|
12
|
+
* words to `POST /api/errors/v1/feedback/<key>` where they attach to the
|
|
13
|
+
* exact occurrence they describe.
|
|
14
|
+
*
|
|
15
|
+
* ## What this is deliberately NOT
|
|
16
|
+
*
|
|
17
|
+
* - Not on by default. Nothing here runs unless the integrator passes
|
|
18
|
+
* `init({ feedback: {...} })` AND the project's feedback switch is on in
|
|
19
|
+
* realuptime Errors settings. Either one missing means no dialog, no
|
|
20
|
+
* ids on the wire, no endpoint accepting anything.
|
|
21
|
+
* - Not a bypass of the scrub. `comments`, `name` and `email` pass the
|
|
22
|
+
* same five pattern rules every other captured string passes, HERE,
|
|
23
|
+
* before serialization, and the server applies them again. A card
|
|
24
|
+
* number typed into "what happened" never lands in our store.
|
|
25
|
+
* - Not a tracker. No cookies, no storage, no fingerprinting; the
|
|
26
|
+
* request carries exactly the four fields in WireFeedback.
|
|
27
|
+
*
|
|
28
|
+
* ## CSP-friendly, on purpose
|
|
29
|
+
*
|
|
30
|
+
* Every style is applied through the CSSOM (`element.style.setProperty`),
|
|
31
|
+
* never through a `<style>` element or a `style="..."` attribute string,
|
|
32
|
+
* so a page running `style-src 'self'` (no 'unsafe-inline') renders the
|
|
33
|
+
* dialog exactly the same. No inline script, no eval, no external asset.
|
|
34
|
+
* Integrators who want their own look get stable class names
|
|
35
|
+
* (`ru-feedback-*`) and a `theme` object; both are honored together.
|
|
36
|
+
*
|
|
37
|
+
* ## Accessible, on purpose
|
|
38
|
+
*
|
|
39
|
+
* `role="dialog"` + `aria-modal`, labelled by its title, focus moves to
|
|
40
|
+
* the first field on open and back to the opener on close, Tab cycles
|
|
41
|
+
* inside the dialog, Escape closes, every field has a real `<label>`, and
|
|
42
|
+
* validation errors are announced through an `aria-live` region.
|
|
43
|
+
*
|
|
44
|
+
* Like the rest of the SDK: never throws. Every public function catches
|
|
45
|
+
* everything and fails quiet.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
export interface ReportDialogField {
|
|
49
|
+
/** Visible label. */
|
|
50
|
+
label?: string;
|
|
51
|
+
/** Prefilled value (e.g. the signed-in user's email). */
|
|
52
|
+
value?: string;
|
|
53
|
+
/** Whether the field must be filled before submit. `comments` is always
|
|
54
|
+
* required; this flag is read for name and email only. */
|
|
55
|
+
required?: boolean;
|
|
56
|
+
/** Hide the field entirely. A hidden field is never sent. */
|
|
57
|
+
hidden?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ReportDialogTheme {
|
|
61
|
+
background: string;
|
|
62
|
+
foreground: string;
|
|
63
|
+
muted: string;
|
|
64
|
+
border: string;
|
|
65
|
+
accent: string;
|
|
66
|
+
accentForeground: string;
|
|
67
|
+
error: string;
|
|
68
|
+
fontFamily: string;
|
|
69
|
+
borderRadius: string;
|
|
70
|
+
overlay: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ReportDialogOptions {
|
|
74
|
+
/** The event to attach to. Defaults to the last captured event's id. */
|
|
75
|
+
eventId?: string;
|
|
76
|
+
title?: string;
|
|
77
|
+
subtitle?: string;
|
|
78
|
+
submitLabel?: string;
|
|
79
|
+
cancelLabel?: string;
|
|
80
|
+
/** Shown (in place of the form) after a successful submit. */
|
|
81
|
+
successMessage?: string;
|
|
82
|
+
/** Shown when the report could not be delivered. */
|
|
83
|
+
errorMessage?: string;
|
|
84
|
+
fields?: {
|
|
85
|
+
name?: ReportDialogField;
|
|
86
|
+
email?: ReportDialogField;
|
|
87
|
+
comments?: ReportDialogField;
|
|
88
|
+
};
|
|
89
|
+
theme?: Partial<ReportDialogTheme>;
|
|
90
|
+
/** Called after a successful delivery. */
|
|
91
|
+
onSubmit?: (feedback: { eventId: string }) => void;
|
|
92
|
+
/** Called whenever the dialog closes, submitted or not. */
|
|
93
|
+
onClose?: () => void;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** `init({ feedback })`: the integrator's standing configuration. Presence
|
|
97
|
+
* of the object is the opt-in; `autoShow` additionally opens the dialog on
|
|
98
|
+
* its own after an UNHANDLED browser exception. */
|
|
99
|
+
export interface FeedbackOptions extends Omit<ReportDialogOptions, "eventId"> {
|
|
100
|
+
/** Open the dialog automatically after an unhandled window error or
|
|
101
|
+
* rejection. Default false: even with feedback enabled, most apps want to
|
|
102
|
+
* decide when to ask. */
|
|
103
|
+
autoShow?: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const DEFAULT_THEME: ReportDialogTheme = {
|
|
107
|
+
background: "#ffffff",
|
|
108
|
+
foreground: "#111827",
|
|
109
|
+
muted: "#6b7280",
|
|
110
|
+
border: "#d1d5db",
|
|
111
|
+
accent: "#5b4de6",
|
|
112
|
+
accentForeground: "#ffffff",
|
|
113
|
+
error: "#b91c1c",
|
|
114
|
+
fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
|
|
115
|
+
borderRadius: "10px",
|
|
116
|
+
overlay: "rgba(17, 24, 39, 0.55)",
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export const DEFAULT_COPY = {
|
|
120
|
+
title: "It looks like something went wrong",
|
|
121
|
+
subtitle:
|
|
122
|
+
"Our team has been notified. If you would like to help, tell us what happened below. Anything you type is sent to this site's error tracking.",
|
|
123
|
+
nameLabel: "Name",
|
|
124
|
+
emailLabel: "Email",
|
|
125
|
+
commentsLabel: "What happened?",
|
|
126
|
+
submitLabel: "Send report",
|
|
127
|
+
cancelLabel: "Close",
|
|
128
|
+
successMessage: "Thank you. Your report has been sent.",
|
|
129
|
+
errorMessage: "Your report could not be sent. Please try again.",
|
|
130
|
+
requiredMessage: "Please fill in the required fields.",
|
|
131
|
+
} as const;
|
|
132
|
+
|
|
133
|
+
export const FEEDBACK_RETRY_DELAYS_MS = [1000, 2000, 4000];
|
|
134
|
+
|
|
135
|
+
export interface FeedbackTransport {
|
|
136
|
+
/** Resolves true on acceptance, false on a permanent refusal. Rejects
|
|
137
|
+
* (or resolves false) on a transient failure after retries. */
|
|
138
|
+
send(feedback: WireFeedback): Promise<boolean>;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface FeedbackDeliveryOptions {
|
|
142
|
+
endpoint: string;
|
|
143
|
+
sdk: string;
|
|
144
|
+
fetchImpl?: typeof fetch;
|
|
145
|
+
/** Test seam: resolves after a delay. */
|
|
146
|
+
wait?: (ms: number) => Promise<void>;
|
|
147
|
+
log?: (message: string) => void;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Turns an ingest DSN into its feedback sibling:
|
|
151
|
+
* `.../api/errors/v1/ingest/<key>` -> `.../api/errors/v1/feedback/<key>`.
|
|
152
|
+
* Returns null for anything else; the caller then declines to send rather
|
|
153
|
+
* than guessing a URL. */
|
|
154
|
+
export function feedbackEndpointFor(dsn: string): string | null {
|
|
155
|
+
const marker = "/api/errors/v1/ingest/";
|
|
156
|
+
const at = dsn.indexOf(marker);
|
|
157
|
+
if (at < 0) return null;
|
|
158
|
+
const key = dsn.slice(at + marker.length).replace(/[?#].*$/, "");
|
|
159
|
+
if (!key) return null;
|
|
160
|
+
return `${dsn.slice(0, at)}/api/errors/v1/feedback/${key}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
164
|
+
|
|
165
|
+
/** Mints one client event id. crypto.randomUUID where it exists (every
|
|
166
|
+
* modern browser and Node 19+), a getRandomValues-based v4 otherwise. */
|
|
167
|
+
export function mintEventId(): string {
|
|
168
|
+
const c = (globalThis as { crypto?: Crypto }).crypto;
|
|
169
|
+
try {
|
|
170
|
+
if (c && typeof c.randomUUID === "function") return c.randomUUID();
|
|
171
|
+
if (c && typeof c.getRandomValues === "function") {
|
|
172
|
+
const bytes = new Uint8Array(16);
|
|
173
|
+
c.getRandomValues(bytes);
|
|
174
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
175
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
176
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
177
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
// fall through
|
|
181
|
+
}
|
|
182
|
+
// No CSPRNG at all (an exotic embedded runtime): a weak id is still a
|
|
183
|
+
// usable correlation key, and this branch never runs in a browser.
|
|
184
|
+
let hex = "";
|
|
185
|
+
while (hex.length < 32) hex += Math.floor(Math.random() * 16).toString(16);
|
|
186
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function isEventId(value: unknown): value is string {
|
|
190
|
+
return typeof value === "string" && UUID_RE.test(value);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function clip(value: string, max: number): string {
|
|
194
|
+
return value.length > max ? value.slice(0, max) : value;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Builds the wire message: trims, clips, SCRUBS. Pure, exported so the
|
|
198
|
+
* scrub discipline on this path is testable without a DOM. Returns null
|
|
199
|
+
* when there is nothing to send (no comments). */
|
|
200
|
+
export function buildFeedback(
|
|
201
|
+
input: { eventId: string; comments: string; name?: string; email?: string },
|
|
202
|
+
sdk: string,
|
|
203
|
+
): WireFeedback | null {
|
|
204
|
+
const comments = scrubString(clip(input.comments.trim(), MAX_FEEDBACK_COMMENTS_LENGTH));
|
|
205
|
+
if (!comments) return null;
|
|
206
|
+
const out: WireFeedback = { eventId: input.eventId, comments, sdk };
|
|
207
|
+
const name = input.name?.trim();
|
|
208
|
+
if (name) out.name = scrubString(clip(name, MAX_FEEDBACK_NAME_LENGTH));
|
|
209
|
+
const email = input.email?.trim();
|
|
210
|
+
if (email) out.email = scrubString(clip(email, MAX_FEEDBACK_EMAIL_LENGTH));
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const defaultWait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Delivery for one report. A report is a human's one-off, not a stream, so
|
|
218
|
+
* this is simpler than the event Transport: a few short retries on a
|
|
219
|
+
* transient failure (network, 5xx, and a 404 `event_not_found`, which is
|
|
220
|
+
* the event batch still in flight ahead of the report), then give up and
|
|
221
|
+
* tell the dialog so it can say so. Any other 4xx is final.
|
|
222
|
+
*/
|
|
223
|
+
export function createFeedbackTransport(options: FeedbackDeliveryOptions): FeedbackTransport {
|
|
224
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
225
|
+
const wait = options.wait ?? defaultWait;
|
|
226
|
+
const log = options.log ?? ((message: string) => console.error(message));
|
|
227
|
+
let logged = false;
|
|
228
|
+
const logOnce = (message: string) => {
|
|
229
|
+
if (logged) return;
|
|
230
|
+
logged = true;
|
|
231
|
+
log(`[realuptime-errors] ${message}`);
|
|
232
|
+
};
|
|
233
|
+
return {
|
|
234
|
+
async send(feedback) {
|
|
235
|
+
for (let attempt = 0; ; attempt++) {
|
|
236
|
+
let response: Response | null = null;
|
|
237
|
+
try {
|
|
238
|
+
response = await fetchImpl(options.endpoint, {
|
|
239
|
+
method: "POST",
|
|
240
|
+
headers: { "content-type": "application/json" },
|
|
241
|
+
body: JSON.stringify(feedback),
|
|
242
|
+
signal: AbortSignal.timeout(10_000),
|
|
243
|
+
});
|
|
244
|
+
} catch {
|
|
245
|
+
response = null;
|
|
246
|
+
}
|
|
247
|
+
if (response?.ok) return true;
|
|
248
|
+
let reason: string | undefined;
|
|
249
|
+
if (response) {
|
|
250
|
+
try {
|
|
251
|
+
reason = ((await response.json()) as { reason?: string }).reason;
|
|
252
|
+
} catch {
|
|
253
|
+
reason = undefined;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const transient =
|
|
257
|
+
response === null ||
|
|
258
|
+
response.status >= 500 ||
|
|
259
|
+
response.status === 429 ||
|
|
260
|
+
(response.status === 404 && reason === "event_not_found");
|
|
261
|
+
if (!transient) {
|
|
262
|
+
if (reason === "feedback_disabled") {
|
|
263
|
+
logOnce(
|
|
264
|
+
"user feedback is switched off for this project in realuptime Errors settings; the report was not sent. Turn it on there, or remove init({ feedback }).",
|
|
265
|
+
);
|
|
266
|
+
} else if (reason === "key_revoked") {
|
|
267
|
+
logOnce("this project's ingest key was revoked; feedback was not sent.");
|
|
268
|
+
}
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
if (attempt >= FEEDBACK_RETRY_DELAYS_MS.length) return false;
|
|
272
|
+
await wait(FEEDBACK_RETRY_DELAYS_MS[attempt]);
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── The dialog ──────────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
export interface DialogDeps {
|
|
281
|
+
document: Document;
|
|
282
|
+
transport: FeedbackTransport;
|
|
283
|
+
sdk: string;
|
|
284
|
+
/** Resolves when the event batch carrying `eventId` has been attempted,
|
|
285
|
+
* so the report rarely arrives ahead of its event. */
|
|
286
|
+
beforeSend?: () => Promise<void>;
|
|
287
|
+
log?: (message: string) => void;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export interface ReportDialogHandle {
|
|
291
|
+
close(): void;
|
|
292
|
+
readonly element: HTMLElement;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let openDialog: ReportDialogHandle | null = null;
|
|
296
|
+
|
|
297
|
+
/** Whether a dialog is currently open (one at a time, by design). */
|
|
298
|
+
export function isReportDialogOpen(): boolean {
|
|
299
|
+
return openDialog !== null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Closes the open dialog, if any. Called by the SDK's `close()`. */
|
|
303
|
+
export function closeReportDialog(): void {
|
|
304
|
+
openDialog?.close();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function styles(el: HTMLElement, decl: Record<string, string>): void {
|
|
308
|
+
for (const [prop, value] of Object.entries(decl)) el.style.setProperty(prop, value);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Renders the dialog into `document.body` and returns a handle. This is
|
|
313
|
+
* the DOM half; `showReportDialog` in index.ts wires it to the SDK state.
|
|
314
|
+
* Exported so the jsdom tests can drive it directly.
|
|
315
|
+
*/
|
|
316
|
+
export function openReportDialog(
|
|
317
|
+
options: ReportDialogOptions & { eventId: string },
|
|
318
|
+
deps: DialogDeps,
|
|
319
|
+
): ReportDialogHandle | null {
|
|
320
|
+
const doc = deps.document;
|
|
321
|
+
if (!doc?.body) return null;
|
|
322
|
+
if (openDialog) return openDialog;
|
|
323
|
+
|
|
324
|
+
const theme: ReportDialogTheme = { ...DEFAULT_THEME, ...(options.theme ?? {}) };
|
|
325
|
+
const fields = options.fields ?? {};
|
|
326
|
+
const opener = doc.activeElement as HTMLElement | null;
|
|
327
|
+
const uid = `ru-feedback-${Math.random().toString(36).slice(2, 8)}`;
|
|
328
|
+
|
|
329
|
+
const overlay = doc.createElement("div");
|
|
330
|
+
overlay.className = "ru-feedback-overlay";
|
|
331
|
+
styles(overlay, {
|
|
332
|
+
position: "fixed",
|
|
333
|
+
inset: "0",
|
|
334
|
+
background: theme.overlay,
|
|
335
|
+
display: "flex",
|
|
336
|
+
"align-items": "center",
|
|
337
|
+
"justify-content": "center",
|
|
338
|
+
"z-index": "2147483000",
|
|
339
|
+
padding: "16px",
|
|
340
|
+
"box-sizing": "border-box",
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const dialog = doc.createElement("div");
|
|
344
|
+
dialog.className = "ru-feedback-dialog";
|
|
345
|
+
dialog.setAttribute("role", "dialog");
|
|
346
|
+
dialog.setAttribute("aria-modal", "true");
|
|
347
|
+
dialog.setAttribute("aria-labelledby", `${uid}-title`);
|
|
348
|
+
dialog.setAttribute("aria-describedby", `${uid}-subtitle`);
|
|
349
|
+
styles(dialog, {
|
|
350
|
+
background: theme.background,
|
|
351
|
+
color: theme.foreground,
|
|
352
|
+
"font-family": theme.fontFamily,
|
|
353
|
+
"font-size": "15px",
|
|
354
|
+
"line-height": "1.45",
|
|
355
|
+
"border-radius": theme.borderRadius,
|
|
356
|
+
border: `1px solid ${theme.border}`,
|
|
357
|
+
"box-shadow": "0 20px 50px rgba(0,0,0,0.25)",
|
|
358
|
+
width: "100%",
|
|
359
|
+
"max-width": "440px",
|
|
360
|
+
"max-height": "100%",
|
|
361
|
+
overflow: "auto",
|
|
362
|
+
padding: "22px",
|
|
363
|
+
"box-sizing": "border-box",
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const title = doc.createElement("h2");
|
|
367
|
+
title.id = `${uid}-title`;
|
|
368
|
+
title.className = "ru-feedback-title";
|
|
369
|
+
title.textContent = options.title ?? DEFAULT_COPY.title;
|
|
370
|
+
styles(title, { margin: "0 0 6px", "font-size": "18px", "font-weight": "600" });
|
|
371
|
+
|
|
372
|
+
const subtitle = doc.createElement("p");
|
|
373
|
+
subtitle.id = `${uid}-subtitle`;
|
|
374
|
+
subtitle.className = "ru-feedback-subtitle";
|
|
375
|
+
subtitle.textContent = options.subtitle ?? DEFAULT_COPY.subtitle;
|
|
376
|
+
styles(subtitle, { margin: "0 0 16px", color: theme.muted, "font-size": "14px" });
|
|
377
|
+
|
|
378
|
+
const form = doc.createElement("form");
|
|
379
|
+
form.className = "ru-feedback-form";
|
|
380
|
+
form.setAttribute("novalidate", "");
|
|
381
|
+
|
|
382
|
+
const live = doc.createElement("p");
|
|
383
|
+
live.className = "ru-feedback-status";
|
|
384
|
+
live.setAttribute("role", "status");
|
|
385
|
+
live.setAttribute("aria-live", "polite");
|
|
386
|
+
styles(live, { margin: "0 0 10px", "min-height": "1.2em", color: theme.error, "font-size": "14px" });
|
|
387
|
+
|
|
388
|
+
const inputs: Partial<Record<"name" | "email" | "comments", HTMLInputElement | HTMLTextAreaElement>> = {};
|
|
389
|
+
|
|
390
|
+
function field(
|
|
391
|
+
key: "name" | "email" | "comments",
|
|
392
|
+
labelText: string,
|
|
393
|
+
spec: ReportDialogField | undefined,
|
|
394
|
+
kind: "input" | "textarea",
|
|
395
|
+
type = "text",
|
|
396
|
+
): void {
|
|
397
|
+
if (spec?.hidden) return;
|
|
398
|
+
const wrap = doc.createElement("div");
|
|
399
|
+
wrap.className = `ru-feedback-field ru-feedback-field-${key}`;
|
|
400
|
+
styles(wrap, { margin: "0 0 12px" });
|
|
401
|
+
const label = doc.createElement("label");
|
|
402
|
+
label.htmlFor = `${uid}-${key}`;
|
|
403
|
+
label.className = "ru-feedback-label";
|
|
404
|
+
const required = key === "comments" || Boolean(spec?.required);
|
|
405
|
+
label.textContent = `${spec?.label ?? labelText}${required ? "" : " (optional)"}`;
|
|
406
|
+
styles(label, { display: "block", "font-size": "13px", "font-weight": "600", margin: "0 0 4px" });
|
|
407
|
+
const control = doc.createElement(kind) as HTMLInputElement | HTMLTextAreaElement;
|
|
408
|
+
control.id = `${uid}-${key}`;
|
|
409
|
+
control.name = key;
|
|
410
|
+
control.className = "ru-feedback-input";
|
|
411
|
+
if (kind === "input") (control as HTMLInputElement).type = type;
|
|
412
|
+
if (kind === "textarea") (control as HTMLTextAreaElement).rows = 4;
|
|
413
|
+
if (key === "email") control.setAttribute("autocomplete", "email");
|
|
414
|
+
if (key === "name") control.setAttribute("autocomplete", "name");
|
|
415
|
+
control.maxLength =
|
|
416
|
+
key === "comments" ? MAX_FEEDBACK_COMMENTS_LENGTH : key === "name" ? MAX_FEEDBACK_NAME_LENGTH : MAX_FEEDBACK_EMAIL_LENGTH;
|
|
417
|
+
if (required) control.setAttribute("aria-required", "true");
|
|
418
|
+
if (spec?.value) control.value = spec.value;
|
|
419
|
+
styles(control, {
|
|
420
|
+
display: "block",
|
|
421
|
+
width: "100%",
|
|
422
|
+
"box-sizing": "border-box",
|
|
423
|
+
padding: "8px 10px",
|
|
424
|
+
font: "inherit",
|
|
425
|
+
color: theme.foreground,
|
|
426
|
+
background: theme.background,
|
|
427
|
+
border: `1px solid ${theme.border}`,
|
|
428
|
+
"border-radius": "6px",
|
|
429
|
+
resize: kind === "textarea" ? "vertical" : "none",
|
|
430
|
+
});
|
|
431
|
+
wrap.appendChild(label);
|
|
432
|
+
wrap.appendChild(control);
|
|
433
|
+
form.appendChild(wrap);
|
|
434
|
+
inputs[key] = control;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
field("name", DEFAULT_COPY.nameLabel, fields.name, "input", "text");
|
|
438
|
+
field("email", DEFAULT_COPY.emailLabel, fields.email, "input", "email");
|
|
439
|
+
field("comments", DEFAULT_COPY.commentsLabel, fields.comments, "textarea");
|
|
440
|
+
|
|
441
|
+
const actions = doc.createElement("div");
|
|
442
|
+
actions.className = "ru-feedback-actions";
|
|
443
|
+
styles(actions, { display: "flex", gap: "8px", "justify-content": "flex-end", "flex-wrap": "wrap" });
|
|
444
|
+
|
|
445
|
+
const cancel = doc.createElement("button");
|
|
446
|
+
cancel.type = "button";
|
|
447
|
+
cancel.className = "ru-feedback-cancel";
|
|
448
|
+
cancel.textContent = options.cancelLabel ?? DEFAULT_COPY.cancelLabel;
|
|
449
|
+
styles(cancel, {
|
|
450
|
+
font: "inherit",
|
|
451
|
+
padding: "8px 14px",
|
|
452
|
+
"border-radius": "6px",
|
|
453
|
+
border: `1px solid ${theme.border}`,
|
|
454
|
+
background: "transparent",
|
|
455
|
+
color: theme.foreground,
|
|
456
|
+
cursor: "pointer",
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
const submit = doc.createElement("button");
|
|
460
|
+
submit.type = "submit";
|
|
461
|
+
submit.className = "ru-feedback-submit";
|
|
462
|
+
submit.textContent = options.submitLabel ?? DEFAULT_COPY.submitLabel;
|
|
463
|
+
styles(submit, {
|
|
464
|
+
font: "inherit",
|
|
465
|
+
padding: "8px 14px",
|
|
466
|
+
"border-radius": "6px",
|
|
467
|
+
border: `1px solid ${theme.accent}`,
|
|
468
|
+
background: theme.accent,
|
|
469
|
+
color: theme.accentForeground,
|
|
470
|
+
cursor: "pointer",
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
actions.appendChild(cancel);
|
|
474
|
+
actions.appendChild(submit);
|
|
475
|
+
form.appendChild(live);
|
|
476
|
+
form.appendChild(actions);
|
|
477
|
+
dialog.appendChild(title);
|
|
478
|
+
dialog.appendChild(subtitle);
|
|
479
|
+
dialog.appendChild(form);
|
|
480
|
+
overlay.appendChild(dialog);
|
|
481
|
+
|
|
482
|
+
let closed = false;
|
|
483
|
+
const handle: ReportDialogHandle = {
|
|
484
|
+
element: overlay,
|
|
485
|
+
close() {
|
|
486
|
+
if (closed) return;
|
|
487
|
+
closed = true;
|
|
488
|
+
try {
|
|
489
|
+
doc.removeEventListener("keydown", onKeydown, true);
|
|
490
|
+
overlay.remove();
|
|
491
|
+
} catch {
|
|
492
|
+
// never throws
|
|
493
|
+
}
|
|
494
|
+
if (openDialog === handle) openDialog = null;
|
|
495
|
+
try {
|
|
496
|
+
opener?.focus?.();
|
|
497
|
+
} catch {
|
|
498
|
+
// never throws
|
|
499
|
+
}
|
|
500
|
+
try {
|
|
501
|
+
options.onClose?.();
|
|
502
|
+
} catch {
|
|
503
|
+
// integrator callback; never ours to throw
|
|
504
|
+
}
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
function focusables(): HTMLElement[] {
|
|
509
|
+
return Array.from(
|
|
510
|
+
dialog.querySelectorAll<HTMLElement>("input, textarea, button, [tabindex]:not([tabindex='-1'])"),
|
|
511
|
+
).filter((el) => !el.hasAttribute("disabled"));
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function onKeydown(event: KeyboardEvent): void {
|
|
515
|
+
if (event.key === "Escape") {
|
|
516
|
+
event.preventDefault();
|
|
517
|
+
handle.close();
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (event.key === "Tab") {
|
|
521
|
+
const list = focusables();
|
|
522
|
+
if (list.length === 0) return;
|
|
523
|
+
const first = list[0];
|
|
524
|
+
const last = list[list.length - 1];
|
|
525
|
+
const active = doc.activeElement;
|
|
526
|
+
if (event.shiftKey && (active === first || !dialog.contains(active))) {
|
|
527
|
+
event.preventDefault();
|
|
528
|
+
last.focus();
|
|
529
|
+
} else if (!event.shiftKey && active === last) {
|
|
530
|
+
event.preventDefault();
|
|
531
|
+
first.focus();
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
cancel.addEventListener("click", () => handle.close());
|
|
537
|
+
overlay.addEventListener("click", (event) => {
|
|
538
|
+
if (event.target === overlay) handle.close();
|
|
539
|
+
});
|
|
540
|
+
doc.addEventListener("keydown", onKeydown, true);
|
|
541
|
+
|
|
542
|
+
form.addEventListener("submit", (event) => {
|
|
543
|
+
event.preventDefault();
|
|
544
|
+
void (async () => {
|
|
545
|
+
try {
|
|
546
|
+
const comments = inputs.comments?.value ?? "";
|
|
547
|
+
const name = inputs.name?.value ?? "";
|
|
548
|
+
const email = inputs.email?.value ?? "";
|
|
549
|
+
const missing =
|
|
550
|
+
!comments.trim() ||
|
|
551
|
+
(fields.name?.required && !name.trim()) ||
|
|
552
|
+
(fields.email?.required && !email.trim());
|
|
553
|
+
if (missing) {
|
|
554
|
+
live.textContent = DEFAULT_COPY.requiredMessage;
|
|
555
|
+
(inputs.comments ?? inputs.name ?? inputs.email)?.focus();
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
const feedback = buildFeedback({ eventId: options.eventId, comments, name, email }, deps.sdk);
|
|
559
|
+
if (!feedback) {
|
|
560
|
+
live.textContent = DEFAULT_COPY.requiredMessage;
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
submit.disabled = true;
|
|
564
|
+
cancel.disabled = true;
|
|
565
|
+
live.textContent = "";
|
|
566
|
+
try {
|
|
567
|
+
await deps.beforeSend?.();
|
|
568
|
+
} catch {
|
|
569
|
+
// the event flush's failure is its own problem; still try
|
|
570
|
+
}
|
|
571
|
+
const ok = await deps.transport.send(feedback);
|
|
572
|
+
if (ok) {
|
|
573
|
+
form.remove();
|
|
574
|
+
subtitle.textContent = options.successMessage ?? DEFAULT_COPY.successMessage;
|
|
575
|
+
subtitle.setAttribute("role", "status");
|
|
576
|
+
styles(subtitle, { color: theme.foreground });
|
|
577
|
+
const done = doc.createElement("button");
|
|
578
|
+
done.type = "button";
|
|
579
|
+
done.className = "ru-feedback-done";
|
|
580
|
+
done.textContent = options.cancelLabel ?? DEFAULT_COPY.cancelLabel;
|
|
581
|
+
styles(done, {
|
|
582
|
+
font: "inherit",
|
|
583
|
+
padding: "8px 14px",
|
|
584
|
+
"border-radius": "6px",
|
|
585
|
+
border: `1px solid ${theme.accent}`,
|
|
586
|
+
background: theme.accent,
|
|
587
|
+
color: theme.accentForeground,
|
|
588
|
+
cursor: "pointer",
|
|
589
|
+
});
|
|
590
|
+
done.addEventListener("click", () => handle.close());
|
|
591
|
+
dialog.appendChild(done);
|
|
592
|
+
done.focus();
|
|
593
|
+
try {
|
|
594
|
+
options.onSubmit?.({ eventId: options.eventId });
|
|
595
|
+
} catch {
|
|
596
|
+
// integrator callback
|
|
597
|
+
}
|
|
598
|
+
} else {
|
|
599
|
+
submit.disabled = false;
|
|
600
|
+
cancel.disabled = false;
|
|
601
|
+
live.textContent = options.errorMessage ?? DEFAULT_COPY.errorMessage;
|
|
602
|
+
}
|
|
603
|
+
} catch (failure) {
|
|
604
|
+
try {
|
|
605
|
+
deps.log?.(`[realuptime-errors] feedback dialog failed: ${String(failure)}`);
|
|
606
|
+
} catch {
|
|
607
|
+
// never throws
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
})();
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
doc.body.appendChild(overlay);
|
|
614
|
+
openDialog = handle;
|
|
615
|
+
(inputs.name ?? inputs.email ?? inputs.comments)?.focus();
|
|
616
|
+
return handle;
|
|
617
|
+
}
|