Guides

Examples

A complete sample app: block, headless action, modal action, hook, iframe page and a bulk action on warehouse transfers.

View Markdown

Field names follow the Open API.

The "ERP sync" sample app contains:

  1. A block in the order form showing the order's ERP sync status.
  2. A headless "Push to ERP" action.
  3. A "Warranty card" modal action with a form.
  4. A hook that blocks saving orders above 20 million without a phone number.
  5. An "ERP reconciliation" iframe page listing orders.
  6. A bulk action that pushes the selected warehouse transfers to the ERP, showing that other resources work the same way.

The ERP in this example is https://erp.example.com, managed by the shop, with CORS enabled for the app's sandbox domain.

Manifest

JSON
{
  "handle": "erp-sync",
  "name": "ERP sync",
  "version": "1.0.0",
  "api_version": "2026-01",
  "access": { "methods": ["GET", "PUT"] },
  "network": { "allowed_origins": ["https://erp.example.com"] },
  "extensions": [
    { "target": "nav.item", "id": "nav", "label": "ERP reconciliation", "page": "reconcile" },
    { "target": "app.page", "id": "reconcile", "title": "ERP reconciliation", "render": "iframe", "entry": "page/index.html" },
    {
      "target": "order.details.block",
      "id": "erp-status",
      "module": "extensions/erp-status.tsx",
      "conditions": { "mode": ["edit"] }
    },
    {
      "target": "order.details.action",
      "id": "push-erp",
      "label": "Push to ERP",
      "presentation": "headless",
      "module": "extensions/push-erp.ts",
      "conditions": { "mode": ["edit"] }
    },
    {
      "target": "order.details.action",
      "id": "warranty",
      "label": "Warranty card",
      "presentation": "modal",
      "module": "extensions/warranty.tsx",
      "conditions": { "mode": ["edit"] }
    },
    {
      "target": "order.before_submit",
      "id": "require-phone",
      "module": "extensions/require-phone.ts",
      "timeout_ms": 1000,
      "on_error": "allow"
    },
    {
      "target": "warehouse_transfer.index.selection-action",
      "id": "push-transfers",
      "label": "Push to ERP",
      "presentation": "headless",
      "module": "extensions/push-transfers.ts"
    }
  ]
}

1. ERP status block

extensions/erp-status.tsx

TSX
import { render } from 'preact';
import { useEffect, useState } from 'preact/hooks';

type Status = { synced: boolean; erp_code?: string; synced_at?: string };

function ErpStatus({ systemId }: { systemId: number }) {
  const [status, setStatus] = useState<Status | null>(null);
  const [error, setError] = useState<string | null>(null);

  const load = async () => {
    setError(null);
    try {
      const res = await fetch(`https://erp.example.com/api/orders/${systemId}`);
      if (!res.ok) throw new Error(`ERP returned ${res.status}`);
      setStatus(await res.json());
    } catch (e) {
      setError(e.message);
    }
  };

  useEffect(() => { load(); }, [systemId]);

  return (
    <pos-section heading="ERP">
      <pos-button slot="actions" variant="tertiary" onClick={load}>Reload</pos-button>
      {error && <pos-banner tone="critical">{error}</pos-banner>}
      {!error && !status && <pos-spinner size="small" />}
      {status && (
        <pos-stack direction="horizontal" gap="small" align="center">
          <pos-badge tone={status.synced ? 'success' : 'warning'}>
            {status.synced ? 'Synced' : 'Not synced'}
          </pos-badge>
          {status.erp_code && <pos-text tone="subdued">ERP code: {status.erp_code}</pos-text>}
        </pos-stack>
      )}
    </pos-section>
  );
}

export default async function (data) {
  render(<ErpStatus systemId={data.record.system_id} />, document.body);
}

2. Headless action "Push to ERP"

extensions/push-erp.ts

TypeScript
export default async function (data) {
  const shopId = pos.shop.id;
  const systemId = data.record.system_id;

  const { data: order } = await pos.api.get(`/shops/${shopId}/orders/${systemId}`);

  const res = await fetch('https://erp.example.com/api/orders', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      pos_order_id: systemId,
      customer_name: order.bill_full_name,
      total: order.total_price,
      items: order.items,
    }),
  });
  if (!res.ok) throw new Error(`ERP rejected the order (${res.status})`);
  const { erp_code } = await res.json();

  // Store the ERP code in the order note; see the Open API docs for updatable fields.
  const note = [order.note, `ERP: ${erp_code}`].filter(Boolean).join('\n');
  await pos.api.put(`/shops/${shopId}/orders/${systemId}`, { note });

  return { message: `Pushed to ERP: ${erp_code}`, refresh: true };
}

Thrown errors (throw) are shown by POS as error toasts.

3. Modal action "Warranty card"

extensions/warranty.tsx

TSX
import { render } from 'preact';
import { useState } from 'preact/hooks';

