> ## Documentation Index
> Fetch the complete documentation index at: https://docs.laportenard.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Frontend plugins

> How to add a POS plugin that reacts to data from an external integration.

A plugin lets an external integration change what the POS *shows* without
threading a new field through `usePOS`, the snapshot builder, the protocol
schemas and four backend mappers. The reservations plugin is the reference
implementation: a booking platform pushes bookings to the hub, and tables on the
floor plan render as reserved.

Plugins are **in-repo TypeScript modules** compiled into the bundle. There is no
dynamic loading — the app is a static export inside a Tauri WebView with a strict
CSP, and evaluating remote code there would need a sandbox, a signing story and a
capability API for very little gain when we control every plugin anyway.

## What a plugin can and cannot do

A plugin can:

* receive **events** pushed by an integration over the hub's plugin channel;
* read **`pluginData`** that an integration stamped onto an order;
* **decorate tables** on the floor plan (border, tint, badge, accessible label);
* **mount components** into three named slots.

A plugin **cannot block an action**. There is no veto extension point. A plugin
can say "this table is booked at 19:30"; it cannot stop a waiter seating it.
Gating needs an override path, a permission and a manager flow, none of which is
designed. A POS that refuses to take an order is a worse failure than one showing
a stale badge.

## The inert rule

<Warning>
  **A plugin with no events must render nothing.** Every slot renderer returns
  `null` when it has no data, and `decorateTables` returns an empty map.
</Warning>

This is what makes the hub's `HUB_ENABLED_PLUGINS` allowlist the *only* on/off
switch — no per-terminal config flag is needed, because a plugin that receives
nothing is invisible. It is pinned by
`src/plugins/__tests__/PluginRuntime.integration.test.tsx`. If a slot ever
renders chrome unconditionally, that claim is false and the switch story breaks.

## Anatomy

```
src/plugins/
  types.ts          # the contract
  registry.ts       # every plugin in this build
  PluginRuntimeProvider.tsx
  PluginSlot.tsx
  useTableDecorations.ts
  reservations/     # a plugin
    schema.ts       # zod — what this plugin accepts
    holdWindow.ts   # pure time logic, no React
    components.tsx  # slot components
    index.tsx       # definition
```

## Writing one

### 1. Declare what you accept

Validation lives in the plugin, not the hub. The hub checks the envelope and the
payload's shape, size and depth — never its meaning. That is deliberate: if the
hub knew each plugin's schema, shipping a plugin would mean upgrading every hub
in the field first.

```ts title="src/plugins/deliveries/schema.ts" theme={null}
export const courierSchema = z.object({
  tableId: z.number().int().positive(),
  courier: z.string().min(1).max(120),
  etaAt: z.string().datetime({ offset: true }),
});
export type Courier = z.infer<typeof courierSchema>;
```

<Tip>
  Require a UTC offset on any timestamp. A naive `2026-08-16T19:30:00` is
  ambiguous, and a booking an hour out is a booking at the wrong time.
</Tip>

### 2. Define the plugin

```tsx title="src/plugins/deliveries/index.tsx" theme={null}
export const deliveriesPlugin: PosPlugin<Courier> = {
  id: "deliveries",            // must equal the hub's plugin id
  name: "Courier pickups",

  parseEvent(payload) {
    const parsed = courierSchema.safeParse(payload);
    return parsed.success ? parsed.data : null;   // null = drop it, silently
  },

  decorateTables(ctx) {
    const out = new Map<number, TableDecoration>();
    for (const c of ctx.events.values()) {
      out.set(c.tableId, {
        pluginId: "deliveries",
        tone: "info",           // intent, not a colour
        badge: "🛵",
        a11yLabel: `courier ${c.courier}`,
        priority: 10,           // highest wins if two plugins claim a table
      });
    }
    return out;
  },

  slots: {
    tableDetail: ({ tableId, ctx }) => <CourierPanel tableId={tableId} ctx={ctx} />,
  },
};
```

Declare hooks with **method syntax**, as above. TypeScript checks method
parameters bivariantly, which is what keeps a concretely-typed
`PosPlugin<Courier>` assignable to the registry's `PosPlugin<unknown>[]`. Written
as arrow-typed properties, every registry entry would need a cast.

### 3. Register it

```ts title="src/plugins/registry.ts" theme={null}
export const PLUGINS: readonly AnyPosPlugin[] = [
  reservationsPlugin,
  deliveriesPlugin,
];
```

Being listed here does **not** turn it on. The hub only accepts events for ids in
its `HUB_ENABLED_PLUGINS` allowlist, so a plugin on a hub that does not know it
simply never receives anything.

### 4. Enable it on the hub

```bash theme={null}
HUB_ENABLED_PLUGINS=reservations,deliveries
```

An id outside the list gets `404` from the whole `/external/v1/plugins` surface —
*before* authentication, so an integration cannot probe which plugins a
restaurant runs.

## Extension points

### Table decorations

`decorateTables` returns a map keyed by the **numeric** Odoo `restaurant.table`
record id.

<Warning>
  `FloorTable.id` is a **string** on the frontend. The canvases look decorations up
  with `parseInt(table.id, 10)`. Keying the map any other way type-checks and
  silently never matches.
</Warning>

A decoration declares a `tone` — `neutral`, `info` or `warning` — not a colour.
`table-shape.tsx` owns what each looks like, so decorations stay consistent with
the rest of the canvas and with dark mode. Decorations render **only on available
tables**: a table with a live check keeps showing that check.

### Slots

| Slot              | Host              | Props     |
| ----------------- | ----------------- | --------- |
| `tableDetail`     | Start-order modal | `tableId` |
| `floorPlanHeader` | Floor-plan header | —         |
| `orderHeader`     | Order screen      | `order`   |

```tsx theme={null}
<PluginSlot id="tableDetail" tableId={42} />
```

Each contribution is wrapped in a `PluginErrorBoundary`, so a throwing plugin
loses its own slot rather than taking the floor plan down mid-service.

### Time-derived state

Do not start a timer. `PluginContext.now` is refreshed on a shared 30-second
tick, so a booking crossing into its window re-renders on its own. Keep the logic
a pure function of `(events, now)` — `reservations/holdWindow.ts` is the model,
and it is testable by passing a number rather than mocking clocks.

## Order data

An integration can stamp namespaced data onto an order:

```json theme={null}
{ "pluginData": { "reservations": { "key": "resy-8891" } } }
```

Read it in an `orderHeader` slot via `order.pluginData?.<yourId>`. It is
**frontend and hub only** — the hub strips it before anything is forwarded to
Odoo, so it never reaches the accounting record. Anything that must survive the
shift belongs in a real order field.

## Testing

| Layer       | What to cover                                                                           |
| ----------- | --------------------------------------------------------------------------------------- |
| Unit        | Pure logic with injected `now`; `parseEvent` rejecting junk without throwing            |
| Integration | The runtime end to end over a fake transport, **including the inert case**              |
| E2E         | `page.routeWebSocket` standing in for the hub — see `e2e/tests/15-reservations.spec.ts` |

<Warning>
  Assert on something **visible**, not only the accessible label. An E2E that
  checked only `aria-label` once passed while the entire visual treatment was
  missing, because a custom table colour set `borderColor` inline and beat the tone
  class.
</Warning>

## Reference

* **Integrators**: the wire contract lives in `docs/external-api.mdx` §7 (the
  plugin channel) and §8 (the reservations payload).
* **Conventions**: the root `CLAUDE.md` "Frontend Plugins" section covers the
  replay caches, the `stripPluginData` sites and the read-only-channel rule.
