Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 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 | 8x 8x 8x 8x 8x 2x 2x 1x 1x 1x 1x 1x | /**
* Page-feedback submission.
*
* The site is a static export (no server / no API route), so submissions POST
* as JSON to a hosted collection endpoint (Google Sheet Apps Script, Formspree,
* Airtable, …) configured via `NEXT_PUBLIC_FEEDBACK_ENDPOINT`. When the env var
* is unset (e.g. local dev) the payload is logged instead of sent, so the UI
* still works end-to-end without a backend.
*
* Everything captured is anonymous: no IP, no geolocation, no cookies, no
* fingerprinting, no persistent user id. Free-text comments are the only place
* a user could type PII themselves — the popover asks them not to.
*/
export type FeedbackRating = 'up' | 'down';
export interface FeedbackPayload {
/** Route path of the page being rated, e.g. '/get-started/admins'. */
page: string;
/** Document title at submit time. */
title: string;
/** ISO-8601 submission timestamp. */
timestamp: string;
rating: FeedbackRating;
/** Optional free-text comment; empty string when none. */
comment: string;
/** Coarse seconds spent on the page before submitting. */
timeOnPageSeconds: number;
/** Resolved theme at submit time ('light' | 'dark'). */
theme: string;
/** Browser UI locale, e.g. 'en-US'. */
locale: string;
/** Viewport size, e.g. '1440x900'. */
viewport: string;
/** Coarse device class derived from viewport width. */
deviceClass: 'mobile' | 'tablet' | 'desktop';
/** In-site page they navigated from (path only); '' for external/direct. */
referrer: string;
}
/** Ambient, anonymous signals gathered in the browser at submit time. */
export interface ClientSignals {
title: string;
theme: string;
locale: string;
viewport: string;
deviceClass: FeedbackPayload['deviceClass'];
referrer: string;
}
/** Collect the anonymous ambient signals. Call from the browser only. */
export function gatherClientSignals(): ClientSignals {
const width = window.innerWidth;
const deviceClass = width < 768 ? 'mobile' : width < 1024 ? 'tablet' : 'desktop';
// Internal referrer only: keep the path when it's this origin, else drop it.
let referrer = '';
Iif (document.referrer) {
try {
const url = new URL(document.referrer);
if (url.origin === window.location.origin) referrer = url.pathname;
} catch {
/* malformed referrer — ignore */
}
}
return {
title: document.title,
theme: document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light',
locale: navigator.language || '',
viewport: `${window.innerWidth}x${window.innerHeight}`,
deviceClass,
referrer,
};
}
/**
* Send a feedback submission. Resolves once the POST settles (or immediately in
* the no-endpoint dev path); never throws — a failed submit is logged, not
* surfaced, so the UI can always show its thank-you state.
*/
export async function submitFeedback(payload: FeedbackPayload): Promise<void> {
const endpoint = process.env.NEXT_PUBLIC_FEEDBACK_ENDPOINT;
if (!endpoint) {
// eslint-disable-next-line no-console
console.info('[feedback] no NEXT_PUBLIC_FEEDBACK_ENDPOINT set — payload:', payload);
return;
}
try {
// text/plain is a CORS 'simple' content type, so the browser skips the
// preflight OPTIONS request that Google Apps Script web apps can't answer.
// The body is still JSON — the endpoint parses it as such.
await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'text/plain;charset=utf-8' },
body: JSON.stringify(payload),
});
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[feedback] submit failed', err);
}
}
|