function WarrantyForm({ systemId }: { systemId: number }) {
  const [serial, setSerial] = useState('');
  const [expiresOn, setExpiresOn] = useState<string | null>(null);
  const [saving, setSaving] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const submit = async () => {
    if (!serial) return setError('Enter the serial number');
    setSaving(true);
    setError(null);
    try {
      const res = await fetch('https://erp.example.com/api/warranties', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ pos_order_id: systemId, serial, expires_on: expiresOn }),
      });
      if (!res.ok) throw new Error(`ERP returned ${res.status}`);
      pos.toast.show('Warranty card created');
      pos.close();
    } catch (e) {
      setError(e.message);
    } finally {
      setSaving(false);
    }
  };

  return (
    <pos-stack>
      {error && <pos-banner tone="critical">{error}</pos-banner>}
      <pos-text-field label="Serial number" required value={serial} onChange={(e) => setSerial(e.detail)} />
      <pos-date-picker label="Warranty expires on" value={expiresOn} onChange={(e) => setExpiresOn(e.detail)} />
      <pos-stack direction="horizontal" justify="end" gap="small">
        <pos-button onClick={() => pos.close()}>Cancel</pos-button>
        <pos-button variant="primary" loading={saving} onClick={submit}>Create card</pos-button>
      </pos-stack>
    </pos-stack>
  );
}

export default async function (data) {
  render(<WarrantyForm systemId={data.record.system_id} />, document.body);
}

4. Hook that blocks saving an order

extensions/require-phone.ts

TypeScript
const LIMIT = 20_000_000;

export default async function ({ draft }) {
  if (draft.total_price > LIMIT && !draft.bill_phone_number) {
    return { ok: false, message: 'Orders above 20 million need the recipient phone number.' };
  }
  return { ok: true };
}

If the shop hides bill_phone_number through access.hidden_order_fields, the field is always undefined and the hook will block every large order. Check access when writing hooks.

5. "ERP reconciliation" iframe page

page/src/main.tsx (React + Vite)

TSX
import { createRoot } from 'react-dom/client';
import { useEffect, useState } from 'react';
import { connect } from '@pancake-pos/ui-extensions/page';

const pos = await connect();

function Reconcile() {
  const [page, setPage] = useState(1);
  const [orders, setOrders] = useState([]);
  const [total, setTotal] = useState(0);

  useEffect(() => {
    const controller = new AbortController();
    pos.api
      .get(`/shops/${pos.shop.id}/orders`, {
        query: { page_number: page, page_size: 20 },
        signal: controller.signal,
      })
      .then((res) => {
        setOrders(res.data);
        setTotal(res.total_entries);
      })
      .catch((e) => e.name !== 'AbortError' && pos.toast.show(e.message, { tone: 'error' }));
    return () => controller.abort();
  }, [page]);

  useEffect(() => {
    pos.page.setTitle('ERP reconciliation');
    pos.page.setPrimaryAction({ label: 'Sync again' });
    return pos.on('primary-action', () => pos.toast.show('Syncing...', { tone: 'info' }));
  }, []);

  return (
    <table>
      <thead>
        <tr><th>Order</th><th>Customer</th><th>Total</th></tr>
      </thead>
      <tbody>
        {orders.map((o) => (
          <tr key={o.system_id}>
            <td>{o.system_id}</td>
            <td>{o.bill_full_name}</td>
            <td>{o.total_price}</td>
          </tr>
        ))}
      </tbody>
      <tfoot>
        <tr>
          <td colSpan={3}>
            <button disabled={page === 1} onClick={() => setPage(page - 1)}>Previous</button>
            <span> Page {page} / {Math.max(1, Math.ceil(total / 20))} </span>
            <button disabled={page * 20 >= total} onClick={() => setPage(page + 1)}>Next</button>
          </td>
        </tr>
      </tfoot>
    </table>
  );
}

createRoot(document.getElementById('root')!).render(<Reconcile />);

Iframe pages use free-form HTML/React; you can replace the table above with @pancake-pos/ui components to match the POS look.

6. Bulk action on warehouse transfers

extensions/push-transfers.ts

TypeScript
// warehouse_transfer.index.selection-action: records is WarehouseTransferRef[]
export default async function ({ records }) {
  const res = await fetch('https://erp.example.com/api/stock-transfers', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(
      records.map((t) => ({
        pos_transfer_id: t.id,
        code: t.custom_id || String(t.display_id),
        from_warehouse_id: t.from_warehouse_id,
        to_warehouse_id: t.to_warehouse_id,
        status: t.status,
      }))
    ),
  });
  if (!res.ok) throw new Error(`ERP rejected the transfers (${res.status})`);
  return { message: `Pushed ${records.length} transfers to the ERP`, refresh: false };
}

The module is the same as for orders; only the resource changes: records holds WarehouseTransferRef, whose id is used with the Open API (/shops/{shop_id}/transfers/{id}). Read line items through the Open API when the ERP needs them.