# Runtime API (pos)

> Áp dụng cho `api_version: "2026-01"`.

Mọi extension nhận một đối tượng `pos` để đọc ngữ cảnh, gọi Open API và ra lệnh cho giao diện POS.

- **Extension trong worker** (block, action, hook, trang Worker UI): `pos` là biến toàn cục do runtime POS nạp sẵn trước khi module chạy.
- **Trang iframe**: gọi `connect()` từ SDK để nhận `pos` (xem [Trang iframe](#trang-iframe)).

Type đầy đủ có trong gói `@pancake-pos/ui-extensions` (xem [SDK & CLI](/developers/apps/sdk)).

## Tổng quan

```ts
interface Pos {
  apiVersion: string;                 // ví dụ "2026-01"
  target: string;                     // target của extension đang chạy
  extensionId: string;                // id khai trong manifest
  app: { id: string; handle: string; version: string };
  shop: { id: number; name: string; currency: string; country: string; timezone: string };
  user: { id: string; name: string; locale: string };
  data: unknown;                      // dữ liệu ngữ cảnh, schema theo target

  api: PosApi;                        // gọi Open API
  session: { getToken(): Promise<{ token: string; expiresAt: string }> };

  toast: { show(message: string, options?: ToastOptions): void };
  ui: { confirm(options: ConfirmOptions): Promise<boolean> };
  navigate(to: NavigateTarget): void;
  refresh(): void;
  close(): void;

  on(event: PosEvent, handler: (payload: any) => void): () => void;
}
```

### Phạm vi khả dụng

| API | Block | Action headless | Action modal/fullscreen | Hook, should_render | Trang |
|---|---|---|---|---|---|
| `shop`, `user`, `app`, `data` | ✓ | ✓ | ✓ | ✓ | ✓ |
| `api`, `session` | ✓ | ✓ | ✓ | ✓ | ✓ |
| `toast`, `ui.confirm` | ✓ | ✓ | ✓ | | ✓ |
| `navigate` | ✓ | ✓ | ✓ | | ✓ |
| `refresh` | ✓ | ✓ | ✓ | | |
| `close` | | | ✓ | | |
| `on('data-change')` | ✓ | | ✓ | | ✓ |
| `page.*` | | | | | ✓ (iframe) |

Gọi API ngoài phạm vi sẽ ném lỗi `PosRuntimeError` với `code: 'unavailable'`.

## pos.data

Dữ liệu ngữ cảnh của vị trí đang chạy, cũng là tham số của hàm `export default`. Schema từng target xem tại [Extension point](/developers/apps/targets).

- Các target gắn với bản ghi POS dùng chung một dạng: `pos.data.resource` cho biết loại bản ghi (`order`, `purchase`…), `pos.data.record` là bản ghi đã lưu (`records` ở thanh thao tác hàng loạt), `pos.data.draft` là nội dung form chi tiết.
- Trường bị ẩn bởi `access.hidden_order_fields` không có trong `pos.data`.
- Với block, `pos.data` được cập nhật khi người dùng sửa form; nghe sự kiện `data-change` để render lại.

## pos.api

Gọi Open API bằng app token. SDK tự gắn token, tự gia hạn và thử lại một lần khi token hết hạn.

```ts
interface PosApi {
  baseUrl: string;   // gốc Open API, ví dụ "https://pos.pages.fm/api/v1"
  request<T = any>(options: {
    method: 'GET' | 'POST' | 'PUT' | 'DELETE';
    path: string;                          // tương đối với baseUrl, bắt đầu bằng /shops/{shop_id}
    query?: Record<string, unknown>;
    body?: unknown;                        // gửi dạng JSON
    signal?: AbortSignal;
  }): Promise<T>;
  get<T = any>(path: string, options?: { query?: Record<string, unknown>; signal?: AbortSignal }): Promise<T>;
  post<T = any>(path: string, body?: unknown, options?: { query?: Record<string, unknown>; signal?: AbortSignal }): Promise<T>;
  put<T = any>(path: string, body?: unknown, options?: { query?: Record<string, unknown>; signal?: AbortSignal }): Promise<T>;
  delete<T = any>(path: string, options?: { query?: Record<string, unknown>; signal?: AbortSignal }): Promise<T>;
}
```

- `path` phải thuộc shop hiện tại (`/shops/${pos.shop.id}/...`). Gọi shop khác bị từ chối.
- Kết quả là JSON nguyên bản của Open API (thường có dạng `{ success, data, ... }`).
- Method phải nằm trong `access.methods` của app; không thì ném lỗi trước khi gửi request.

### Lỗi

```ts
class PosApiError extends Error {
  status: number;            // HTTP status
  code?: number | string;    // error_code của Open API nếu có
  body?: unknown;            // body trả về
}
```

SDK ném `PosApiError` khi HTTP status ≥ 400 **hoặc** body có `success: false`. Danh sách mã lỗi xem [Truy cập dữ liệu › Mã lỗi](/developers/apps/data-access#ma-loi).

```ts
try {
  const { record } = pos.data;   // order.details.action: record là OrderRef
  const res = await pos.api.get(`/shops/${pos.shop.id}/orders/${record.system_id}`);
  render(<OrderInfo order={res.data} />, document.body);
} catch (error) {
  if (error.status === 403) pos.toast.show('Bạn không có quyền xem đơn này', { tone: 'error' });
  else throw error;
}
```

## pos.session.getToken

Lấy app token hiện tại để tự gọi Open API bằng thư viện khác.

```ts
const { token, expiresAt } = await pos.session.getToken();
await fetch(`${pos.api.baseUrl}/shops/${pos.shop.id}/orders`, {
  headers: { Authorization: `Bearer ${token}` },
});
```

Không lưu token lâu dài, không gửi token cho hệ thống bên ngoài, trừ backend của [trang tự host](#trang-tu-host). Token hết hạn sau 15 phút; gọi lại `getToken()` để lấy token mới.

## pos.toast.show

```ts
type ToastOptions = {
  tone?: 'success' | 'error' | 'info' | 'warning';   // mặc định 'success'
  duration?: number;                                  // giây, mặc định 3, từ 1 đến 10
};
```

Tối đa 3 toast mỗi 10 giây cho mỗi extension; vượt thì bị bỏ qua.

## pos.ui.confirm

Hộp xác nhận do POS vẽ, phủ toàn màn hình.

```ts
type ConfirmOptions = {
  title: string;
  content?: string;
  okText?: string;       // mặc định "Đồng ý"
  cancelText?: string;   // mặc định "Huỷ"
  danger?: boolean;      // nút đồng ý màu đỏ
};

if (await pos.ui.confirm({ title: 'Đẩy đơn sang ERP?' })) {
  // ...
}
```

## pos.navigate

Chuyển tới một trang của app.

```ts
type NavigateTarget = {
  page: string;                       // id extension app.page
  path?: string;                      // ví dụ "/returns"
  query?: Record<string, string>;
};

pos.navigate({ page: 'home', path: '/returns', query: { order_id: String(pos.data.record.system_id) } });
```

Nếu đang ở modal, modal đóng trước khi chuyển trang. Nếu đang ở đúng trang đó, POS chỉ đổi URL và phát `data-change` với `path`, `query` mới, không tải lại trang.

## pos.refresh

Yêu cầu POS tải lại dữ liệu của màn hình hiện tại (ví dụ đơn hay phiếu nhập đang mở) sau khi app đã cập nhật qua Open API.

- POS tải lại danh sách đang xem và bản ghi đang mở. Thay đổi chưa lưu trên form của bản ghi đó bị thay bằng dữ liệu mới.
- Nhiều lần gọi liên tiếp được gộp lại: mỗi loại bản ghi tải lại tối đa 1 lần mỗi 3 giây.

## pos.close

Đóng modal/fullscreen của action đang mở.

## Sự kiện

```ts
const off = pos.on('data-change', (data) => {
  // data có schema giống pos.data
});
off(); // huỷ đăng ký
```

| Sự kiện | Phát khi | Payload |
|---|---|---|
| `data-change` | Dữ liệu ngữ cảnh thay đổi, ví dụ người dùng sửa form chi tiết (gom trong 300 ms) hoặc trang đổi đường dẫn | `pos.data` mới |
| `primary-action` | Người dùng bấm nút chính của trang iframe | không có |

## Trang iframe

Trang `render: "iframe"` là một ứng dụng web bình thường, được phục vụ tại gốc domain sandbox của app. Mọi đường dẫn không phải file tĩnh đều trả về `index.html`, nên router dùng `basename` là `/`. Đường dẫn bắt đầu bằng `/_pos/` dành riêng cho POS: không dùng làm route hay đường dẫn file.

```ts
import { connect } from '@pancake-pos/ui-extensions/page';

const pos = await connect();   // chờ POS gửi ngữ cảnh và token
// pos.data: { path, query }
```

Ngoài các API ở trên, trang iframe có thêm:

```ts
interface PosPage {
  setTitle(title: string): void;
  setPrimaryAction(action: { label: string; disabled?: boolean; loading?: boolean } | null): void;
}

pos.page.setTitle('Đối soát ngày 12/05');
pos.page.setPrimaryAction({ label: 'Xuất Excel' });
pos.on('primary-action', exportExcel);
```

- **Đồng bộ URL:** SDK theo dõi `history.pushState`, `replaceState` và `popstate` trong iframe rồi cập nhật URL của POS. Khi mở link có sẵn, iframe được nạp đúng đường dẫn tương ứng.
- **Chiều cao:** iframe chiếm toàn bộ vùng nội dung của POS; trang tự cuộn bên trong.
- **Giao diện:** khuyến nghị dùng `@pancake-pos/ui` (theme và component theo style POS).
- **Lưu trữ:** `localStorage` của trang iframe thuộc riêng domain sandbox của app, không dùng chung với POS hay app khác.

### Trang tự host

Khai `url` thay cho `entry` khi trang đã chạy trên máy chủ của bạn, ví dụ web ERP sẵn có. POS nhúng thẳng `url` vào iframe; gói không cần thư mục `page/`.

```json
{ "target": "app.page", "id": "home", "title": "ERP", "render": "iframe", "url": "https://erp.example.com/pos" }
```

- **Kết nối:** trang cài `@pancake-pos/ui-extensions` và gọi `connect()` như trang trong gói; `pos.page`, `pos.api`, `pos.toast`… dùng như nhau.
- **Đường dẫn:** phần đường dẫn của `url` là gốc của app. `/shop/<id>/apps/<handle>/orders/5` trong POS tương ứng `https://erp.example.com/pos/orders/5`. Trang đi ra ngoài gốc này (ví dụ `/login`) thì URL của POS giữ nguyên. `pos.data.path` là đường dẫn thật của trang.
- **Cho phép POS nhúng:** đặt header `Content-Security-Policy: frame-ancestors https://pos.pancake.vn https://pos.pages.fm` để chỉ POS nhúng được trang. Không dựa vào cookie đăng nhập của trang: trình duyệt có thể chặn cookie trong iframe khác domain (Safari chặn mặc định).
- **Gọi API từ máy chủ của bạn:** gửi app token (`pos.session.getToken()`) kèm request về backend, backend gọi Open API bằng token đó (`Authorization: Bearer`). Token hạn 15 phút và chỉ có quyền của app; không lưu token lâu dài.
- **Duyệt và thay đổi:** quản trị viên thấy `url` khi kích hoạt. Nội dung trang do máy chủ của bạn quyết định và đổi được bất cứ lúc nào mà không qua duyệt version, nên chỉ dùng máy chủ bạn kiểm soát.
- **Chạy thử:** `pos-app dev --page-url http://localhost:3000/pos` thay `url` bằng máy chủ trên máy bạn; hot reload của Vite hay Next vẫn chạy trong iframe.

## Môi trường worker

Extension trong worker chỉ được bảo đảm có:

- Cú pháp và đối tượng chuẩn của JavaScript (ES2020).
- `fetch`, `Headers`, `Request`, `Response`, `AbortController`, `URL`, `URLSearchParams`.
- `setTimeout`, `clearTimeout`, `setInterval`, `clearInterval`.
- `console` (chỉ xem được ở chế độ phát triển).
- DOM tối giản do runtime POS cung cấp để Preact hoặc JS thuần dựng element `pos-*`: `document.createElement`, `document.createTextNode`, `append`, `appendChild`, `insertBefore`, `removeChild`, `remove`, `setAttribute`, `removeAttribute`, `textContent`, `addEventListener`, `removeEventListener`, `parentNode`, `childNodes`, `firstChild`, `nextSibling`, `CustomEvent`. `document.body` là gốc giao diện của extension.

**Không** dùng: `window`, `document.querySelector`, `localStorage`, `sessionStorage`, `canvas`, đo kích thước DOM (`getBoundingClientRect`…), `alert`/`confirm`/`prompt`, nạp script từ CDN. Các API này có thể không tồn tại hoặc bị khoá bất kỳ lúc nào.

## Nhật ký lỗi

- Chế độ phát triển: lỗi và `console.*` hiện trong DevTools của trình duyệt.
- Version đang kích hoạt: lỗi chưa bắt (uncaught), lỗi render, timeout được ghi vào nhật ký lỗi của app, xem trong [Nhật ký lỗi](/developers/apps/manage-apps#nhat-ky-loi) của trang quản lý ứng dụng tuỳ biến.
