# gotcha-feedback

A developer-first contextual feedback SDK for React. Add a feedback button to any component with a single line of code.

## Installation

```bash
npm install gotcha-feedback
# or
yarn add gotcha-feedback
# or
pnpm add gotcha-feedback
```

## Quick Start

```tsx
import { GotchaProvider, Gotcha } from 'gotcha-feedback';

function App() {
  return (
    <GotchaProvider apiKey="your-api-key">
      <YourApp />
    </GotchaProvider>
  );
}

function FeatureCard() {
  return (
    <div style={{ position: 'relative' }}>
      <h2>New Feature</h2>
      <p>Check out this awesome new feature!</p>
      <Gotcha elementId="feature-card" />
    </div>
  );
}
```

## Features

- **Feedback Mode** - Star rating + text input
- **Vote Mode** - Thumbs up/down with customizable labels
- **Poll Mode** - Custom options (single or multi-select)
- **NPS Mode** - 0-10 Net Promoter Score with optional follow-up
- **Score Component** - Embeddable `<GotchaScore />` to display aggregate ratings inline
- **Bug Flagging** - Let users flag feedback as issues/bugs with a single toggle
- **User Segmentation** - Analyze feedback by custom user attributes
- **One Answer Per User** - With `onePerUser`, a user who already answered doesn't see the widget again (until `cooldownDays` pass)
- **Programmatic Trigger** - Open/close the widget from code with `useGotchaTrigger`
- **Follow-up Questions** - Ask a follow-up after low ratings or negative votes
- **Trigger Conditions** - Show widget after time delay, scroll depth, or visit count
- **Screenshot Capture** - Users can attach screenshots when reporting bugs
- **Offline Support** - Submissions queue in localStorage when offline, flush on reconnect
- **Context Enrichment** - Viewport, timezone, language, and recent JS errors auto-attached
- **Visibility Control** - Hide widget for N days after submission, reset onePerUser after N days
- **Customizable** - Themes, sizes, positions
- **Accessible** - Full keyboard navigation and screen reader support
- **Animated** - Smooth enter/exit animations with CSS transitions

## Next.js App Router

Every export in this package is a client component — `GotchaProvider` holds
React context, and the widgets use state and effects. The package ships with
the `"use client"` directive, so the Quick Start above works as written: you
can import `GotchaProvider` directly in `app/layout.tsx` even though that is a
Server Component.

If you prefer to keep the boundary explicit — or you are on a bundler that
does not infer it — put the provider in its own file:

```tsx
// app/providers.tsx
'use client';

import { GotchaProvider } from 'gotcha-feedback';

export function Providers({ children }: { children: React.ReactNode }) {
  return <GotchaProvider apiKey={process.env.NEXT_PUBLIC_GOTCHA_KEY!}>{children}</GotchaProvider>;
}
```

```tsx
// app/layout.tsx — stays a Server Component
import { Providers } from './providers';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}
```

Individual `<Gotcha />` widgets can then be dropped into any Server Component
page — they are client components and Next will handle the boundary.

## Bundle size

~29KB gzipped for the core bundle. A further ~10KB loads lazily, and only if
you enable screenshot capture (`enableScreenshot`).

Roughly a third of the core is the display font, embedded as a `data:` URL.
That is what buys the zero-third-party-request posture below: the widget makes
no network call to Google Fonts or anywhere else.

## Content Security Policy

The SDK injects its stylesheet as an inline `<style>` tag and embeds its
display font as a `data:` URL inside that stylesheet. If your site runs
a strict CSP, you will need the following directives:

- `style-src 'unsafe-inline'` (or a matching nonce) — the SDK injects
  its own stylesheet at runtime.
- `font-src data:` (or `font-src 'self' data:`) — the branded serif font
  is embedded as a base64 data URL so the SDK makes **zero** third-party
  font requests.

The SDK makes no other external requests for fonts, analytics, or
tracking. See `LICENSE.FRAUNCES.txt` for the bundled Fraunces subset
license (OFL 1.1).

## Props

### GotchaProvider

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `apiKey` | `string` | Required | Your Gotcha API key |
| `baseUrl` | `string` | `https://gotcha.cx/api/v1` | Override API endpoint |
| `debug` | `boolean` | `false` | Enable debug logging |
| `disabled` | `boolean` | `false` | Disable all Gotcha buttons |
| `defaultUser` | `object` | `{}` | Default user metadata |
| `captureErrors` | `boolean` | `true` | Attach recent JS errors to submissions. `false` installs no error listeners and sends no `recentErrors` |

