# @sensiblestats/widget-react

React component for the SensibleStats operator chat widget. Renders a floating
launcher and chat panel, built on `@sensiblestats/widget-sdk`.

## Install

`yarn add @sensiblestats/widget-react`

Peer dependencies: `react` and `react-dom` (18 or 19).

## Usage

```tsx
import { StatsWidget } from '@sensiblestats/widget-react'

export function App() {
  return (
    <StatsWidget
      config={{
        operatorId: 'sandbox1',
        publicKey: 'wgk_…',
        baseUrl: 'https://widget.sensiblestats.com',
        locale: 'en',
      }}
    />
  )
}
```

Common `config` fields:

- `operatorId`, `publicKey` — your gateway credentials (both browser-safe; the
  public key is origin-gated at the gateway).
- `baseUrl` — the widget gateway URL.
- `locale` — `'en'` or `'tr'` for the widget's own chrome text.
- `userContext` — `{ operatorUserId, defaultLanguage }` to tie the conversation
  and personalization to your signed-in user.
- White-label branding — `logoUrl`, `headerTitle`, `accent`, `cta`, `surface`,
  `ink`. Absent → the default SensibleStats look.

## Hooks: the slip and the match page

Optional hooks keep the widget and your site in step. Two are `config` callbacks, from the widget
to your code. Three are methods on a host object you create, from your code to the widget.

```tsx
// widget-host.ts
import { createWidgetHost } from '@sensiblestats/widget-react'

// Create it once, at module scope. It is safe under server rendering.
export const widgetHost = createWidgetHost()
```

```tsx
<StatsWidget
  host={widgetHost}
  config={{
    ...config,
    onAddToSlip: (selection) => addToMySlip(selection),
  }}
/>
```

```ts
widgetHost.slipRemoved({ matchOddsId })                  // a leg left your slip
widgetHost.betPlaced({ selections: [{ matchOddsId }] })  // the bet was placed
widgetHost.matchOpened({ matchId })                      // your match page opened
```

What each one does:

- `onAddToSlip`. Called when the user taps "Add to slip", with `{ matchOddsId, matchId, oddsDecimal }`.
  Add that selection to your slip. Return nothing, or a result such as `{ status: 'added' }`,
  `{ status: 'duplicate' }` or `{ status: 'rejected', reason }`. The widget shows "In slip" for an
  added or duplicate selection. Answer within 10 seconds. Without a `host`, an "In slip" button
  stays that way until the page reloads; with one, `slipRemoved` and `betPlaced` clear it.
- `onOpenMatch` (deprecated). Ignored since 0.72.1: the widget no longer opens matches from any
  surface, so team names are plain text and this is never called. It stays in the config type so
  existing hosts still compile.
- `slipRemoved`. Clears the widget's "In slip" mark for that selection. Call it whenever a leg
  leaves your slip for any reason, a "clear all" included. A leg the widget still believes is in
  the slip can be reported as bet later.
- `betPlaced`. Pass every leg of the bet. The widget reports the legs it added itself, at most 25
  per call, and ignores the rest, so you do not filter.
- `matchOpened`. Call it from your match page. A view counts only for a visitor who has opened the
  widget in that tab, and at most once per match every 30 minutes.

Rules for the three host methods:

- `matchOddsId` and `matchId` are the numbers `onAddToSlip` gave you. Anything that is not a
  positive integer is ignored, a numeric string included.
- No method throws, and each is safe to call twice.
- A call made while no widget is mounted is queued, the last 20, and replayed when one mounts. The
  queue lives in the page, so it does not survive a full page load.
- Use one host for one mounted widget.

## Integration: persisting the conversation across SPA navigation

The conversation (messages, conversation id, open/closed state) lives in the
component's in-memory React state. It survives for exactly as long as the
`<StatsWidget>` instance stays mounted:

- **In-app (client-side) navigation** keeps it — as long as the component is not
  unmounted.
- **A hard page refresh** tears down the React tree and remounts the widget, so
  the conversation starts fresh. This is intentional.

To keep the active conversation across route changes in a single-page app,
**mount `<StatsWidget>` once in a component that stays mounted across
navigation** — your app shell / persistent layout — not inside a routed page.

### React Router (v6)

Render the widget in the layout route element, alongside `<Outlet />`, so it
persists while pages swap underneath it:

```tsx
// AppLayout.tsx
import { Outlet } from 'react-router-dom'
import { StatsWidget } from '@sensiblestats/widget-react'

// Define config once, outside render, so its identity is stable across renders.
const widgetConfig = {
  operatorId: 'sandbox1',
  publicKey: 'wgk_…',
  baseUrl: 'https://widget.sensiblestats.com',
}

export function AppLayout() {
  return (
    <>
      <Header />
      <Outlet />                          {/* pages render here and swap on navigation */}
      <StatsWidget config={widgetConfig} />  {/* stays mounted across route changes */}
    </>
  )
}
```

```tsx
// routes — AppLayout wraps the routed pages, so it is not remounted per route
<Route element={<AppLayout />}>
  <Route path="/" element={<Home />} />
  <Route path="/matches" element={<Matches />} />
</Route>
```

### Next.js App Router

Render `<StatsWidget>` in the root `app/layout.tsx` (a client component, or a
small `'use client'` wrapper). The root layout is not re-rendered on route
navigation, so the conversation persists; a full reload resets it.

### What breaks persistence

Both of these force React to unmount and remount the widget, wiping the
conversation on every navigation — avoid them:

- **Rendering `<StatsWidget>` inside a per-route page component.** React Router
  unmounts the page (and the widget with it) on navigation.
- **Changing the component's `key`, or passing a `config` object rebuilt on
  every render, in a way that remounts the subtree.** Keep the widget's place in
  the tree and its `config` reference stable — define `config` at module scope or
  memoize it with `useMemo`.

The launcher is fixed-position, so the app-shell mount is also the correct place
for it visually — it does not belong to any single page.
