# @paramms/chat-widget

Real-time embeddable chat widget for the Relay platform. Drop into any website with a single script tag — no build step required.

## Install

```bash
npm install @paramms/chat-widget
```

## Quick start — CDN, ESM `mount()` (no npm, no build, manual container)

This is the ESM path — you control the mount point yourself. For a floating bubble on any site with zero JavaScript (or a config object), see [Script tag](#script-tag-any-website--plain-html-wordpress-shopify-phprailsdjangolaravel-or-literally-anything) below instead — it's usually simpler unless you specifically need to place the widget inline in your own container (`data-relay-*`/`Relay('boot', ...)` don't support choosing a container element; this ESM form does, via `el`).

```html
<div id="chat"></div>
<script type="module">
  import { mount } from 'https://relay.paramms.com/index.js'
  mount({
    el:        document.getElementById('chat'),
    url:       'https://api.relay.paramms.com',   // ONE url, any scheme — ws + REST derived
    profileId: 'YOUR_PROFILE_ID',
  })
</script>
```

## React / Next.js

### General support chat (`ChatWidget`)

```tsx
'use client'
import { ChatWidget } from '@paramms/chat-widget/react'

export default function SupportPage({ session }) {
  return (
    <ChatWidget
      url={process.env.NEXT_PUBLIC_RELAY_URL}
      profileId={process.env.NEXT_PUBLIC_RELAY_PROFILE_ID}
      userId={session?.user.id}        // optional — anonymous if omitted
      userName={session?.user.name}    // optional — shown to agents
      userEmail={session?.user.email}  // optional — shown to agents
    />
  )
}
```

### Marketplace / multi-listing chat (`MarketplaceChat`)

Two modes — one component:

**On a listing page** (with `listingId`) → opens that item's chat directly. No list. Buyer is already looking at the item.

```tsx
'use client'
import { MarketplaceChat } from '@paramms/chat-widget/react'

// Listing detail page — floating bubble in bottom-right corner
export default function ListingPage({ car, session }) {
  return (
    <>
      <YourPageContent />

      <MarketplaceChat
        url={process.env.NEXT_PUBLIC_RELAY_URL}
        profileId={process.env.NEXT_PUBLIC_RELAY_PROFILE_ID}
        listingId={car.id}
        listingTitle={car.title}          // shown in chat header
        listingPrice={car.price}          // shown as "$12,500" tag
        listingMeta="214,500 km · LPG"   // one line of detail
        listingStatus="Available"
        userId={session?.user.id}         // optional — anonymous if omitted
        userName={session?.user.name}     // optional
        launcher                          // floating bubble button
        position="bottom-right"
      />
    </>
  )
}
```

**Without `listingId`** → opens the buyer's single general (non-listing) thread.

**For a /messages inbox page** (WhatsApp-style thread list, tap to open, ✎ to start a new chat) use `ChatApp` — by default it lists **every conversation the user has with your business, across all your chatrooms**, and opens each against its own chatroom (`scope="tenant"`; pass `scope="profile"` for one chatroom only). It's a single-pane **stack**, like a native messaging app: the list fills the surface, tapping a row pushes the chatroom over it, and the chatroom's back chevron (‹) returns to the list — the same on desktop, in a launcher panel, and on mobile. The list updates **live** over the socket (a `subscribe_inbox` stream keyed to the user), so new messages and threads appear without a refresh.

**Add an inbox to a single-thread widget.** Pass `inbox` to `ChatWidget` or `MarketplaceChat` to add a back chevron in the chatroom header that opens the full `ChatApp` list (and the list's ✕ returns to the thread). `inboxScope="tenant"` (default) lists the user's threads across all chatrooms; `"profile"` limits it to the current one:

```tsx
<MarketplaceChat url={RELAY} profileId={PID} listingId={car.id} listingTitle={car.title} inbox />
```

```tsx
// Dedicated inbox page — e.g. /messages
import { ChatApp } from '@paramms/chat-widget/react'

export default function MessagesPage({ session }) {
  return (
    <div style={{ height: '600px' }}>
      <ChatApp
        url={process.env.NEXT_PUBLIC_RELAY_URL}
        tenantId={process.env.NEXT_PUBLIC_RELAY_TENANT_ID}  // or profileId={…} — either works
        userId={session?.user.id}
        userName={session?.user.name}
      />
    </div>
  )
}
```

Prefer a floating bubble that opens the same app in a panel? `<ChatAppLauncher … floating />`.

`ChatAppLauncher` (both `floating` and inline) shows an always-visible close (✕) in the panel's top-right, so it dismisses reliably on mobile too (where a fullscreen panel has no Escape key or reachable backdrop). If you embed a bare `<ChatApp />` inside your own overlay, pass `onClose={() => …}` to get the same close control; omit it for a plain inline embed you chrome yourself.

> **Multi-tenancy note:** conversations are created and keyed under the tenant the server resolves *from* `profileId` — guests can't spoof it. For **reading your own inbox**, `ChatApp`/`ChatAppLauncher` (and `useRelayChatList`/`mountChatList`/`GET /conversations/mine`) also accept a **`tenantId`** directly: it lists every conversation the user has with that business across all of its chatrooms, and is equally safe because the list is always self-scoped to the caller's own identity. `scope="tenant"` / `tenantId` never crosses into another business's data.

## Script tag (any website — plain HTML, WordPress, Shopify, PHP/Rails/Django/Laravel, or literally anything)

This is the path for every site that isn't React: one `<script>` tag, no build step, no framework. It works two ways — pick based on how much JavaScript you're willing to write:

- **Zero-JavaScript** — `data-relay-*` attributes right on the `<script>` tag. This is the one that matters most for a plain-HTML site, a WordPress block, or a no-code builder, because it's the *only* option when there's no JavaScript on the page at all — a server template just fills in attribute values.
- **Full config** — a JS object (`window.relaySettings = {...}` or `Relay('boot', {...})`, same shape either way). Needed the moment you want something an HTML attribute can't hold: a nested object, an array, a function, or changing the widget's identity *after* the page has loaded (e.g. once a visitor logs in).

Both configure the exact same widget with the exact same field names as the React components above — `profileId`, `userName`/`userEmail`/`userAvatar`, `contextTitle`/`contextSubtitle`/`contextStatus` (or `listingTitle`/`listingMeta`/`listingPrice`/`listingStatus` for a marketplace-style card), `accent`, `launcher`, `position`, `launcherMessage`. If you already know the React props, you already know these.

### Zero-JavaScript — `data-relay-*` attributes

```html
<script
  async
  src="https://relay.paramms.com/embed.js"
  data-relay-app="YOUR_PROFILE_ID"
  data-relay-user="user_123"
  data-relay-user-name="Jane Doe"
  data-relay-user-email="jane@example.com"
  data-relay-accent="#6c5ce7"
  data-relay-position="bottom-right"
></script>
```

Drop that before `</body>` and you have a working floating bubble — no other JavaScript needed anywhere on the page. Every attribute is optional except `data-relay-app`.

| Attribute | Matches field | Notes |
|---|---|---|
| `data-relay-app` | `profileId` | **Required.** `data-relay-profile-id` works identically — same thing, matches the React prop name |
| `data-relay-user` | `userId` | Unauthenticated stable id. Omit for an anonymous guest |
| `data-relay-token` | `token` | Signed ES256 JWT — the production identity tier |
| `data-relay-user-name` | `userName` | Shown to agents |
| `data-relay-user-email` | `userEmail` | Shown to agents; also powers the offline email fallback |
| `data-relay-user-avatar` | `userAvatar` | Shown to agents |
| `data-relay-listing` | `listingId` | Sugar for `subjectId: "listing_<id>"` |
| `data-relay-context-title` / `-subtitle` / `-status` | `contextTitle` / `contextSubtitle` / `contextStatus` | Context card (general — an order, ticket, booking) |
| `data-relay-listing-title` / `-meta` / `-price` / `-status` | `listingTitle` / `listingMeta` / `listingPrice` / `listingStatus` | Marketplace card (a specific item — price + status badge). Use these OR the `context-*` set, both build the same card |
| `data-relay-accent` | `accent` | Brand colour hex |
| `data-relay-url` | `url` | Only needed for self-hosted Relay |
| `data-relay-position` | `position` | `bottom-right` \| `bottom-left` |
| `data-relay-launcher` | `launcher` | Set to `"false"` for an inline (non-floating) widget |
| `data-relay-launcher-message` / `data-relay-launcher-subtitle` | `launcherMessage` | Two flat attributes combine into the teaser card — see note below |
| `data-relay-target` | `el` | CSS selector for an existing element to mount into. Ignored in launcher mode |
| `data-relay-height` | `height` | Inline container height. Ignored in launcher mode |
| `data-relay-inbox` | `inbox` | `"true"` adds a back-chevron → full conversation list. Requires `data-relay-launcher="false"` — see the note below the table |
| `data-relay-inbox-start` | `inboxStart` | `"true"` opens **directly on the conversation list** instead of a single thread — for a dedicated "Messages" page. Implies `inbox`. Requires `data-relay-launcher="false"` |
| `data-relay-inbox-scope` | `inboxScope` | `tenant` (default) \| `profile` |
| `data-relay-translate-lang` | `translateLang` | ISO code (e.g. `es`, `ja`) to auto-translate incoming messages into — shows a 🌐 button per message |
| `data-relay-tenant-id` | `tenantId` | Your business id. Lists threads across **all** your chatrooms without naming one — use instead of `data-relay-app` on an inbox page |

**What's NOT available as an attribute:** `user`/`subject` as nested objects, `quickReplies`, `i18n`, and `refreshToken` — an HTML attribute can only hold a string, so these need the JS-object form below. (`launcherMessage`'s two attributes are a workaround for exactly this: the React prop takes one `{ title, subtitle }` object, which an attribute can't express, so it's split into two flat attributes that get recombined.)

**`inbox` requires `data-relay-launcher="false"`** — same limit React has (a launcher+inbox combination needs a different component there, `ChatAppLauncher`); with the default floating launcher, `data-relay-inbox` is silently ignored.

**A whole "Messages" page, zero JavaScript.** `data-relay-inbox` alone only puts the list *behind* a back-chevron — the widget still lands on one thread, which is right for a product page and wrong for a page whose entire job is the inbox. `data-relay-inbox-start` makes the list the landing view, and `data-relay-tenant-id` spans every chatroom you run:

```html
<div id="relay-inbox" style="height:600px"></div>

<script
  async
  src="https://relay.paramms.com/embed.js"
  data-relay-tenant-id="acme"
  data-relay-inbox-start="true"
  data-relay-launcher="false"
  data-relay-target="#relay-inbox"
  data-relay-user="user_123"
></script>
```

Tapping a row opens that conversation against **its own** chatroom; its back-chevron returns to the list. There's no ✕ in this mode — the list is the root view, so there'd be nothing behind it to close to. ✎ compose opens against your default chatroom (the server reports which). This is the script-tag equivalent of React's `<ChatApp tenantId={...} />`.

### Full config — JS object

Same field names, all in one place, same object whether it's set on page load or reacts to something happening later:

```html
<script async src="https://relay.paramms.com/embed.js"></script>
<script>
  window.relaySettings = {
    profileId:        'YOUR_PROFILE_ID',   // required — your chatroom id
    url:               undefined,          // optional — only for self-hosted Relay
    apiUrl:            undefined,          // optional — only if REST lives on a different origin than the socket

    token:             undefined,          // optional — signed ES256 JWT, production identity (wins over userId)
    userId:            'user_123',         // optional — unauthenticated stable id; omit both → anonymous guest
    refreshToken:      () => fetchNewToken(),  // optional — called when a signed token expires

    userName:          'Jane Doe',              // optional — shown to agents, not identity
    userEmail:         'jane@example.com',      // optional — also powers the offline email fallback
    userAvatar:        'https://…/jane.png',    // optional
    // or, if it's easier to build one object: user: { name, email, avatar }

    subjectId:         undefined,          // optional — pins a dedicated thread; usually just use listingId below
    listingId:         '4821',             // optional — sugar for subjectId: "listing_4821"
    contextTitle:       'Order #4821',              // optional — context card title (general use)
    contextSubtitle:    'Placed Mar 3 · $129.00',   // optional — context card subtitle
    contextStatus:       'Shipped',                  // optional — status badge
    // or, for a marketplace-style card: listingTitle / listingMeta / listingPrice / listingStatus
    // or build the card yourself:        subject: { title, subtitle, tags, status }

    accent:            '#6c5ce7',           // optional — brand colour
    launcher:           true,               // optional — floating bubble vs inline; default true
    position:          'bottom-right',      // optional — 'bottom-right' | 'bottom-left'
    launcherMessage:   { title: 'Questions? Chat with us', subtitle: 'Start a conversation' },  // optional — a bare string also works (title only)

    quickReplies:      ['Track my order', 'Return an item'],   // optional — reply chips above the input
    i18n:              { send: 'Enviar' },                      // optional — UI string overrides
    translateLang:      undefined,          // optional — auto-translate incoming messages (ISO code)

    // Inline placement (ignored in launcher mode — a floating bubble is a
    // fixed popup mount() owns, not something placed at a point in the page):
    el:                 '#chat',            // optional — CSS selector or element; omit → an auto-created host
    height:             '600px',            // optional — inline container height
    inbox:              false,              // optional — back-chevron → full conversation list (inline only, see below)
    inboxScope:        'tenant',            // optional — 'tenant' (default, all your chatrooms) | 'profile' (this one only)
  }
</script>
```

`Relay('boot', {...})` (below) takes this exact same object — use whichever form fits how the page is built.

**`inbox` matches React exactly, including its one limit:** it only works with `launcher: false`. A floating launcher bubble is a fixed-size popup the widget owns; React itself needs a *different* component (`ChatAppLauncher`) for a launcher-with-inbox experience, and the script-tag path draws the same line. With `inbox: true` and `launcher: false`, the widget's header shows a back-chevron; tapping it swaps to the full conversation list (reusing the same list engine the dashboard's own inbox runs on); tapping a row opens that conversation; a ✕ in the list view returns to this widget's original thread.

