All files / packages/design-system-2-site/src/lib feedback.ts

75% Statements 12/16
71.42% Branches 10/14
100% Functions 2/2
80% Lines 12/15

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);
  }
}