Skip to content

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

LayerTechnologyVersion
RuntimeBun>= 1.3.8
UI FrameworkReact19
BuildVite7
StylingTailwindCSS4
LanguageTypeScript~5.9
Iconslucide-reactlatest
UI PrimitivesRadix UI15+ primitives
Component Libraryshadcn/ui (via admin-ui-kit)

App Dependency Graph

AppFrameworkPortPurpose
@nx-app/clientReact + Vite + shadcn/ui5173Admin dashboard — 28 screen modules
@nx-app/boReact + Vite + shadcn/ui5174Back office — merchant management
@nx-app/sale-rendererReact + Vite + Tauri WebViewPOS desktop UI (barcode, grid, QR)
@nx-app/sale-mainRust + Tauri 2POS desktop backend (SQLite, USB, payment)
@nx-app/overtureAstro4321Marketing / landing page
@nx-app/admin-ui-kitReact + Radix UIShared component library (38 shadcn + custom)
@nx-app/coreTypeScriptShared 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 exports

Key dependencies

PackagePurpose
@radix-ui/*15+ headless UI primitives
lucide-reactIcon set
sonnerToast notifications
next-themesTheme switching
tailwind-mergeMerge TailwindCSS classes
class-variance-authorityVariant-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

ExportDescription
constantsAPI endpoints, configuration values
hooksShared React hooks
localesi18n translation files (separate per locale)
utilitiesShared 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/:

ModuleDomain
categories, category-templatesProduct catalog
products, merchants, merchant-typesCommerce
organizersOrganization
customersCRM
devices, terminalsHardware
sale-orders, sale-channelsSales
purchase-orders, vendorsProcurement
inventory, inventory-tracking, importationStock
finance (wallets, transactions, categories)Finance
invoiceInvoicing
reports (orders, revenue)Reporting
roles, users, user-employeesIdentity
settings, sign-in, sign-up, transactionsSystem

POS Desktop (Tauri)

The POS app is a two-process Tauri application:

ProcessPackageTechnology
Renderer (UI)@nx-app/sale-rendererReact + Vite + TailwindCSS
Main (backend)@nx-app/sale-mainRust + SQLite + Sea-ORM

sale-renderer extras

PackagePurpose
socket.io-clientWebSocket connection to Signal service
@nichetech/barcode-scannerBarcode/QR scanning
react-grid-layoutDraggable tile layout
pdfjs-distPDF rendering
QR code generationTransaction QR display

sale-main Tauri plugins

PluginPurpose
tauri-plugin-usbUSB device communication
tauri-plugin-paymentPayment terminal integration
tauri-plugin-external-displayCustomer-facing display
tauri-plugin-machine-uidDevice fingerprint for licensing

Commands

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

Development

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

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

EventWhat happens
LoginAuthProvider calls socketConnectionManager.setUserToken(token)SignalSocket is created and connects
LogoutsocketConnectionManager.clearUserToken() → socket is destroyed, state resets to DISCONNECTED
App unmountdestroy() 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-e2e

Message Routing

AbstractSignalSocketMessageHandler (apps/core/src/socket/message-handler/) is an abstract class with two layers:

  1. Public routing methods (onXxxTopic) — called by AbstractSignalSocket.subscribe() for each incoming event. Each routing method switches on the event string and calls a protected stub.
  2. 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/clientsrc/socket/signal-socket-message-handler.ts — overrides:

StubAction
onMerchantSaleOrderTopicSaleSaleOrderUpdates Redux saleOrder slice or invalidates list query
onSaleOrderTopicSaleSaleOrderInvalidates SaleOrderApi.findById
onMerchantTransactionsTopicPaymentTransactionUpdates Redux transaction slice or invalidates list query
onTransactionTopicPaymentTransactionInvalidates TransactionApi.findById
onTransactionPaymentAttemptTopicPaymentAttemptInvalidates TransactionApi.findById
onLedgerProcessTopicLedgerJobStatusInvalidates LedgerBatchApi.statusBatch

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

StubAction
onOutreachInquiryInvalidates 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.

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

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

SocketClientTopicWebSocket RoomEvent(s) firedPayload typeRequired param
MERCHANTwr:observation/merchants/{merchantId}SALE_SALE_ORDER, SALE_SALE_ORDER_ITEM, PAYMENT_TRANSACTION, PAYMENT_ATTEMPT, SALE_KITCHEN_TICKET, SALE_KITCHEN_TICKET_ITEMVariousmerchantId
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(none)
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

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:

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

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

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

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

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) unsubscribe() switch — add before 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() — three lines in three places:

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

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

Step 7 — Subscribe in the 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]);

Summary checklist:

#FileWhat to add
1apps/core/src/interfaces/…Payload interface
2apps/core/src/socket/constants.tsSocketClientTopic.MY_DOMAIN + WebSocketRooms.MY_DOMAIN (if new room)
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.tssubscribe case + unsubscribe case + _handleClearListener (3 lines)
6apps/client/src/socket/signal-socket-message-handler.tsOverride stub
7Screen shell .screen.tsxuseEffect subscribe/unsubscribe

PageDescription
Getting StartedLocal setup
Build SystemMake targets for apps
Apps OverviewDetailed per-app documentation
Signal — WebSocket ClientLow-level EncryptedWebSocketClient (ECDH, AES-256-GCM, reconnect)
Signal — Package OverviewBackend Signal service architecture

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