### Gotcha

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `elementId` | `string` | Required | Unique identifier for this element |
| `mode` | `'feedback' \| 'vote' \| 'poll' \| 'nps'` | `'feedback'` | Feedback mode |
| `position` | `'top-right' \| 'top-left' \| 'bottom-right' \| 'bottom-left' \| 'inline'` | `'top-right'` | Button position |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Button size |
| `theme` | `'light' \| 'dark' \| 'auto'` | `'light'` | Color theme |
| `showOnHover` | `boolean` | `true` | Only show on hover |
| `headless` | `boolean` | `false` | Render no button; open with `useGotchaTrigger`. The modal centres in the viewport |
| `contextPath` | `string` | Real pathname | Path sent as `context.url` (origin is prepended), e.g. a route template `"/accounts/[id]"` |
| `showText` | `boolean` | `true` | Show the text input in feedback mode |
| `showRating` | `boolean` | `true` | Show the star rating in feedback mode |
| `promptText` | `string` | Mode-specific | Custom prompt text |
| `voteLabels` | `{ up: string, down: string }` | `{ up: 'Like', down: 'Dislike' }` | Custom vote button labels |
| `options` | `string[]` | - | Poll options (2-6 items, required for poll mode) |
| `allowMultiple` | `boolean` | `false` | Allow selecting multiple poll options |
| `npsQuestion` | `string` | `'How likely are you to recommend us?'` | Custom NPS question |
| `npsFollowUp` | `boolean` | `true` | Show follow-up textarea after NPS score |
| `npsFollowUpPlaceholder` | `string` | - | Placeholder for NPS follow-up textarea |
| `npsLowLabel` | `string` | `'Not likely'` | Label for low end of NPS scale |
| `npsHighLabel` | `string` | `'Very likely'` | Label for high end of NPS scale |
| `enableBugFlag` | `boolean` | `false` | Show "Report an issue" toggle in feedback form |
| `bugFlagLabel` | `string` | `'Report an issue'` | Custom label for the bug flag toggle |
| `bugFlagActiveLabel` | `string` | `'Reported as an issue'` | Label shown while the bug flag is on |
| `hintText` | `string` | - | Permanent hint under the feedback textarea, linked with `aria-describedby` |
| `focusRing` | `boolean` | `false` | Visible ink focus ring on the open button and modal fields |
| `initialFocus` | `'close' \| 'input'` | `'close'` | Where focus lands when the modal opens. `'input'` focuses the textarea (falls back to close) |
| `keepDraft` | `boolean` | `false` | Keep unsent text, rating, bug flag and screenshot when the modal is closed; cleared on submit. In memory only |
| `plainErrors` | `boolean` | `false` | Plain-English failure messages instead of the raw error. `onError` still gets the original |
| `enableScreenshot` | `boolean` | `false` | Enable screenshot capture when bug flag is toggled (requires `enableBugFlag`) |
| `onePerUser` | `boolean` | `false` | Hide the widget from a user who already answered it (needs `user.id`). Within the same session, a second submit updates the first |
| `cooldownDays` | `number` | - | Allow new submission after N days (requires `onePerUser`) |
| `hideAfterSubmitDays` | `number` | - | Hide widget for N days after submission (localStorage) |
| `showAfterSeconds` | `number` | - | Delay showing widget by N seconds |
| `showAfterScrollPercent` | `number` | - | Show widget after scrolling past N% (0-100) |
| `showAfterVisits` | `number` | - | Show widget after N page visits (localStorage) |
| `followUp` | `object` | - | Show follow-up question after low rating or negative vote |
| `followUp.ratingThreshold` | `number` | - | Ratings at or below this trigger the follow-up |
| `followUp.onNegativeVote` | `boolean` | - | Trigger follow-up on thumbs down |
| `followUp.promptText` | `string` | Required | The follow-up question text |
| `followUp.placeholder` | `string` | `'Tell us more...'` | Placeholder for follow-up textarea |
| `user` | `object` | - | User metadata for segmentation |
| `onSubmit` | `function` | - | Callback after submission |
| `onOpen` | `function` | - | Callback when modal opens |
| `onClose` | `function` | - | Callback when the user closes the modal |
| `onAutoClose` | `function` | - | Callback when the success screen closes itself after submitting (`onClose` is not called then) |
| `onError` | `function` | - | Callback when a submission fails |

### Edit tokens (1.4.0)

The server now returns an `editToken` with each new response, valid for 24 hours. The SDK uses it for the follow-up question and for a same-visit update under `onePerUser`; you don't need to handle it. Two changes to know about:

