# Notification Bell & Inbox (Web)

Add a notification bell with an unread badge and a popup inbox panel to any web site — plain JS, React or Next.js — reading each device's universal inbox for list, unread count, read/unread, delete and deep links. Agent Notify can add it to your repo in a pull request.

The **web bell + inbox** is a drop-in widget for your site: a bell button with an unread badge and an inbox panel — list, read/unread, delete, and deep links — that reads the **universal inbox API** for a device. It ships for **plain JS, React and Next.js**, has no dependencies, and needs no build step.

> **Pair it with browser push:**
>
> The widget is the in-app history; **web push** is what wakes the browser. Set that up first — it takes one service worker and one registration call, and there is nothing to paste: see the [Web Push Recipe](/docs/universal-push/web-push).

> **Agent Notify can add it for you:**
>
> You do not have to wire this up by hand. Ask Agent Notify to *"add a notification bell to my web app"* — it opens a pull request on an `agent-notify/*` branch with the three widget files plus a guide, exactly like the push setup flow. See [Let the agent add it](#let-the-agent-add-it).

## Let the agent add it

Agent Notify treats the widget as an **add-on**: an opt-in capability applied on top of whatever stack your repo uses, in the same setup pull request (see [Framework Recipes](/docs/universal-push/recipes) for the base recipes). The tool call is:

```json
{
  "tool": "start_setup_session",
  "args": {
    "task": "add a notification bell + inbox to my site",
    "addons": ["web-bell-inbox"]
  }
}
```

The same add-on can be fetched as a guide without touching the repo:

```json
{
  "tool": "get_setup_guide",
  "args": { "stack": "web", "addons": ["web-bell-inbox"] }
}
```

What the pull request adds (new files only — nothing existing is modified):

| File                                     | What it is                                                                                                                  |
| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `native-notify/web/nativeNotifyBell.js`  | Plain-JS ES module: `mountNativeNotifyBell(options)` and `createNativeNotifyBell(options)`. No dependencies, no build step. |
| `native-notify/web/NativeNotifyBell.jsx` | React / Next.js component (`"use client"`) plus the `useNativeNotifyInbox` headless hook.                                   |
| `native-notify/web/nativeNotifyBell.css` | Styles + theming for both entry points (shared class names).                                                                |
| `native-notify/web/README.md`            | The add-on's own setup guide and checklist.                                                                                 |

If you already merged the push-setup PR, running the add-on session again writes only the new widget files (files that already exist are never overwritten or duplicated).

## What the widget does

- **Bell button with a live unread badge** — a numeric count (`99+` past the cap) or a plain dot.
- **Inbox panel**: entries newest first with title, body, time, and a per-row unread dot.
- **Read state per entry** — opening the panel does *not* mark everything read; a row is marked read when it is opened, and **Mark all read** is an explicit action. Read state lives on the server, so every tab and the app agree.
- **Delete** per row (soft delete, idempotent) — hide the button with `allowDelete: false`.
- **Deep links** — put a `url` in the notification's `pushData` and the row opens it on click.
- **Live unread count by polling** (default every 30s, paused while the tab is hidden). The API has no SSE channel.
- **Theming** — a `theme` object or CSS custom properties; light/dark defaults follow `prefers-color-scheme`.
- **Accessibility** — a real button with `aria-label`/`aria-expanded`, a labelled non-modal `role="dialog"` panel, Escape + click-outside to close (focus returns to the bell), a polite live region for the unread count, and `prefers-reduced-motion` support.

## Quick start — React / Next.js

Only `appId` and `appToken` are required — every other prop in [Options](#options) is optional. The component file starts with `"use client"`, so a Server Component (a Next.js App Router layout or page) can render it directly:

```tsx
// app/site-header.tsx — adjust the import path to where the add-on put the file
import NativeNotifyBell from "../native-notify/web/NativeNotifyBell";

// NEXT_PUBLIC_* values are inlined at build time; TypeScript types them string | undefined.
const appId = process.env.NEXT_PUBLIC_NN_APP_ID;
const appToken = process.env.NEXT_PUBLIC_NN_APP_TOKEN;

export function SiteHeader() {
  return (
    <header>
      {/* your header… */}
      {appId && appToken ? (
        <NativeNotifyBell appId={appId} appToken={appToken} title="Notifications" />
      ) : null}
    </header>
  );
}
```

Import the stylesheet once for the whole site — in the App Router's root layout (`app/layout.tsx`), or `pages/_app.tsx` in the Pages Router:

```tsx
import "../native-notify/web/nativeNotifyBell.css";
```

Callback props (`onNavigate`, `onNotificationPress`, `onUnreadChange`) are functions, so pass them from a Client Component — for example to route deep links with the Next.js router instead of a full page load:

```tsx
"use client";

import { useRouter } from "next/navigation";
import NativeNotifyBell from "../native-notify/web/NativeNotifyBell";

export function HeaderBell({ appId, appToken }: { appId: string; appToken: string }) {
  const router = useRouter();
  return (
    <NativeNotifyBell
      appId={appId}
      appToken={appToken}
      theme={{ accent: "#2563eb" }}
      onNavigate={(url: string) => router.push(url)}
    />
  );
}
```

For your own UI instead of the built-in panel, use the headless hook — the same data and actions. It loads nothing on its own: call `refresh()` for the first page (it also sets `unreadCount`):

```tsx
"use client";

import { useEffect } from "react";
import { useNativeNotifyInbox } from "../native-notify/web/NativeNotifyBell";

// The entry fields this list renders (every field is listed under "The inbox API behind it").
type InboxEntry = { entryId: number; title: string; body: string; sentAt: string; read: boolean };

export function InboxList({ appId, appToken }: { appId: string; appToken: string }) {
  const inbox = useNativeNotifyInbox({ appId, appToken, take: 20 });
  const { refresh } = inbox;
  const entries: InboxEntry[] = inbox.entries;

  // Fetch page 1 — this re-runs once the per-browser device id has resolved.
  useEffect(() => {
    refresh();
  }, [refresh]);

  if (inbox.error) return <p role="alert">{inbox.error}</p>;
  return (
    <ul>
      {entries.map((entry) => (
        <li key={entry.entryId}>
          <button type="button" onClick={() => inbox.markRead(entry)}>
            {entry.read ? "" : "• "}
            {entry.title}
          </button>
        </li>
      ))}
    </ul>
  );
}
```

The hook returns `deviceId`, `entries`, `total`, `unreadCount`, `loading`, `error` and the actions `refresh`, `refreshUnread`, `markRead(entry)`, `markAllRead`, `remove(entry)`, `clearInbox` and `loadMore`.

## Quick start — plain JS (any site)

```html
<link rel="stylesheet" href="/native-notify/web/nativeNotifyBell.css" />
<script type="module">
  import { mountNativeNotifyBell } from "/native-notify/web/nativeNotifyBell.js";

  mountNativeNotifyBell({
    appId: window.NN_CONFIG.appId,     // your config — never commit real values
    appToken: window.NN_CONFIG.appToken,
    title: "Notifications",
    position: "bottom-right",          // or mount into your own header
  });
</script>
```

`mount` accepts an element or a selector, so the bell can live in your header instead of floating in a corner:

```js
mountNativeNotifyBell({ appId, appToken, mount: "#header-notifications" });
```

## The inbox API behind it

The widget is a thin client of the universal inbox — one inbox per **device**, keyed by the same stable `deviceId` universal registration used. All endpoints authenticate with your **app id + app token**; the GETs carry them in the URL, the POSTs in the body.

| Call          | Endpoint                                                                                         |
| ------------- | ------------------------------------------------------------------------------------------------ |
| List          | `GET /api/universal/inbox/:appId/:appToken?deviceId=&take=&skip=`                                |
| Unread count  | `GET /api/universal/inbox/:appId/:appToken/unread-count?deviceId=`                               |
| Mark one read | `POST /api/universal/inbox/read` — `{ appId, appToken, deviceId, entryId }`                      |
| Mark all read | `POST /api/universal/inbox/read-all` — `{ appId, appToken, deviceId }`                           |
| Delete one    | `POST /api/universal/inbox/delete` — `{ appId, appToken, deviceId, entryId }` (soft, idempotent) |
| Clear         | `POST /api/universal/inbox/clear` — `{ appId, appToken, deviceId }` (soft, idempotent)           |

The list answers `200` with the device's own entries, newest first:

```json
{
  "ok": true,
  "deviceId": "device-abc",
  "take": 50,
  "skip": 0,
  "total": 12,
  "unread": 3,
  "entries": [
    {
      "entryId": 4181,
      "appId": 123,
      "deviceId": "device-abc",
      "subscriberId": null,
      "environment": "production",
      "title": "New service times",
      "body": "This Sunday: 9am and 11am.",
      "data": { "url": "/news/service-times" },
      "audienceType": "all",
      "source": "api",
      "sentAt": "2026-09-24T14:03:11.000Z",
      "readAt": null,
      "read": false
    }
  ]
}
```

- `take` defaults to 50 and must be an integer 1..200 (a larger value is a `400`, never a silent cap); `skip` defaults to 0. `total` and `unread` cover the device's whole visible inbox, so "load more" is exact.
- Only the requesting device's own entries are ever returned — the same device key on another app sees nothing.
- Every universal send writes one entry per targeted device, in the background — an entry can appear a moment after the send response.

The other calls answer `200` too:

| Call          | Response                                                                                        |
| ------------- | ----------------------------------------------------------------------------------------------- |
| Unread count  | `{ "ok": true, "deviceId", "unread" }`                                                          |
| Mark one read | `{ "ok": true, "entryId", "read": true, "readAt" }` — marking it again keeps the first `readAt` |
| Mark all read | `{ "ok": true, "deviceId", "marked" }` — how many entries changed                               |
| Delete one    | `{ "ok": true, "entryId", "removed" }` — `removed: false` when it was already deleted           |
| Clear         | `{ "ok": true, "deviceId", "removed" }` — how many entries were cleared                         |

Errors are machine-readable: `{ "error": { "code", "message", "field"? } }` — `400` for `missing_field` / `invalid_field` / `invalid_device_id` / `invalid_entry_id` / `invalid_body`, `404 entry_not_found` (an entry that does not exist for this device, or marking a deleted one read) and `404 app_not_found`.

### Which `deviceId` does a browser use?

A browser registers itself as a **web** device when the site runs the [Web Push](/docs/universal-push/web-push) subscribe snippet — that snippet stores its device id under `nn_web_device_id`, the same key this widget defaults to, so the bell shows exactly the pushes this browser receives. The widget can instead:

- **show the same inbox as the app** — pass the device id your backend knows for the logged-in user. The plain-JS widget can resolve it for you with `getDeviceId`:

  ```js
  mountNativeNotifyBell({
    appId, appToken,
    getDeviceId: async () => (await fetch("/api/me/device-id").then((r) => r.json())).deviceId,
  });
  ```

  The React component and hook take the resolved value as `deviceId` instead (there is no `getDeviceId` prop).

- **keep a per-browser key** (the default) — a stable id persisted in `localStorage` under `nn_web_device_id` (change the key with `storageKey`), useful for previewing the widget before wiring identity.

## Options

The React component takes these as props; the plain-JS `mountNativeNotifyBell` / `createNativeNotifyBell` take the same names in its options object.

| Option                | Default                        | What it does                                                                                                                                            |
| --------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `appId` / `appToken`  | —                              | Required. Read them from your config; never commit real values.                                                                                         |
| `deviceId`            | per-browser key                | Whose inbox to show.                                                                                                                                    |
| `getDeviceId`         | —                              | Plain JS only: `async () => deviceId`, resolved before the first request.                                                                               |
| `storageKey`          | `"nn_web_device_id"`           | `localStorage` key of the per-browser default id.                                                                                                       |
| `apiBase`             | `https://app.nativenotify.com` | Point the widget at your own proxy (see [App token on the client](#app-token-on-the-client)).                                                           |
| `take`                | `20`                           | Rows per page (max 200).                                                                                                                                |
| `title`               | `"Notifications"`              | Panel header.                                                                                                                                           |
| `emptyText`           | `"You are all caught up."`     | Empty state.                                                                                                                                            |
| `showCount`           | `true`                         | Numeric badge; `false` shows a plain dot.                                                                                                               |
| `maxCount`            | `99`                           | Badge cap (`99+`).                                                                                                                                      |
| `allowDelete`         | `true`                         | Show the per-row delete button.                                                                                                                         |
| `pollMs`              | `30000`                        | Unread poll interval; `0` disables polling. The minimum is 10000 — the plain-JS widget raises a smaller value to 10 s, the React hook treats it as off. |
| `theme`               | light/dark defaults            | Color override — see Themes.                                                                                                                            |
| `position`            | `"bottom-right"`               | Floating corner: `bottom-right`, `bottom-left`, `top-right` or `top-left`.                                                                              |
| `mount`               | `<body>`                       | Plain JS only: element or selector to mount into (your header).                                                                                         |
| `showLoadMore`        | `true`                         | Plain JS only: a **Load more** button when more pages exist.                                                                                            |
| `onNotificationPress` | —                              | Called on every row click.                                                                                                                              |
| `onNavigate`          | —                              | `(url, entry)` — handle the deep link yourself instead of navigating.                                                                                   |
| `onUnreadChange`      | —                              | Called with the unread count on every change.                                                                                                           |
| `onError`             | —                              | Plain JS only: called with request errors.                                                                                                              |
| `className`           | —                              | React only: extra class on the root element.                                                                                                            |

## Deep links

Convention: put a `url` inside the notification's `pushData` — the same key the mobile SDKs use (see [Push Data & Taps](/docs/setup/push-data)):

```json
{
  "title": "New service times",
  "message": "This Sunday: 9am and 11am.",
  "pushData": { "url": "/news/service-times" }
}
```

Clicking the row marks it read and then opens the URL — same-origin paths and `http(s)` URLs only; anything else is ignored. Pass `onNavigate` when your app is an SPA and prefers client-side routing.

## Themes

Pass a subset of these keys in `theme`; the widget maps each to the CSS custom property named after it. You can also set the variables in your own stylesheet — the widget ships light defaults with a dark `prefers-color-scheme` set.

| Theme key    | CSS variable      | Used for                           |
| ------------ | ----------------- | ---------------------------------- |
| `accent`     | `--nn-accent`     | Focus rings, links, unread titles. |
| `background` | `--nn-surface`    | The bell button.                   |
| `card`       | `--nn-card`       | The panel.                         |
| `border`     | `--nn-border`     | Borders and separators.            |
| `title`      | `--nn-title`      | Headings and row titles.           |
| `text`       | `--nn-text`       | Body text.                         |
| `mutedText`  | `--nn-muted`      | Timestamps, empty state.           |
| `dot`        | `--nn-dot`        | Unread dot and badge background.   |
| `badgeText`  | `--nn-badge-text` | Badge text.                        |
| `delete`     | `--nn-delete`     | Delete button.                     |
| `radius`     | `--nn-radius`     | Panel corners.                     |

```js
theme: { accent: "#2563eb", dot: "#ef4444", radius: "14px" }
```

## App token on the client

The widget calls the API from the browser, so `appId` and `appToken` are visible in your page — the same situation as shipping them inside a mobile app. The inbox endpoints are device-scoped: a caller can only read, mark and delete **that device's own** entries. If your policy still requires the token to stay server-side, proxy the six calls above through your own API (the widget accepts `apiBase`, and your proxy can strip/replace the credentials).

## Checklist

- [ ] Point `appId` / `appToken` at your configuration (env vars, never committed)
- [ ] Decide the `deviceId`: the app's registered device id, or the per-browser default
- [ ] Import `nativeNotifyBell.css` (or copy its variables into your stylesheet)
- [ ] Mount the bell (React component or `mountNativeNotifyBell`)
- [ ] Send a test notification with `pushData.url` and click the row
- [ ] Run the accessibility pass (keyboard, screen reader, reduced motion)