### Commands — the same object, applied later

For identity that arrives after the page loads (a visitor logs in), or SPA-style navigation between pages without a full reload, call `Relay(...)` directly instead of (or in addition to) setting `window.relaySettings`. Same field names as above — `Relay('boot'/'identify'/'update', {...})` all take a `RelaySettings` object, just at a different moment:

```html
<script async src="https://relay.paramms.com/embed.js"></script>
<script>
  Relay('boot', { profileId: 'YOUR_PROFILE_ID' })      // mount immediately, anonymous

  // later, once the visitor logs in:
  Relay('identify', { userId: currentUser.id, userName: currentUser.name })   // merges their guest history in

  // on navigation to a different listing/order page:
  Relay('update', { listingId: newListing.id, listingTitle: newListing.title })

  // on logout:
  Relay('shutdown')                                     // removes the widget + local session
</script>
```

`Relay(...)` is queue-safe — calls made before `embed.js` finishes loading are never lost. `identify`/`update` merge onto whatever's currently running (a fresh `boot` replaces it entirely).

**Network requirement:** if the host page sets a Content-Security-Policy, it must allow the relay connection — `connect-src https://api.paramms.com wss://api.paramms.com;` (both are separate origins to the browser; include both), or your self-hosted equivalent.

### Old section below (kept for reference)