- `onePerUser` hides the widget from someone who already answered, instead of loading their old answer for editing.
- A follow-up answer waits for the server to acknowledge the response it belongs to; its Submit button shows as sending until then.

**Breaking (low-level client only).** If you call `useGotcha().client` directly:

- `checkExistingResponse()` now resolves to `{ createdAt }` (or `null`), not the earlier answer's content.
- `updateResponse(id, payload, { editToken })` replaces passing a user id. A user id string still works against servers that accept it.

The components and hooks above need no changes.

**Upgrade if you use `onePerUser`.** Once Gotcha's API stops returning earlier answers, SDKs before 1.4.0 can no longer tell that a user already answered: the widget keeps showing and every submit creates a new response. 1.4.0 handles the new API. `client.flagAsBug(id, { editToken })` likewise needs the token from `submitResponse`.

Before 1.4.0 the server rejected the follow-up answer (after a low rating or a thumbs-down) because the request carried no proof that this browser wrote the response. With the token it is saved.

### Accessibility and polish (1.3.1)

Every 1.3.1 prop is opt-in. With none set, the widget behaves exactly as in 1.3.0.

```tsx
<Gotcha
  elementId="checkout"
  enableBugFlag
  bugFlagActiveLabel="Flagged as a bug"
  hintText="Don't include card numbers or passwords."
  focusRing
  initialFocus="input"
  keepDraft
  plainErrors
  onClose={handleClose}
  onAutoClose={handleClose}
/>
```

- `hintText`, `initialFocus="input"` and `keepDraft` apply to feedback mode, the one with a textarea.
- `initialFocus="input"` opens the on-screen keyboard on phones. With `onePerUser`, the textarea appears after the earlier-answer check, so focus stays on the close button.
- To hear about every close, pass the same handler to `onClose` and `onAutoClose`.

## Examples

### Inline Position

```tsx
<h2>
  Feature Title
  <Gotcha elementId="title" position="inline" size="sm" showOnHover={false} />
</h2>
```

### Vote Mode

```tsx
<Gotcha
  elementId="pricing-feedback"
  mode="vote"
  promptText="Is this pricing fair?"
/>
```

### Vote Mode with Custom Labels

```tsx
<Gotcha
  elementId="ship-feature"
  mode="vote"
  voteLabels={{ up: "Yes", down: "No" }}
  promptText="Should we ship this feature?"
/>
```

### Poll Mode

```tsx
<Gotcha
  elementId="priority-poll"
  mode="poll"
  options={["Yes", "No", "Maybe"]}
  promptText="Should we ship this feature?"
/>
```

### Poll Mode (Multi-Select)

```tsx
<Gotcha
  elementId="feature-priority"
  mode="poll"
  options={["Analytics", "Segments", "Exports", "API"]}
  allowMultiple
  promptText="Which features matter most?"
/>
```

### NPS Mode

```tsx
<Gotcha
  elementId="nps-survey"
  mode="nps"
  npsQuestion="How likely are you to recommend us?"
  npsLowLabel="Not likely"
  npsHighLabel="Very likely"
/>
```

### Bug Flagging

Add a "Report an issue" toggle to any feedback form. When toggled on, the submission is automatically flagged as a bug and creates a ticket in your dashboard.

```tsx
<Gotcha
  elementId="checkout"
  mode="feedback"
  enableBugFlag
/>
```

```tsx
// Custom label for non-technical users
<Gotcha
  elementId="portal"
  mode="feedback"
  enableBugFlag
  bugFlagLabel="Something isn't working"
/>
```

### Screenshot Capture

When bug flagging is enabled, you can also let users capture and attach a screenshot. Capture renders the page's own DOM (via the bundled `modern-screenshot`) and never uses the Screen Capture API, so the browser's share-screen prompt never appears and nothing outside your page can be captured.

```tsx
<Gotcha
  elementId="checkout"
  mode="feedback"
  enableBugFlag
  enableScreenshot
/>
```

### Follow-up Questions

Ask a follow-up question when users give a low rating or negative vote. The follow-up updates the original response.

```tsx
<Gotcha
  elementId="feature"
  mode="feedback"
  followUp={{
    ratingThreshold: 2,          // Ratings 1-2 trigger follow-up
    promptText: "What could we improve?",
    placeholder: "Tell us more...",
  }}
/>
```

```tsx
// Follow-up on negative vote
<Gotcha
  elementId="article"
  mode="vote"
  followUp={{
    onNegativeVote: true,
    promptText: "What went wrong?",
  }}
/>
```

