Frontend Patterns
All BANA frontend apps are React 19 SPAs built with Vite 7 and TailwindCSS 4. This page covers the shared patterns that apply across all apps.
Shared Stack
| Layer | Technology | Version |
|---|---|---|
| Runtime | Bun | >= 1.3.8 |
| UI Framework | React | 19 |
| Build | Vite | 7 |
| Styling | TailwindCSS | 4 |
| Language | TypeScript | ~5.9 |
| Icons | lucide-react | latest |
| UI Primitives | Radix UI | 15+ primitives |
| Component Library | shadcn/ui (via admin-ui-kit) | — |
App Dependency Graph
| App | Framework | Port | Purpose |
|---|---|---|---|
@nx-app/client | React + Vite + shadcn/ui | 5173 | Admin dashboard — 28 screen modules |
@nx-app/bo | React + Vite + shadcn/ui | 5174 | Back office — merchant management |
@nx-app/sale-renderer | React + Vite + Tauri WebView | — | POS desktop UI (barcode, grid, QR) |
@nx-app/sale-main | Rust + Tauri 2 | — | POS desktop backend (SQLite, USB, payment) |
@nx-app/overture | Astro | 4321 | Marketing / landing page |
@nx-app/admin-ui-kit | React + Radix UI | — | Shared component library (38 shadcn + custom) |
@nx-app/core | TypeScript | — | Shared constants, hooks, i18n, utilities |
@nx-app/admin-ui-kit
The shared UI component library used by client and bo. Built on Radix UI + shadcn/ui patterns.
Structure
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 exportsKey dependencies
| Package | Purpose |
|---|---|
@radix-ui/* | 15+ headless UI primitives |
lucide-react | Icon set |
sonner | Toast notifications |
next-themes | Theme switching |
tailwind-merge | Merge TailwindCSS classes |
class-variance-authority | Variant-based component styling |
Run bun run gen:index after adding new components to regenerate barrel export files.
@nx-app/core
Shared module consumed by client, bo, and sale-renderer.
Exports
| Export | Description |
|---|---|
constants | API endpoints, configuration values |
hooks | Shared React hooks |
locales | i18n translation files (separate per locale) |
utilities | Shared utility functions |
Peer dependencies
ra-core, @minimaltech/ra-core-infra, React 19.
i18n Pattern
Translation files are organized as separate files per locale with no abstraction layer:
apps/core/src/locales/
├── en/
│ ├── common.ts
│ ├── products.ts
│ └── ...
└── vi/
├── common.ts
├── products.ts
└── ...Backend i18n uses { en: string, vi: string } JSON objects stored directly in database fields (e.g. name: { en: 'Products', vi: 'Sản phẩm' }).
Client Screen Modules
@nx-app/client contains 28 screen modules in src/screens/:
| Module | Domain |
|---|---|
| categories, category-templates | Product catalog |
| products, merchants, merchant-types | Commerce |
| organizers | Organization |
| customers | CRM |
| devices, terminals | Hardware |
| sale-orders, sale-channels | Sales |
| purchase-orders, vendors | Procurement |
| inventory, inventory-tracking, importation | Stock |
| finance (wallets, transactions, categories) | Finance |
| invoice | Invoicing |
| reports (orders, revenue) | Reporting |
| roles, users, user-employees | Identity |
| settings, sign-in, sign-up, transactions | System |
POS Desktop (Tauri)
The POS app is a two-process Tauri application:
| Process | Package | Technology |
|---|---|---|
| Renderer (UI) | @nx-app/sale-renderer | React + Vite + TailwindCSS |
| Main (backend) | @nx-app/sale-main | Rust + SQLite + Sea-ORM |
sale-renderer extras
| Package | Purpose |
|---|---|
socket.io-client | WebSocket connection to Signal service |
@nichetech/barcode-scanner | Barcode/QR scanning |
react-grid-layout | Draggable tile layout |
pdfjs-dist | PDF rendering |
| QR code generation | Transaction QR display |
sale-main Tauri plugins
| Plugin | Purpose |
|---|---|
tauri-plugin-usb | USB device communication |
tauri-plugin-payment | Payment terminal integration |
tauri-plugin-external-display | Customer-facing display |
tauri-plugin-machine-uid | Device fingerprint for licensing |
Commands
cd apps/sale-main
bunx tauri dev # Start Tauri in development mode
bun run gene-model # Generate Sea-ORM entities from SQLite
bun run migrate # Run SQLite migrationsDevelopment
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 — Real-Time Subscriptions
apps/client and apps/bo receive live server events through an encrypted WebSocket connected to the @nx/signal backend service. The frontend stack has four layers, each with a single responsibility.
The encrypted transport layer (
EncryptedWebSocketClient) is documented in the Signal — Client Guide. This section covers everything above it — the room subscription system, the message handler pattern, and how screens wire into it.
Architecture
Service Registration
Both apps register the two socket singletons in their IoC bindingPreList() before any other service:
// apps/client/src/application/application.ts (apps/bo is identical)
bindingPreList() {
return {
'services.SocketConnectionManager': SocketConnectionManager,
'services.SocketSubscriptionManager': SocketSubscriptionManager,
};
}SocketConnectionManager is app-specific (lives in apps/client/src/socket/ and apps/bo/src/socket/). It instantiates the concrete SignalSocket and the app's own SignalSocketMessageHandler when the user logs in.
SocketSubscriptionManager is shared from apps/core. It is a thin facade that calls signalSocket()?.subscribe() / unsubscribe().
Connection lifecycle:
| Event | What happens |
|---|---|
| Login | AuthProvider calls socketConnectionManager.setUserToken(token) → SignalSocket is created and connects |
| Logout | socketConnectionManager.clearUserToken() → socket is destroyed, state resets to DISCONNECTED |
| App unmount | destroy() is called on SocketConnectionManager singleton |
Environment variables required in .env.development (or .env.local):
VITE_WEB_SOCKET_SIGNAL_URL=ws://localhost:3500/stream
VITE_WEB_SOCKET_SIGNAL_ECDH_INFO=nx-signal-e2eMessage Routing
AbstractSignalSocketMessageHandler (apps/core/src/socket/message-handler/) is an abstract class with two layers:
- Public routing methods (
onXxxTopic) — called byAbstractSignalSocket.subscribe()for each incoming event. Each routing method switches on the event string and calls a protected stub. - Protected stubs (
onXxxTopicYyyEvent) — no-op by default. App-specific subclasses override only the stubs they care about.
AbstractSignalSocket.subscribe()
└─ socket.on(event, handler)
└─ _messageHandlers.onLedgerProcessTopic({ event, data })
└─ AbstractSignalSocketMessageHandler.onLedgerProcessTopic()
└─ switch(event):
case LEDGER_JOB_STATUS →
this.onLedgerProcessTopicLedgerJobStatus({ data })
↑
└─ SignalSocketMessageHandler (apps/client) overrides this
queryClient.invalidateQueries(...)apps/client — src/socket/signal-socket-message-handler.ts — overrides:
| Stub | Action |
|---|---|
onMerchantSaleOrderTopicSaleSaleOrder | Updates Redux saleOrder slice or invalidates list query |
onSaleOrderTopicSaleSaleOrder | Invalidates SaleOrderApi.findById |
onMerchantTransactionsTopicPaymentTransaction | Updates Redux transaction slice or invalidates list query |
onTransactionTopicPaymentTransaction | Invalidates TransactionApi.findById |
onTransactionPaymentAttemptTopicPaymentAttempt | Invalidates TransactionApi.findById |
onLedgerProcessTopicLedgerJobStatus | Invalidates LedgerBatchApi.statusBatch |
apps/bo — src/socket/signal-socket-message-handler.ts — overrides:
| Stub | Action |
|---|---|
onOutreachInquiry | Invalidates OutreachInquiryApi.find + .count, fires InquiryListObservation |
Ref-Count Subscription Manager
BaseSocketSubscriptionManager (in apps/core) keeps a Map<string, number> where the key is "TOPIC|id" (e.g. "LEDGER_PROCESS|merchant-uuid"). This lets multiple components subscribe to the same topic/id without double-joining the WebSocket room.
| Method | Behaviour |
|---|---|
subscribe({ key }) | Increments counter. If new key, sets to 1. |
unsubscribe({ key }) | Decrements counter. When it hits 0, deletes the key. |
counter({ key }) | Returns current count, or undefined if not subscribed. |
AbstractSignalSocket.unsubscribe() calls leaveRooms() only when counter({ key }) reaches zero. _handleClearListener() calls socket.offAll() for an event only when no active subscriptions for that topic type remain.
Subscription Pattern in Screens
Screens never interact with SocketConnectionManager or SignalSocket directly. They call SocketSubscriptionManager via useInjectable:
// Pattern used in apps/client and apps/bo screen shells
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]);Rules:
- Subscribe/unsubscribe belongs in the screen shell (
.screen.tsx), not in table or child components. - Always guard on the required ID (
if (!merchantId) return). - The cleanup function in
useEffectis the unsubscribe call — React runs it both on dependency change and on unmount. - Data components (e.g.
LedgerTable) re-fetch automatically when their React Query key is invalidated by the message handler. They do not need to know about WebSocket.
All Topics Reference
Defined in apps/core/src/socket/constants.ts.
SocketClientTopic | WebSocket Room | Event(s) fired | Payload type | Required param |
|---|---|---|---|---|
MERCHANT | wr:observation/merchants/{merchantId} | SALE_SALE_ORDER, SALE_SALE_ORDER_ITEM, PAYMENT_TRANSACTION, PAYMENT_ATTEMPT, SALE_KITCHEN_TICKET, SALE_KITCHEN_TICKET_ITEM | Various | 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 | (none) |
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 |
How to Add a New Topic
Adding a new real-time subscription requires changes to 6 locations across apps/core and the consuming app. Complete them in order — earlier steps define types that later steps depend on.
Step 1 — Add payload interface (apps/core/src/interfaces/)
Create or extend the relevant interface file with the socket payload type:
export interface IMyDomainSocket {
id: string;
merchantId: string;
status: string;
// ... fields the backend sends
}Verify it is exported from apps/core/src/index.ts.
Step 2 — Add SocketClientTopic constant (apps/core/src/socket/constants.ts)
export class SocketClientTopic {
// ... existing entries
// MyDomain
static readonly MY_DOMAIN = 'MY_DOMAIN';
}The string value must be unique across all topics.
Step 3 — Register the payload type (apps/core/src/socket/types.ts)
// Add the message type
export type TSubAndUnsubMyDomainMessage = {
merchantId: IdType; // or whichever ID scopes the room
};
// Add to TSignalSocketTopicToDataMap
export type TSignalSocketTopicToDataMap = {
// ... existing entries
// MyDomain
[SocketClientTopic.MY_DOMAIN]: TSubAndUnsubMyDomainMessage;
};Step 4 — Extend the abstract message handler (apps/core/src/socket/message-handler/base-signal-socket-message-handler.ts)
Three additions:
// 1. Add to ISignalSocketMessageHandler interface
onMyDomainTopic(opts: { event: string; data: AnyType }): void;
// 2. Add the protected stub to AbstractSignalSocketMessageHandler
protected onMyDomainTopicMyEvent(_opts: { data: IMyDomainSocket }): void {}
// 3. Add the routing method
onMyDomainTopic(opts: { event: string; data: AnyType }) {
const { event, data } = opts;
switch (event) {
case WebSocketTopics.MY_DOMAIN_EVENT: {
this.onMyDomainTopicMyEvent({ data });
break;
}
default: break;
}
}Step 5 — Wire subscribe / unsubscribe / cleanup (apps/core/src/socket/services/base-signal-socket.service.ts)
Three sections to update:
a) subscribe() switch — add before 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) unsubscribe() switch — add before 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() — three lines in three places:
// 1. Counter declaration (with the other let declarations)
let myDomainCount = 0;
// 2. Inside the for loop (with the other counter increments)
myDomainCount += topic === SocketClientTopic.MY_DOMAIN ? 1 : 0;
// 3. After the loop, with the other offAll blocks
if (!myDomainCount) {
socket.offAll({ event: WebSocketTopics.MY_DOMAIN_EVENT });
}Important: Skipping the
_handleClearListener()update causes event handler leaks. When the last subscriber unmounts, the handler continues firing on stale data across navigations.
Step 6 — Override the stub in the app's handler
In apps/client/src/socket/signal-socket-message-handler.ts (or apps/bo):
protected override onMyDomainTopicMyEvent(_opts: { data: IMyDomainSocket }): void {
queryClient.invalidateQueries({ queryKey: ['services.MyDomainApi.find'] });
}Step 7 — Subscribe in the 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]);Summary checklist:
| # | File | What to add |
|---|---|---|
| 1 | apps/core/src/interfaces/… | Payload interface |
| 2 | apps/core/src/socket/constants.ts | SocketClientTopic.MY_DOMAIN + WebSocketRooms.MY_DOMAIN (if new room) |
| 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 | subscribe case + unsubscribe case + _handleClearListener (3 lines) |
| 6 | apps/client/src/socket/signal-socket-message-handler.ts | Override stub |
| 7 | Screen shell .screen.tsx | useEffect subscribe/unsubscribe |
Related Pages
| Page | Description |
|---|---|
| Getting Started | Local setup |
| Build System | Make targets for apps |
| Apps Overview | Detailed per-app documentation |
| Signal — WebSocket Client | Low-level EncryptedWebSocketClient (ECDH, AES-256-GCM, reconnect) |
| Signal — Package Overview | Backend Signal service architecture |