Á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):
poslà 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ậnpos(xem Trang iframe).
Type đầy đủ có trong gói @pancake-pos/ui-extensions (xem SDK & CLI).
Tổng quan
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.
- Các target gắn với bản ghi POS dùng chung một dạng:
pos.data.resourcecho biết loại bản ghi (order,purchase…),pos.data.recordlà bản ghi đã lưu (recordsở thanh thao tác hàng loạt),pos.data.draftlà nội dung form chi tiết. - Trường bị ẩn bởi
access.hidden_order_fieldskhông có trongpos.data. - Với block,
pos.datađược cập nhật khi người dùng sửa form; nghe sự kiệndata-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.
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>;
}pathphả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.methodscủa app; không thì ném lỗi trước khi gửi request.
Lỗi
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.
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.
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. Token hết hạn sau 15 phút; gọi lại getToken() để lấy token mới.
pos.toast.show
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.
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.
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
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.
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:
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,replaceStatevàpopstatetrong 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ữ:
localStoragecủ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/.
{ "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-extensionsvà gọiconnect()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
urllà gốc của app./shop/<id>/apps/<handle>/orders/5trong POS tương ứnghttps://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.pathlà đườ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
urlkhi 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/posthayurlbằ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.bodylà 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 của trang quản lý ứng dụng tuỳ biến.