```tsx

'use client'
import { ChatWidget } from '@paramms/chat-widget/react'

export default function SupportChat() {
  return (
    <ChatWidget
      url="https://api.relay.paramms.com"
      profileId="YOUR_PROFILE_ID"
    />
  )
}
```

## Identified users (no separate login needed)

Pass your own user's ID as the token and their details via `user`. Anonymous users work without any configuration.

```tsx
<ChatWidget
  url="https://api.relay.paramms.com"
  profileId="YOUR_PROFILE_ID"
  token={currentUser.id}          // your own stable user ID — ties history across devices
  user={{
    name:   currentUser.name,     // shown to agents instead of opaque ID
    email:  currentUser.email,    // agents can follow up even after disconnect
    avatar: currentUser.avatarUrl,
    meta: {
      plan:      currentUser.plan,
      accountId: currentUser.id,
    },
  }}
/>
```

## Cryptographically verified identity (Tier 4)

For marketplaces and financial services — the guest's identity is verified against your ECDSA key so it cannot be spoofed.

```tsx
// 1. Generate a key pair (once, server-side)
//    openssl ecparam -genkey -name prime256v1 -noout | openssl pkcs8 -topk8 -nocrypt -out private.pem
//    openssl ec -in private.pem -pubout | base64 -w0  → paste in Dashboard → Domain → Guest identity linking

// 2. Sign a token per user (your backend)
import { createSign } from 'node:crypto'
const hdr = Buffer.from(JSON.stringify({ alg: 'ES256', typ: 'JWT' })).toString('base64url')
const pay = Buffer.from(JSON.stringify({ sub: userId, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 3600 })).toString('base64url')
const sig = createSign('SHA256').update(`${hdr}.${pay}`).sign(privateKey, 'base64url')
const signedToken = `${hdr}.${pay}.${sig}`

// 3. Pass to widget — server verifies the signature automatically
<ChatWidget url="..." profileId="..." token={signedToken} />
```

