Skip to content

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ầngCông nghệPhiên bản
RuntimeBun>= 1.3.8
UI FrameworkReact19
BuildVite7
StylingTailwindCSS4
Ngôn ngữTypeScript~5.9
Iconlucide-reactmới nhất
UI PrimitiveRadix UI15+ primitive
Thư viện Componentshadcn/ui (qua admin-ui-kit)

Đồ thị Phụ thuộc App

AppFrameworkCổngMục đích
@nx-app/clientReact + Vite + shadcn/ui5173Admin dashboard — 28 module màn hình
@nx-app/boReact + Vite + shadcn/ui5174Back office — quản lý merchant
@nx-app/sale-rendererReact + Vite + Tauri WebViewUI POS desktop (barcode, lưới, QR)
@nx-app/sale-mainRust + Tauri 2Backend POS desktop (SQLite, USB, payment)
@nx-app/overtureAstro4321Trang marketing / landing
@nx-app/admin-ui-kitReact + Radix UIThư viện component dùng chung (38 shadcn + custom)
@nx-app/coreTypeScriptHằ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 clientbo. 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 exports

Phụ thuộc Chính

PackageMục đích
@radix-ui/*15+ headless UI primitive
lucide-reactBộ icon
sonnerThông báo toast
next-themesChuyển đổi theme
tailwind-mergeGộp class TailwindCSS
class-variance-authorityStyling 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

ExportMô tả
constantsEndpoint API, các giá trị cấu hình
hooksReact hook dùng chung
localesFile dịch i18n (riêng theo từng locale)
utilitiesHà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/:

ModuleLĩnh vực
categories, category-templatesDanh mục sản phẩm
products, merchants, merchant-typesCommerce
organizersTổ chức
customersCRM
devices, terminalsPhần cứng
sale-orders, sale-channelsBán hàng
purchase-orders, vendorsMua hàng
inventory, inventory-tracking, importationTồn kho
finance (wallets, transactions, categories)Tài chính
invoiceHóa đơn
reports (orders, revenue)Báo cáo
roles, users, user-employeesIdentity
settings, sign-in, sign-up, transactionsHệ thống

POS Desktop (Tauri)

Ứng dụng POS là một ứng dụng Tauri hai tiến trình:

Tiến trìnhPackageCông nghệ
Renderer (UI)@nx-app/sale-rendererReact + Vite + TailwindCSS
Main (backend)@nx-app/sale-mainRust + SQLite + Sea-ORM

sale-renderer extras

PackageMục đích
socket.io-clientKết nối WebSocket đến dịch vụ Signal
@nichetech/barcode-scannerQuét barcode/QR
react-grid-layoutLayout tile có thể kéo thả
pdfjs-distRender PDF
QR code generationHiển thị QR giao dịch

Plugin Tauri của sale-main

PluginMục đích
tauri-plugin-usbGiao tiếp thiết bị USB
tauri-plugin-paymentTích hợp terminal thanh toán
tauri-plugin-external-displayMàn hình hiển thị cho khách hàng
tauri-plugin-machine-uidVân tay thiết bị cho licensing

Lệnh

bash
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 SQLite

Phát triển

bash
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 dev

WebSocket — Đăng ký Dữ liệu Thời gian Thực

apps/clientapps/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:

typescript
// 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/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ậpAuthProvider gọi socketConnectionManager.setUserToken(token)SignalSocket được tạo và kết nối
Đăng xuấtsocketConnectionManager.clearUserToken() → socket bị hủy, trạng thái reset về DISCONNECTED
App unmountdestroy() đượ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:

  1. Phương thức định tuyến public (onXxxTopic) — được gọi bởi AbstractSignalSocket.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.
  2. 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/clientsrc/socket/signal-socket-message-handler.ts — override:

StubHành động
onMerchantSaleOrderTopicSaleSaleOrderCập nhật Redux saleOrder slice hoặc invalidate list query
onSaleOrderTopicSaleSaleOrderInvalidate SaleOrderApi.findById
onMerchantTransactionsTopicPaymentTransactionCập nhật Redux transaction slice hoặc invalidate list query
onTransactionTopicPaymentTransactionInvalidate TransactionApi.findById
onTransactionPaymentAttemptTopicPaymentAttemptInvalidate TransactionApi.findById
onLedgerProcessTopicLedgerJobStatusInvalidate LedgerBatchApi.statusBatch

apps/bosrc/socket/signal-socket-message-handler.ts — override:

StubHành động
onOutreachInquiryInvalidate 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ứcHà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:

typescript
// 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 useEffect là 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.

SocketClientTopicWebSocket RoomEvent(s)Kiểu payloadTham số bắt buộc
MERCHANTwr:observation/merchants/{merchantId}SALE_SALE_ORDER, SALE_SALE_ORDER_ITEM, PAYMENT_TRANSACTION, PAYMENT_ATTEMPT, SALE_KITCHEN_TICKET, SALE_KITCHEN_TICKET_ITEMNhiều kiểumerchantId
MERCHANT_SALE_ORDERSwr:observation/merchants/{merchantId}/sale-ordersSALE_SALE_ORDER, SALE_SALE_ORDER_ITEMISaleOrderSocketmerchantId
SALE_ORDERwr:observation/sale-orders/{saleOrderId}SALE_SALE_ORDER, SALE_SALE_ORDER_ITEMISaleOrderSocketsaleOrderId
SALE_ORDER_SALE_ORDER_ITEMSwr:observation/sale-orders/{saleOrderId}/sale-order-itemsSALE_SALE_ORDER_ITEMISaleOrderItemSocketsaleOrderId
MERCHANT_TRANSACTIONSwr:observation/merchants/{merchantId}/transactionsPAYMENT_TRANSACTIONITransactionmerchantId
TRANSACTIONwr:observation/transactions/{transactionId}PAYMENT_TRANSACTIONITransactiontransactionId
MERCHANT_PAYMENT_ATTEMPTSwr:observation/merchants/{merchantId}/payment-attemptsPAYMENT_ATTEMPTIPaymentAttemptmerchantId
PAYMENT_ATTEMPTwr:observation/payment-attempt/{attemptId}PAYMENT_ATTEMPTIPaymentAttemptattemptId
TRANSACTION_PAYMENT_ATTEMPTwr:observation/transactions/{transactionId}/payment-attemptsPAYMENT_ATTEMPTIPaymentAttempttransactionId
MERCHANT_KITCHENwr:observation/merchants/{merchantId}/kitchenSALE_KITCHEN_TICKET, SALE_KITCHEN_TICKET_ITEMIKitchenTicketSocketmerchantId
SALE_ORDER_KITCHENwr:observation/sale-orders/{saleOrderId}/kitchenSALE_KITCHEN_TICKET, SALE_KITCHEN_TICKET_ITEMIKitchenTicketSocketsaleOrderId
KITCHEN_STATIONwr:observation/kitchen-stations/{kitchenStationId}SALE_KITCHEN_TICKET, SALE_KITCHEN_TICKET_ITEMIKitchenTicketSocketkitchenStationId
OUTREACH_INQUIRIESwr:observation/outreach/inquiriesOUTREACH_INQUIRYIOutreachInquirySocket(không có)
MERCHANT_ALLOCATION_USAGESwr:observation/merchants/{merchantId}/allocation-usagesALLOCATION_ALLOCATION_USAGEIAllocationUsageSocketmerchantId
ALLOCATION_ZONE_LV0wr:observation/allocation-zones/lv0/{allocationZoneId}ALLOCATION_ALLOCATION_USAGEIAllocationUsageSocketallocationZoneId
ALLOCATION_ZONE_LV1wr:observation/allocation-zones/lv1/{allocationZoneParentId}ALLOCATION_ALLOCATION_USAGE, ALLOCATION_RESERVATIONIAllocationUsageSocketallocationZoneId
LEDGER_PROCESSwr:ledger/{merchantId}/processLEDGER_JOB_STATUSILedgerJobStatusSocketmerchantId

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:

typescript
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)

typescript
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)

typescript
// 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:

typescript
// 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::

typescript
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::

typescript
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í:

typescript
// 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):

typescript
protected override onMyDomainTopicMyEvent(_opts: { data: IMyDomainSocket }): void {
  queryClient.invalidateQueries({ queryKey: ['services.MyDomainApi.find'] });
}

Bước 7 — Đăng ký trong screen shell

typescript
// 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:

#FileNội dung thêm
1apps/core/src/interfaces/…Interface payload
2apps/core/src/socket/constants.tsSocketClientTopic.MY_DOMAIN + WebSocketRooms.MY_DOMAIN (nếu room mới)
3apps/core/src/socket/types.tsTSubAndUnsubMyDomainMessage + map entry
4…/base-signal-socket-message-handler.tsInterface method + abstract stub + routing method
5…/base-signal-socket.service.tsCase subscribe + case unsubscribe + _handleClearListener (3 dòng)
6apps/client/src/socket/signal-socket-message-handler.tsOverride stub
7Screen shell .screen.tsxuseEffect subscribe/unsubscribe

Trang Liên quan

TrangMô tả
Bắt đầuCài đặt cục bộ
Hệ thống BuildTarget Make cho các app
Tổng quan AppsTài liệu chi tiết theo từng app
Signal — WebSocket ClientEncryptedWebSocketClient cấp thấp (ECDH, AES-256-GCM, tự kết nối lại)
Signal — Tổng quan PackageKiến trúc backend Signal service

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.