Frontend Patterns
Tất cả ứng dụng frontend BANA là SPA React 19 được xây dựng với Vite 7 và TailwindCSS 4. Trang này trình bày các pattern dùng chung được áp dụng cho mọi app.
Stack Dùng chung
| Tầng | Công nghệ | Phiên bản |
|---|---|---|
| Runtime | Bun | >= 1.3.8 |
| UI Framework | React | 19 |
| Build | Vite | 7 |
| Styling | TailwindCSS | 4 |
| Ngôn ngữ | TypeScript | ~5.9 |
| Icon | lucide-react | mới nhất |
| UI Primitive | Radix UI | 15+ primitive |
| Thư viện Component | shadcn/ui (qua admin-ui-kit) | — |
Đồ thị Phụ thuộc App
| App | Framework | Cổng | Mục đích |
|---|---|---|---|
@nx-app/client | React + Vite + shadcn/ui | 5173 | Admin dashboard — 28 module màn hình |
@nx-app/bo | React + Vite + shadcn/ui | 5174 | Back office — quản lý merchant |
@nx-app/sale-renderer | React + Vite + Tauri WebView | — | UI POS desktop (barcode, lưới, QR) |
@nx-app/sale-main | Rust + Tauri 2 | — | Backend POS desktop (SQLite, USB, payment) |
@nx-app/overture | Astro | 4321 | Trang marketing / landing |
@nx-app/admin-ui-kit | React + Radix UI | — | Thư viện component dùng chung (38 shadcn + custom) |
@nx-app/core | TypeScript | — | Hằng số, hook, i18n, tiện ích dùng chung |
@nx-app/admin-ui-kit
Thư viện component UI dùng chung được sử dụng bởi client và bo. Xây dựng trên Radix UI + pattern shadcn/ui.
Cấu trúc
apps/admin-ui-kit/
├── components/
│ ├── shadcn/ # 38 shadcn components
│ ├── core/ # Custom core components
│ └── icons/ # Icon components
├── hooks/ # Shared hooks
├── utilities/ # Shared utilities
└── gen:index # Auto-generates barrel exportsPhụ thuộc Chính
| Package | Mục đích |
|---|---|
@radix-ui/* | 15+ headless UI primitive |
lucide-react | Bộ icon |
sonner | Thông báo toast |
next-themes | Chuyển đổi theme |
tailwind-merge | Gộp class TailwindCSS |
class-variance-authority | Styling component theo variant |
Chạy bun run gen:index sau khi thêm component mới để tạo lại các file barrel export.
@nx-app/core
Module dùng chung được sử dụng bởi client, bo, và sale-renderer.
Export
| Export | Mô tả |
|---|---|
constants | Endpoint API, các giá trị cấu hình |
hooks | React hook dùng chung |
locales | File dịch i18n (riêng theo từng locale) |
utilities | Hàm tiện ích dùng chung |
Peer Dependency
ra-core, @minimaltech/ra-core-infra, React 19.
Pattern i18n
File dịch được tổ chức thành các file riêng theo từng locale không có lớp trừu tượng:
apps/core/src/locales/
├── en/
│ ├── common.ts
│ ├── products.ts
│ └── ...
└── vi/
├── common.ts
├── products.ts
└── ...i18n backend dùng object JSON { en: string, vi: string } lưu trực tiếp trong các trường database (ví dụ name: { en: 'Products', vi: 'Sản phẩm' }).
Module Màn hình của Client
@nx-app/client chứa 28 module màn hình trong src/screens/:
| Module | Lĩnh vực |
|---|---|
| categories, category-templates | Danh mục sản phẩm |
| products, merchants, merchant-types | Commerce |
| organizers | Tổ chức |
| customers | CRM |
| devices, terminals | Phần cứng |
| sale-orders, sale-channels | Bán hàng |
| purchase-orders, vendors | Mua hàng |
| inventory, inventory-tracking, importation | Tồn kho |
| finance (wallets, transactions, categories) | Tài chính |
| invoice | Hóa đơn |
| reports (orders, revenue) | Báo cáo |
| roles, users, user-employees | Identity |
| settings, sign-in, sign-up, transactions | Hệ thống |
POS Desktop (Tauri)
Ứng dụng POS là một ứng dụng Tauri hai tiến trình:
| Tiến trình | Package | Công nghệ |
|---|---|---|
| Renderer (UI) | @nx-app/sale-renderer | React + Vite + TailwindCSS |
| Main (backend) | @nx-app/sale-main | Rust + SQLite + Sea-ORM |
sale-renderer extras
| Package | Mục đích |
|---|---|
socket.io-client | Kết nối WebSocket đến dịch vụ Signal |
@nichetech/barcode-scanner | Quét barcode/QR |
react-grid-layout | Layout tile có thể kéo thả |
pdfjs-dist | Render PDF |
| QR code generation | Hiển thị QR giao dịch |
Plugin Tauri của sale-main
| Plugin | Mục đích |
|---|---|
tauri-plugin-usb | Giao tiếp thiết bị USB |
tauri-plugin-payment | Tích hợp terminal thanh toán |
tauri-plugin-external-display | Màn hình hiển thị cho khách hàng |
tauri-plugin-machine-uid | Vân tay thiết bị cho licensing |
Lệnh
cd apps/sale-main
bunx tauri dev # Khởi động Tauri ở chế độ development
bun run gene-model # Tạo entity Sea-ORM từ SQLite
bun run migrate # Chạy migration SQLitePhát triển
make dev-client # http://localhost:5173
make dev-bo # http://localhost:5174
make dev-overture # http://localhost:4321
# POS desktop
cd apps/sale-main && bunx tauri devWebSocket — Đăng ký Dữ liệu Thời gian Thực
apps/client và apps/bo nhận sự kiện trực tiếp từ server thông qua WebSocket mã hóa kết nối đến backend service @nx/signal. Stack frontend gồm bốn lớp, mỗi lớp có một trách nhiệm duy nhất.
Lớp transport mã hóa (
EncryptedWebSocketClient) được tài liệu hóa trong Signal — Hướng dẫn Client. Phần này đề cập mọi thứ bên trên nó — hệ thống đăng ký room, pattern xử lý message, và cách các screen kết nối vào.
Kiến trúc
Đăng ký Service
Cả hai app đều đăng ký hai singleton socket trong bindingPreList() trước mọi service khác:
// apps/client/src/application/application.ts (apps/bo tương tự)
bindingPreList() {
return {
'services.SocketConnectionManager': SocketConnectionManager,
'services.SocketSubscriptionManager': SocketSubscriptionManager,
};
}SocketConnectionManager là app-specific (nằm trong apps/client/src/socket/ và apps/bo/src/socket/). Nó khởi tạo SignalSocket cụ thể và SignalSocketMessageHandler của app khi người dùng đăng nhập.
SocketSubscriptionManager được chia sẻ từ apps/core. Đây là facade mỏng gọi signalSocket()?.subscribe() / unsubscribe().
Vòng đời kết nối:
| Sự kiện | Điều xảy ra |
|---|---|
| Đăng nhập | AuthProvider gọi socketConnectionManager.setUserToken(token) → SignalSocket được tạo và kết nối |
| Đăng xuất | socketConnectionManager.clearUserToken() → socket bị hủy, trạng thái reset về DISCONNECTED |
| App unmount | destroy() được gọi trên singleton SocketConnectionManager |
Biến môi trường bắt buộc trong .env.development (hoặc .env.local):
VITE_WEB_SOCKET_SIGNAL_URL=ws://localhost:3500/stream
VITE_WEB_SOCKET_SIGNAL_ECDH_INFO=nx-signal-e2eĐịnh tuyến Message
AbstractSignalSocketMessageHandler (apps/core/src/socket/message-handler/) là abstract class với hai lớp:
- Phương thức định tuyến public (
onXxxTopic) — được gọi bởiAbstractSignalSocket.subscribe()cho mỗi sự kiện đến. Mỗi phương thức định tuyến switch theo chuỗi event và gọi một protected stub. - Protected stub (
onXxxTopicYyyEvent) — mặc định là no-op. Subclass theo từng app chỉ override những stub cần thiết.
AbstractSignalSocket.subscribe()
└─ socket.on(event, handler)
└─ _messageHandlers.onLedgerProcessTopic({ event, data })
└─ AbstractSignalSocketMessageHandler.onLedgerProcessTopic()
└─ switch(event):
case LEDGER_JOB_STATUS →
this.onLedgerProcessTopicLedgerJobStatus({ data })
↑
└─ SignalSocketMessageHandler (apps/client) override
queryClient.invalidateQueries(...)apps/client — src/socket/signal-socket-message-handler.ts — override:
| Stub | Hành động |
|---|---|
onMerchantSaleOrderTopicSaleSaleOrder | Cập nhật Redux saleOrder slice hoặc invalidate list query |
onSaleOrderTopicSaleSaleOrder | Invalidate SaleOrderApi.findById |
onMerchantTransactionsTopicPaymentTransaction | Cập nhật Redux transaction slice hoặc invalidate list query |
onTransactionTopicPaymentTransaction | Invalidate TransactionApi.findById |
onTransactionPaymentAttemptTopicPaymentAttempt | Invalidate TransactionApi.findById |
onLedgerProcessTopicLedgerJobStatus | Invalidate LedgerBatchApi.statusBatch |
apps/bo — src/socket/signal-socket-message-handler.ts — override:
| Stub | Hành động |
|---|---|
onOutreachInquiry | Invalidate OutreachInquiryApi.find + .count, kích hoạt InquiryListObservation |
Subscription Manager Đếm Tham chiếu
BaseSocketSubscriptionManager (trong apps/core) giữ một Map<string, number> với key là "TOPIC|id" (ví dụ: "LEDGER_PROCESS|merchant-uuid"). Điều này cho phép nhiều component đăng ký cùng topic/id mà không join WebSocket room hai lần.
| Phương thức | Hành vi |
|---|---|
subscribe({ key }) | Tăng counter. Nếu key mới, đặt thành 1. |
unsubscribe({ key }) | Giảm counter. Khi về 0, xóa key. |
counter({ key }) | Trả về số đếm hiện tại, hoặc undefined nếu chưa đăng ký. |
AbstractSignalSocket.unsubscribe() chỉ gọi leaveRooms() khi counter({ key }) về zero. _handleClearListener() gọi socket.offAll() cho một event chỉ khi không còn subscription nào cho topic type đó.
Pattern Đăng ký trong Screen
Screen không bao giờ tương tác trực tiếp với SocketConnectionManager hoặc SignalSocket. Chúng gọi SocketSubscriptionManager qua useInjectable:
// Pattern dùng trong screen shell của apps/client và apps/bo
const socketSubscriptionManager = useInjectable<ISocketSubscriptionManager>({
key: 'services.SocketSubscriptionManager',
});
const merchantId = useAppSelector((state) => state.workspace.merchantId);
React.useEffect(() => {
if (!merchantId) {
return;
}
socketSubscriptionManager.subscribeSignal({
topic: SocketClientTopic.LEDGER_PROCESS,
data: { merchantId },
});
return () => {
socketSubscriptionManager.unsubscribeSignal({
topic: SocketClientTopic.LEDGER_PROCESS,
data: { merchantId },
});
};
}, [merchantId, socketSubscriptionManager]);Quy tắc:
- Subscribe/unsubscribe thuộc về screen shell (
.screen.tsx), không phải table hay child component. - Luôn kiểm tra ID bắt buộc (
if (!merchantId) return). - Hàm cleanup trong
useEffectlà lời gọi unsubscribe — React chạy nó cả khi dependency thay đổi lẫn khi unmount. - Các component dữ liệu (ví dụ
LedgerTable) tự động re-fetch khi React Query key của chúng bị invalidate bởi message handler. Chúng không cần biết về WebSocket.
Tham chiếu Tất cả Topic
Được định nghĩa trong apps/core/src/socket/constants.ts.
SocketClientTopic | WebSocket Room | Event(s) | Kiểu payload | Tham số bắt buộc |
|---|---|---|---|---|
MERCHANT | wr:observation/merchants/{merchantId} | SALE_SALE_ORDER, SALE_SALE_ORDER_ITEM, PAYMENT_TRANSACTION, PAYMENT_ATTEMPT, SALE_KITCHEN_TICKET, SALE_KITCHEN_TICKET_ITEM | Nhiều kiểu | merchantId |
MERCHANT_SALE_ORDERS | wr:observation/merchants/{merchantId}/sale-orders | SALE_SALE_ORDER, SALE_SALE_ORDER_ITEM | ISaleOrderSocket | merchantId |
SALE_ORDER | wr:observation/sale-orders/{saleOrderId} | SALE_SALE_ORDER, SALE_SALE_ORDER_ITEM | ISaleOrderSocket | saleOrderId |
SALE_ORDER_SALE_ORDER_ITEMS | wr:observation/sale-orders/{saleOrderId}/sale-order-items | SALE_SALE_ORDER_ITEM | ISaleOrderItemSocket | saleOrderId |
MERCHANT_TRANSACTIONS | wr:observation/merchants/{merchantId}/transactions | PAYMENT_TRANSACTION | ITransaction | merchantId |
TRANSACTION | wr:observation/transactions/{transactionId} | PAYMENT_TRANSACTION | ITransaction | transactionId |
MERCHANT_PAYMENT_ATTEMPTS | wr:observation/merchants/{merchantId}/payment-attempts | PAYMENT_ATTEMPT | IPaymentAttempt | merchantId |
PAYMENT_ATTEMPT | wr:observation/payment-attempt/{attemptId} | PAYMENT_ATTEMPT | IPaymentAttempt | attemptId |
TRANSACTION_PAYMENT_ATTEMPT | wr:observation/transactions/{transactionId}/payment-attempts | PAYMENT_ATTEMPT | IPaymentAttempt | transactionId |
MERCHANT_KITCHEN | wr:observation/merchants/{merchantId}/kitchen | SALE_KITCHEN_TICKET, SALE_KITCHEN_TICKET_ITEM | IKitchenTicketSocket | merchantId |
SALE_ORDER_KITCHEN | wr:observation/sale-orders/{saleOrderId}/kitchen | SALE_KITCHEN_TICKET, SALE_KITCHEN_TICKET_ITEM | IKitchenTicketSocket | saleOrderId |
KITCHEN_STATION | wr:observation/kitchen-stations/{kitchenStationId} | SALE_KITCHEN_TICKET, SALE_KITCHEN_TICKET_ITEM | IKitchenTicketSocket | kitchenStationId |
OUTREACH_INQUIRIES | wr:observation/outreach/inquiries | OUTREACH_INQUIRY | IOutreachInquirySocket | (không có) |
MERCHANT_ALLOCATION_USAGES | wr:observation/merchants/{merchantId}/allocation-usages | ALLOCATION_ALLOCATION_USAGE | IAllocationUsageSocket | merchantId |
ALLOCATION_ZONE_LV0 | wr:observation/allocation-zones/lv0/{allocationZoneId} | ALLOCATION_ALLOCATION_USAGE | IAllocationUsageSocket | allocationZoneId |
ALLOCATION_ZONE_LV1 | wr:observation/allocation-zones/lv1/{allocationZoneParentId} | ALLOCATION_ALLOCATION_USAGE, ALLOCATION_RESERVATION | IAllocationUsageSocket | allocationZoneId |
LEDGER_PROCESS | wr:ledger/{merchantId}/process | LEDGER_JOB_STATUS | ILedgerJobStatusSocket | merchantId |
Cách Thêm Topic Mới
Thêm một đăng ký real-time mới yêu cầu thay đổi 6 vị trí trong apps/core và app tiêu thụ. Thực hiện theo thứ tự — các bước trước định nghĩa kiểu mà các bước sau phụ thuộc vào.
Bước 1 — Thêm interface payload (apps/core/src/interfaces/)
Tạo hoặc mở rộng file interface liên quan với kiểu payload socket:
export interface IMyDomainSocket {
id: string;
merchantId: string;
status: string;
// ... các trường backend gửi
}Kiểm tra nó được export từ apps/core/src/index.ts.
Bước 2 — Thêm hằng số SocketClientTopic (apps/core/src/socket/constants.ts)
export class SocketClientTopic {
// ... các entry hiện có
// MyDomain
static readonly MY_DOMAIN = 'MY_DOMAIN';
}Giá trị chuỗi phải là duy nhất trong tất cả topic.
Bước 3 — Đăng ký kiểu payload (apps/core/src/socket/types.ts)
// Thêm kiểu message
export type TSubAndUnsubMyDomainMessage = {
merchantId: IdType; // hoặc ID nào scope room
};
// Thêm vào TSignalSocketTopicToDataMap
export type TSignalSocketTopicToDataMap = {
// ... các entry hiện có
// MyDomain
[SocketClientTopic.MY_DOMAIN]: TSubAndUnsubMyDomainMessage;
};Bước 4 — Mở rộng abstract message handler (apps/core/src/socket/message-handler/base-signal-socket-message-handler.ts)
Ba phần bổ sung:
// 1. Thêm vào interface ISignalSocketMessageHandler
onMyDomainTopic(opts: { event: string; data: AnyType }): void;
// 2. Thêm protected stub vào AbstractSignalSocketMessageHandler
protected onMyDomainTopicMyEvent(_opts: { data: IMyDomainSocket }): void {}
// 3. Thêm phương thức định tuyến
onMyDomainTopic(opts: { event: string; data: AnyType }) {
const { event, data } = opts;
switch (event) {
case WebSocketTopics.MY_DOMAIN_EVENT: {
this.onMyDomainTopicMyEvent({ data });
break;
}
default: break;
}
}Bước 5 — Wire subscribe / unsubscribe / cleanup (apps/core/src/socket/services/base-signal-socket.service.ts)
Ba phần cần cập nhật:
a) Switch subscribe() — thêm trước default::
case SocketClientTopic.MY_DOMAIN: {
const { merchantId } =
_data as TSignalSocketTopicToDataMap[typeof SocketClientTopic.MY_DOMAIN];
const key = `${topic}|${merchantId}`;
this._subscriptionManager.subscribe({ key });
socket.joinRooms({
rooms: [getRoomPath({ pathKey: WebSocketRooms.MY_DOMAIN, params: { merchantId } })],
});
socket.on({
event: WebSocketTopics.MY_DOMAIN_EVENT,
handler: (data: IMyDomainSocket) => {
this._messageHandlers.onMyDomainTopic({
event: WebSocketTopics.MY_DOMAIN_EVENT,
data,
});
},
});
break;
}b) Switch unsubscribe() — thêm trước default::
case SocketClientTopic.MY_DOMAIN: {
const { merchantId } =
data as TSignalSocketTopicToDataMap[typeof SocketClientTopic.MY_DOMAIN];
const key = `${topic}|${merchantId}`;
this._subscriptionManager.unsubscribe({ key });
if (!this._subscriptionManager.counter({ key })) {
socket.leaveRooms({
rooms: [getRoomPath({ pathKey: WebSocketRooms.MY_DOMAIN, params: { merchantId } })],
});
}
break;
}c) _handleClearListener() — ba dòng ở ba vị trí:
// 1. Khai báo counter (cùng với các let declaration khác)
let myDomainCount = 0;
// 2. Bên trong vòng for (cùng với các counter increment khác)
myDomainCount += topic === SocketClientTopic.MY_DOMAIN ? 1 : 0;
// 3. Sau vòng lặp, cùng với các khối offAll khác
if (!myDomainCount) {
socket.offAll({ event: WebSocketTopics.MY_DOMAIN_EVENT });
}Quan trọng: Bỏ qua cập nhật
_handleClearListener()gây rò rỉ event handler. Khi subscriber cuối cùng unmount, handler tiếp tục kích hoạt trên dữ liệu cũ qua các lần điều hướng.
Bước 6 — Override stub trong handler của app
Trong apps/client/src/socket/signal-socket-message-handler.ts (hoặc apps/bo):
protected override onMyDomainTopicMyEvent(_opts: { data: IMyDomainSocket }): void {
queryClient.invalidateQueries({ queryKey: ['services.MyDomainApi.find'] });
}Bước 7 — Đăng ký trong screen shell
// MyFeature.screen.tsx
const socketSubscriptionManager = useInjectable<ISocketSubscriptionManager>({
key: 'services.SocketSubscriptionManager',
});
const merchantId = useAppSelector((state) => state.workspace.merchantId);
React.useEffect(() => {
if (!merchantId) { return; }
socketSubscriptionManager.subscribeSignal({
topic: SocketClientTopic.MY_DOMAIN,
data: { merchantId },
});
return () => {
socketSubscriptionManager.unsubscribeSignal({
topic: SocketClientTopic.MY_DOMAIN,
data: { merchantId },
});
};
}, [merchantId, socketSubscriptionManager]);Tóm tắt checklist:
| # | File | Nội dung thêm |
|---|---|---|
| 1 | apps/core/src/interfaces/… | Interface payload |
| 2 | apps/core/src/socket/constants.ts | SocketClientTopic.MY_DOMAIN + WebSocketRooms.MY_DOMAIN (nếu room mới) |
| 3 | apps/core/src/socket/types.ts | TSubAndUnsubMyDomainMessage + map entry |
| 4 | …/base-signal-socket-message-handler.ts | Interface method + abstract stub + routing method |
| 5 | …/base-signal-socket.service.ts | Case subscribe + case unsubscribe + _handleClearListener (3 dòng) |
| 6 | apps/client/src/socket/signal-socket-message-handler.ts | Override stub |
| 7 | Screen shell .screen.tsx | useEffect subscribe/unsubscribe |
Trang Liên quan
| Trang | Mô tả |
|---|---|
| Bắt đầu | Cài đặt cục bộ |
| Hệ thống Build | Target Make cho các app |
| Tổng quan Apps | Tài liệu chi tiết theo từng app |
| Signal — WebSocket Client | EncryptedWebSocketClient cấp thấp (ECDH, AES-256-GCM, tự kết nối lại) |
| Signal — Tổng quan Package | Kiến trúc backend Signal service |