## Marketplace / multi-item (one thread per listing)

```tsx
<ChatWidget
  url="https://api.relay.paramms.com"
  profileId="YOUR_PROFILE_ID"
  subjectId={`car_${listing.id}`}   // one conversation per item
  showChatList={true}                // guest can switch between their threads
/>
```

## Launcher (floating button)

```tsx
<ChatWidget
  url="https://api.relay.paramms.com"
  profileId="YOUR_PROFILE_ID"
  launcher={true}
  position="bottom-right"
  accent="#6c5ce7"
/>
```

### Launcher message (the teaser card)

An optional card shown **above the closed bubble** to invite a conversation.
Visitors can dismiss it with the ×; it stays dismissed for the browser session
and retires automatically once the chat is opened.

```tsx
<ChatWidget
  url="https://api.relay.paramms.com"
  profileId="YOUR_PROFILE_ID"
  launcher
  launcherMessage={{ title: 'Questions? Chat with us', subtitle: 'Start a conversation' }}
/>
```

A bare string works too (title only): `launcherMessage="Need help?"`.

**You usually don't need this prop.** Set the message once per chatroom in the
dashboard (*Chatrooms → your chatroom → Widget message & availability*) and it
arrives automatically over the socket. Pass `launcherMessage` only to override
the dashboard value for one embed (it also renders instantly, before the
socket connects).