### Trigger Conditions

Control when the widget appears based on user engagement. All specified conditions must be met (AND logic).

```tsx
// Show after 5 seconds on page
<Gotcha elementId="onboarding" showAfterSeconds={5} />

// Show after scrolling 50%
<Gotcha elementId="article-end" showAfterScrollPercent={50} />

// Show after 3 visits
<Gotcha elementId="feature" showAfterVisits={3} />

// Combine: show after 10 seconds AND 3 visits
<Gotcha elementId="power-user" showAfterSeconds={10} showAfterVisits={3} />
```

### Visibility Control

Hide the widget for a period after submission, or allow re-submission after a cooldown.

```tsx
// Hide widget for 7 days after submission
<Gotcha elementId="nps" hideAfterSubmitDays={7} />

// One response per user, but allow new submission after 30 days
<Gotcha
  elementId="quarterly-survey"
  onePerUser
  cooldownDays={30}
  user={{ id: currentUser.id }}
/>
```

### Feedback Field Options

By default, feedback mode shows both a star rating and a text input. Use `showText` and `showRating` to control which fields appear.

```tsx
// Text only (no star rating)
<Gotcha
  elementId="text-only"
  showRating={false}
/>
```

```tsx
// Rating only (no text input)
<Gotcha
  elementId="rating-only"
  showText={false}
/>
```

```tsx
// Both fields (default behavior)
<Gotcha
  elementId="full-feedback"
  showText={true}
  showRating={true}
/>
```

### Dark Theme

```tsx
<Gotcha
  elementId="dark-card"
  theme="dark"
/>
```

### With User Data

Pass user metadata for segmentation and analytics. A `user.id` is also what `onePerUser` uses to recognise someone who already answered.

```tsx
// Set default user data at the provider level
<GotchaProvider
  apiKey="your-api-key"
  defaultUser={{ plan: 'pro', role: 'admin' }}
>
  <App />
</GotchaProvider>
```

```tsx
// Pass dynamic user data from your app
<Gotcha
  elementId="feature-card"
  user={{
    id: currentUser.id,           // Required for onePerUser
    email: currentUser.email,
    plan: currentUser.subscription,
    age: currentUser.age,
    country: currentUser.country,
    company: currentUser.company
  }}
/>
```

```tsx
// Or capture device/browser info
<Gotcha
  elementId="checkout"
  user={{
    id: user.id,
    deviceType: isMobile ? 'mobile' : 'desktop',
    browser: navigator.userAgent.includes('Chrome') ? 'Chrome' : 'Other',
    locale: navigator.language
  }}
/>
```

Pass any attributes relevant to your use case. Supported value types: `string`, `number`, `boolean`, or `null`.

### Custom Callbacks

```tsx
<Gotcha
  elementId="feature"
  onSubmit={(response) => {
    console.log('Feedback submitted:', response);
    analytics.track('feedback_submitted', { elementId: 'feature' });
  }}
  onError={(error) => {
    console.error('Feedback error:', error);
  }}
/>
```

## Hooks

### useGotchaTrigger

Open or close a specific widget programmatically. The corresponding `<Gotcha>` component must be mounted.

```tsx
import { useGotchaTrigger } from 'gotcha-feedback';

function ErrorBoundary({ children }) {
  const { open } = useGotchaTrigger('error-feedback');

  const handleError = () => {
    open(); // Opens the feedback modal for "error-feedback"
  };

  return <ErrorHandler onError={handleError}>{children}</ErrorHandler>;
}

// The widget must be rendered somewhere in the tree
<Gotcha elementId="error-feedback" mode="feedback" promptText="What happened?" />
```

Returns `{ open, close, isOpen }`.

To use your own trigger (a menu item, a help link) with no Gotcha button at all, render the widget `headless`:

```tsx
<Gotcha elementId="app-feedback" headless />

function HelpMenu() {
  const { open } = useGotchaTrigger('app-feedback');
  return <button onClick={open}>Send feedback</button>;
}
```

`onOpen` fires only from the Gotcha button, so in headless mode run your own code where you call `open()`.

### useGotcha

Access the Gotcha context for programmatic control:

```tsx
import { useGotcha } from 'gotcha-feedback';

function MyComponent() {
  const { submitFeedback, openModal, closeModal } = useGotcha();

  const handleCustomSubmit = async () => {
    await submitFeedback({
      elementId: 'custom',
      mode: 'feedback',
      rating: 5,
      content: 'Great feature!',
    });
  };

  return <button onClick={handleCustomSubmit}>Submit Feedback</button>;
}
```

