npm package: @randrusname/support-widget
Description: Embeddable React support widget with webhook delivery, safe diagnostics, and optional attachment support.
Latest version: 0.2.0

# `@randrusname/support-widget`

Embeddable React support widget for collecting issue reports and sending them to
your webhook as `multipart/form-data`.

It is designed as a lightweight reusable library rather than app-local code:

- explicit CSS import
- React 18/19 support
- controlled or uncontrolled dialog state
- optional attachment support
- safe diagnostics defaults with redaction
- typed lifecycle callbacks for integrations

## When to use it

Use this package when you need a simple in-app support / bug-report entry point
that can be embedded into multiple React projects and delivered to your own
backend, webhook receiver, or automation flow.

## Install

```bash
npm install @randrusname/support-widget
```

Peer dependencies:

- `react`
- `react-dom`

## Quick start

```tsx
import { SupportWidget } from "@randrusname/support-widget";
import "@randrusname/support-widget/styles.css";

export function App() {
  return (
    <SupportWidget
      webhook={{
        url: "https://example.com/webhook",
        headers: {
          "x-widget-source": "my-app"
        }
      }}
      context={{
        app: "crm",
        userId: "42",
        environment: "production"
      }}
    />
  );
}
```

## Full example

```tsx
import { useState } from "react";

import {
  SupportWidget,
  type SupportWidgetSubmitErrorDetails
} from "@randrusname/support-widget";
import "@randrusname/support-widget/styles.css";

export function App() {
  const [isOpen, setIsOpen] = useState(false);

  function handleSubmitError(details: SupportWidgetSubmitErrorDetails) {
    console.error("Support widget submit failed", details.error);
  }

  return (
    <SupportWidget
      webhook={{
        url: "https://example.com/webhook",
        headers: {
          "x-widget-source": "customer-portal"
        }
      }}
      open={isOpen}
      onOpenChange={setIsOpen}
      onSubmitError={handleSubmitError}
      accept={["image/*", ".txt", ".log"]}
      maxFileSizeMb={5}
      context={{
        app: "customer-portal",
        userId: "42",
        tenantId: "acme"
      }}
      diagnostics={{
        includePayloads: true,
        includeResponses: true,
        maxPayloadLength: 8000
      }}
      labels={{
        buttonLabel: "Contact support",
        dialogTitle: "Report a problem"
      }}
    />
  );
}
```

## Styles

Styles are not injected automatically. Import them explicitly:

```tsx
import "@randrusname/support-widget/styles.css";
```

The widget exposes CSS custom properties with built-in fallbacks:

- `--sw-bg`
- `--sw-fg`
- `--sw-muted`
- `--sw-border`
- `--sw-accent`
- `--sw-accent-contrast`
- `--sw-overlay`
- `--sw-radius`
- `--sw-shadow`
- `--sw-surface`
- `--sw-font-family`

## Public API

### Props

- `webhook: { url: string; headers?: Record<string, string> }`
  Required destination for the multipart request.
- `context?: Record<string, string | number | boolean | null>`
  Optional flat metadata appended as `context.*` fields.
- `defaultOpen?: boolean`
  Initial dialog state for uncontrolled usage.
- `open?: boolean`
  Controlled dialog state.
- `onOpenChange?: (open: boolean) => void`
  Called when the widget requests an open-state change.
- `onOpen?: () => void`
  Called when the dialog actually becomes visible.
- `onClose?: () => void`
  Called when the dialog actually closes.
- `onSubmitStart?: (details) => void`
  Called after validation and before the request is sent.
- `onSubmitSuccess?: (details) => void`
  Called after a successful `2xx` response.
- `onSubmitError?: (details) => void`
  Called when the request fails or the webhook returns a non-`2xx` response.
- `accept?: string | string[]`
  Native file accept rules. The widget also validates selected files against
  these rules before submission.
- `maxFileSizeMb?: number`
  Maximum single attachment size. Default: `10`.
- `diagnostics?: SupportWidgetDiagnosticsConfig`
  Controls console/network diagnostics capture.
- `labels?: Partial<SupportWidgetLabels>`
  Text overrides for the built-in UI.
- `buttonLabel?: string`
  Deprecated alias for `labels.buttonLabel`.