Script-tag embeds use `data-relay-launcher-message` / `data-relay-launcher-subtitle` (or the `launcherMessage: {title,subtitle}` object in JS-object form) — see the [Script tag](#script-tag-any-website--plain-html-wordpress-shopify-phprailsdjangolaravel-or-literally-anything) section above for the full reference.

## Internationalisation

```tsx
<ChatWidget
  url="https://api.relay.paramms.com"
  profileId="YOUR_PROFILE_ID"
  i18n={{
    placeholder: 'Écrivez un message…',
    send:        'Envoyer',
    offline:     'Nous sommes absents pour le moment',   // the away notice
    poweredBy:   '',   // empty string hides the footer
  }}
/>
```

`offline` is the fallback text for the **away notice** shown above the composer
outside the chatroom's office hours. It never blocks anything: guests can always
send, the message is delivered like any other, and an agent replies when they're
back. The chatroom's own "offline message" (dashboard → *Widget message &
availability*) takes precedence over this string.

RTL is detected automatically for Arabic, Hebrew, Persian and Urdu browsers.

## All options

| Option | Type | Default | Description |
|---|---|---|---|
| `el` | `HTMLElement` | required | Mount target |
| `url` | `string` | required | Relay URL — ONE url, any scheme (`https://api.relay.paramms.com`); the WebSocket URL and REST base are derived |
| `profileId` | `string` | required | Domain profile ID |
| `token` | `string` | auto-generated | Guest identity token — pass your user's stable ID to tie history across devices |
| `user` | `UserInfo` | — | Name, email, avatar, custom metadata — shown to agents |
| `subjectId` | `string` | — | Item ID for marketplace mode |
| `showChatList` | `boolean` | `false` | Show conversation switcher in header |
| `accent` | `string` | `#6c5ce7` | Brand colour (hex) |
| `launcher` | `boolean` | `false` | Render as floating button |
| `position` | `'bottom-right' \| 'bottom-left'` | `'bottom-right'` | Launcher position |
| `launcherMessage` | `string \| { title, subtitle? }` | chatroom setting | Teaser card above the closed launcher. Overrides the dashboard's per-chatroom message; dismissible per session |
| `translateLang` | `string` | — | Auto-translate incoming messages (ISO language code) |
| `quickReplies` | `string[]` | — | Pre-set reply chips shown above input |
| `i18n` | `I18nStrings` | English | UI string overrides |
| `inbox` | `boolean` | `false` | Add a ‹ back in the chatroom header that opens the full `ChatApp` conversation list (inline widgets only) |
| `inboxScope` | `'tenant' \| 'profile'` | `'tenant'` | With `inbox`: list threads across all chatrooms (`tenant`) or just this one (`profile`) |
| `inboxStart` | `boolean` | `false` | Land ON the conversation list instead of a single thread (a dedicated "Messages" page). Implies `inbox`; requires `launcher: false`. Script-tag/embed only — in React use `<ChatApp/>` directly |
| `tenantId` | `string` | — | Script-tag/embed only. Your business id: lists threads across ALL your chatrooms with no chatroom named. Only meaningful with `inboxStart` — a single thread still needs a `profileId` |

## Development

```bash
npm install
npm run dev          # Vite preview at http://localhost:5174/
npm run build        # tsc → dist/
npm run build:bundle # Vite → standalone CDN bundle
npm test             # 75 tests via vitest
npm run typecheck
```