Returns `{ client, disabled, defaultUser, debug, submitFeedback, openModal, closeModal, activeModalId }`.

## User Metadata & Segmentation

When you pass custom attributes in the `user` prop, Gotcha automatically tracks them and enables segmentation in your dashboard.

### How It Works

1. **Pass user attributes** when rendering the Gotcha component
2. **View segmented analytics** in your dashboard under Analytics > Segments
3. **Filter and compare** feedback by user attributes (plan, age, location, etc.)

### Example Use Cases

```tsx
// Segment by subscription plan
<Gotcha elementId="pricing" user={{ id: user.id, plan: user.plan }} />
// → Compare how free vs. pro users feel about pricing

// Segment by device type
<Gotcha elementId="checkout" user={{ id: user.id, device: isMobile ? 'mobile' : 'desktop' }} />
// → See if mobile users have different pain points

// Segment by country
<Gotcha elementId="shipping" user={{ id: user.id, country: user.country }} />
// → Understand regional differences in feedback

// Segment by user tenure
<Gotcha elementId="dashboard" user={{ id: user.id, accountAge: user.daysActive > 30 ? 'established' : 'new' }} />
// → Compare new user vs. power user sentiment
```

In your dashboard under Analytics > Segments, you can group responses by any of these attributes.

## One Answer Per User

With `onePerUser` and a `user.id`, the widget asks each user once.

```tsx
<Gotcha elementId="quarterly-survey" onePerUser user={{ id: 'user_123' }} />
```

- If the user already answered (for example on an earlier visit), the widget is not shown. Add `cooldownDays` to ask again after that many days. While the SDK is checking, the widget stays hidden, so it never flashes and disappears.
- Until the widget unmounts, submitting again updates the answer just given; the modal shows "Update".
- From 1.4.0 the SDK never needs an earlier answer's content, and it stops using it. Gotcha's API is moving to return only whether an answer exists, so nobody holding your public key can read or change someone else's feedback.

## GotchaScore Component

Display aggregate feedback scores inline — star ratings, vote percentages, or raw numbers.

```tsx
import { GotchaScore } from 'gotcha-feedback';

// Star rating display
<GotchaScore elementId="feature-card" variant="stars" />

// Compact pill (star + number)
<GotchaScore elementId="feature-card" variant="compact" />

// Vote percentage bar
<GotchaScore elementId="pricing" variant="votes" />

// Plain number
<GotchaScore elementId="feature-card" variant="number" />
```

### GotchaScore Props

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `elementId` | `string` | Required | Element to show score for |
| `variant` | `'stars' \| 'number' \| 'compact' \| 'votes'` | `'stars'` | Display variant |
| `theme` | `'light' \| 'dark' \| 'auto'` | `'auto'` | Color theme |
| `refreshInterval` | `number` | - | Auto-refresh interval in ms |

## Offline Support

Submissions are automatically queued in localStorage when the user is offline. When connectivity returns, queued items are flushed automatically. A subtle indicator appears on the widget button when items are queued. No configuration needed — this works out of the box.

## Context Enrichment

Every submission automatically includes enriched context beyond the page URL and user agent:

- **Viewport size** (width x height)
- **Language** and **timezone**
- **Screen resolution**
- **Recent JS errors** (last 10 uncaught errors and unhandled rejections)

This context appears in your dashboard and is included in bug ticket descriptions. No configuration needed.

The URL is `origin + pathname`, never the query string or hash. Two opt-outs for apps where that is still too much:

```tsx
// Send the route template, not the real path with record IDs in it
<Gotcha elementId="account" contextPath="/crm/accounts/[id]" />

// Never listen for or send JS error messages
<GotchaProvider apiKey="..." captureErrors={false}>
```

## Reading your data (REST API)

Your project API key also reads data back: `GET /api/v1/responses`, `GET /api/v1/responses/{id}` and, on Pro, `GET /api/v1/bugs` (filter by `status`, `priority`, `elementId`, date range).

```bash
curl -H "Authorization: Bearer $GOTCHA_API_KEY" "https://gotcha.cx/api/v1/bugs?status=open,investigating&priority=high"
```

## TypeScript

The package includes full TypeScript definitions:

```tsx
import type {
  GotchaProps,
  GotchaProviderProps,
  ScoreData,
  SubmissionContext,
  GotchaResponse,
  GotchaError,
} from 'gotcha-feedback';
```

## Requirements

- React 18+

## License

MIT