- `zIndex?: number`
  Override stacking order. Default is a very high widget-friendly value.

### Callbacks

`onSubmitStart`, `onSubmitSuccess`, and `onSubmitError` receive:

- `description`
- `attachment`
- `context`
- `diagnosticsPayload`
- `formData`

Success callbacks also receive `response`, and error callbacks receive `error`.

## Payload format

The widget submits `multipart/form-data` with these fields:

- `description`
- `app`
  Taken from `context.app` when present.
- `submittedAt`
  ISO timestamp created on submit.
- `context.*`
  Flattened context fields such as `context.userId`.
- `context.page.url`
  Sanitized current page URL without query string or hash.
- `context.page.route`
  Current pathname.
- `context.client.browser`
- `context.client.os`
- `context.client.mobile`
- `diagnostics`
  Compact JSON string, or literal `null` when no diagnostics are included.
- `attachment`
  A single file, when selected.

Example diagnostics payload:

```json
{
  "c": [
    {
      "l": "e",
      "m": "Request failed with token=[REDACTED]",
      "t": "2026-03-24T10:00:00.000Z"
    }
  ],
  "n": [
    {
      "x": "f",
      "m": "POST",
      "u": "https://api.example.com/checkout",
      "s": 500,
      "d": 124,
      "t": "2026-03-24T10:00:01.000Z",
      "p": "{\"token\":\"[REDACTED]\",\"step\":\"checkout\"}",
      "r": "{\"message\":\"failed\"}"
    }
  ]
}
```

## Attachments

- Only one attachment is supported in this version.
- Files are validated against both `accept` and `maxFileSizeMb`.
- File contents are sent only as the final `attachment` multipart field.
- Diagnostics capture never includes raw file contents. File references are
  reduced to metadata such as name, type, and size.

## Diagnostics and privacy

Diagnostics are enabled by default, but the default mode is intentionally
conservative:

- console capture: `warn` and `error`
- network capture: failed `fetch` / `XMLHttpRequest` calls
- request URLs are sanitized to `origin + pathname`
- request payloads are not captured unless `includePayloads: true`
- response bodies are not captured unless `includeResponses: true`
- sensitive values are redacted by default
- diagnostics payload size is capped
- the widget webhook request itself is excluded from diagnostics output
- cookies and request headers are not collected

Default sensitive keys:

- `token`
- `access_token`
- `refresh_token`
- `session`
- `cookie`
- `authorization`
- `api_key`
- `secret`
- `password`
- `key`

You can extend the redaction list with `diagnostics.redactedKeys`.

### Diagnostics config

- `enabled?: boolean`
- `captureConsole?: boolean`
- `captureNetwork?: boolean`
- `includePayloads?: boolean`
- `includeResponses?: boolean`
- `maxConsoleEntries?: number`
- `maxNetworkEntries?: number`
- `maxPayloadLength?: number`
- `redactedKeys?: string[]`

## Accessibility notes

The widget includes:

- keyboard close via `Escape`
- focus return to the trigger button
- basic focus trapping inside the dialog
- `aria-invalid` and `role="alert"` for validation errors
- semantic `dialog` markup

## Troubleshooting

- Import styles explicitly. The package does not auto-inject CSS.
- If you use controlled mode, make sure `onOpenChange` updates your `open`
  state. Otherwise the dialog will not visually open or close.
- If files are rejected, verify both `accept` and `maxFileSizeMb`.
- If you enable `includePayloads` or `includeResponses`, remember that redaction
  is best-effort and should complement, not replace, server-side controls.

## Compatibility

- React `^18.3.0 || ^19.0.0`
- ESM and CommonJS entry points are exported
- SSR-safe render behavior: the widget returns `null` when `document` is not
  available

## Migration notes

- `buttonLabel` still works, but it is deprecated. Prefer
  `labels.buttonLabel`.
- Styles must continue to be imported explicitly from
  `@randrusname/support-widget/styles.css`.
- The diagnostics default is now safer: payloads and response bodies are
  excluded unless explicitly enabled.

## Versioning

This package is intended to follow semver once the public API stabilizes. Until
then, review release notes carefully when upgrading between early versions.