@retalia/pos-components
v0.0.7
Published
Presentational Adaptive POS Angular components. State in, typed intents out — no service calls.
Keywords
Readme
@retalia/pos-components
Presentational Adaptive POS Angular components. State in, typed intents out — no service calls.
Optional HTTP clients live behind a separate entry point: @retalia/pos-components/services. Components never import that entry. The host calls a service, then passes display state into the UI.
Requires Angular 21 (@angular/core and @angular/common ^21.2.0).
Install
npm i @retalia/pos-componentsServices
Import from @retalia/pos-components/services, never from a pos-* component.
import {
ClerkAuthService,
POS_API_CONFIG,
PosCommandsClient,
posApiInterceptor,
} from '@retalia/pos-components/services';| | |
|---|---|
| posApiInterceptor | Reads portalApiToken + contextShopId cookies and sets Authorization / Shop-Context. Provide via provideHttpClient(withInterceptors([posApiInterceptor])). |
| ClerkAuthService | signIn(clerkNumber, password) — GET /api/v1.0/clerks/{number}, password check in the service (stub until ID-647). Call this from the host on login.submit. |
| PosCommandsClient | addItem / changeQuantity / clearBasket / tenderCash / newSale — each returns a PosStateEnvelope. |
In local Adaptive POS, paste a Portal Bearer into the portalApiToken cookie and the shop id into contextShopId. Do not send clerk credentials to POST /token.
Styles
Add the package stylesheet once in the host app (e.g. angular.json styles):
node_modules/@retalia/pos-components/styles/styles.cssTheme via CSS custom properties (--pos-*) defined in that file. Override tokens in the host to rebrand; values not exposed as tokens are not overridable without a library change.
Use <pos-tokens> (Storybook: Theme / Tokens, or an internal theme lab page) to see every token in action. Click a sample or token to edit values as live overrides — this does not change the package defaults in styles.css. Export a complete :root CSS file for the consuming POS, and import that file back into the lab later.
Tokens gallery
import { Component } from '@angular/core';
import { PosTokens } from '@retalia/pos-components';
@Component({
selector: 'app-theme-lab',
imports: [PosTokens],
template: `<pos-tokens />`,
})
export class ThemeLab {}Host the exported file after the package stylesheet so --pos-* overrides win:
node_modules/@retalia/pos-components/styles/styles.css
src/styles/pos-theme.css| | |
|---|---|
| Selector | pos-tokens |
| Purpose | Documentation / theme lab — not for production POS screens |
| Export | Complete :root { --pos-*: … } CSS for the host POS |
| Import | Previously exported theme CSS, applied as lab overrides only |
Login
Keypad login UI — presentational only. It does not call auth services. Layout around it (e.g. split with an image) belongs in the host app.
The host envelope holds login/session state and passes it in. If the host uses a package AuthService, it calls that on login.submit, then updates [session] / [errorMessage]. The component never injects the service.
import { Component, signal } from '@angular/core';
import {
PosLogin,
PosLoginIntent,
PosLoginSessionState,
} from '@retalia/pos-components';
@Component({
selector: 'app-root',
imports: [PosLogin],
template: `
<pos-login
[session]="session()"
[errorMessage]="errorMessage()"
(action)="onLoginAction($event)"
/>
`,
})
export class App {
readonly session = signal<PosLoginSessionState>({ status: 'signedOut' });
readonly errorMessage = signal('');
onLoginAction(intent: PosLoginIntent): void {
if (intent.type !== 'login.submit') {
return;
}
// Host (or package ClerkAuthService) calls the API, then:
// this.session.set({ status: 'busy' });
// this.session.set({ status: 'signedIn', displayName: clerk.name });
// this.errorMessage.set('Invalid clerk or password');
}
}| | |
|---|---|
| Selector | pos-login |
| Input session | Display-only login/session state: signedOut | busy | signedIn, optional displayName |
| Input errorMessage | Optional error text |
| Output action | Typed intents: login.key, login.next, login.submit, login.back, … |
Basket
Presentational sale basket — renders basket.lines[] and totals exactly as the host envelope provides them. It does not price, tax, or discount. Quantity, void, and clear only emit intents; the host (or server) updates the envelope.
import { Component, signal } from '@angular/core';
import {
PosBasket,
PosBasketIntent,
PosBasketLine,
PosBasketTotals,
} from '@retalia/pos-components';
@Component({
selector: 'app-sale',
imports: [PosBasket],
template: `
<pos-basket
[lines]="lines()"
[totals]="totals()"
(action)="onBasketAction($event)"
/>
`,
})
export class Sale {
readonly lines = signal<readonly PosBasketLine[]>([]);
readonly totals = signal<PosBasketTotals>({
gross: 0,
net: 0,
vat: 0,
discount: 0,
due: 0,
currency: 'EUR',
});
onBasketAction(intent: PosBasketIntent): void {
// Host envelope binding maps:
// basket.changeQuantity → change-quantity
// basket.voidLine → void-line
// basket.clear → clear-basket
}
}| | |
|---|---|
| Selector | pos-basket |
| Input lines | Envelope basket.lines[] as given (lineId, sku, name, qty, unitPrice, lineTotal, vat, struckThrough) |
| Input totals | Envelope totals as given (gross, net, vat, discount, due, currency) |
| Input disabled | Optional lock while the host is busy |
| Output action | Typed intents: basket.changeQuantity, basket.voidLine, basket.clear |
Item entry
Presentational scan-or-type field for the sale screen. It captures an item reference (barcode scan or typed SKU) and emits an item-entry intent. It does not look up, validate, or price the item — the host envelope maps the intent to item-entry, and the server handles catalog lookup.
import { Component, signal } from '@angular/core';
import { PosItemEntry, PosItemEntryIntent } from '@retalia/pos-components';
@Component({
selector: 'app-sale',
imports: [PosItemEntry],
template: `
<pos-item-entry
[disabled]="busy()"
[errorMessage]="errorMessage()"
(action)="onItemEntryAction($event)"
/>
`,
})
export class Sale {
readonly busy = signal(false);
readonly errorMessage = signal('');
onItemEntryAction(intent: PosItemEntryIntent): void {
// Host envelope binding maps:
// itemEntry.submit → item-entry (payload: reference)
}
}| | |
|---|---|
| Selector | pos-item-entry |
| Input disabled | Optional lock while the host is busy (e.g. AddItem in flight) |
| Input errorMessage | Optional host-provided error text (lookup failures belong to the host) |
| Input autofocus | Focus the field on render so a scanner can type immediately (default true) |
| Output action | Typed intent: itemEntry.submit with reference |
Tender
Presentational cash tender — the cashier enters the amount tendered; the host envelope sends that intent and passes back changeDue. The component never calculates change or takes payment. Card / electronic tender is out of scope.
import { Component, signal } from '@angular/core';
import { PosTender, PosTenderIntent } from '@retalia/pos-components';
@Component({
selector: 'app-tender',
imports: [PosTender],
template: `
<pos-tender
[amountDue]="amountDue()"
[changeDue]="changeDue()"
[currency]="currency()"
[disabled]="busy()"
[errorMessage]="errorMessage()"
(action)="onTenderAction($event)"
/>
`,
})
export class Tender {
readonly amountDue = signal(12);
readonly changeDue = signal<number | null>(null);
readonly currency = signal('EUR');
readonly busy = signal(false);
readonly errorMessage = signal('');
onTenderAction(intent: PosTenderIntent): void {
if (intent.type !== 'tender.cash') {
return;
}
// Host envelope binding maps tender.cash → tender intent,
// then sets changeDue from the envelope response.
// this.busy.set(true);
// this.changeDue.set(envelope.changeDue);
}
}| | |
|---|---|
| Selector | pos-tender |
| Input amountDue | Envelope amount due as given — display only |
| Input changeDue | Envelope change due as given (null until the host responds). Shown as received, never calculated |
| Input currency | ISO currency code used only for display formatting |
| Input disabled | Optional lock while the host is busy |
| Input errorMessage | Optional error text |
| Output action | Typed intent: tender.cash with the cashier-entered amount |
Receipt
Presentational completed-sale receipt — renders basket.lines[], totals, payments[], and document exactly as the host envelope provides them. It does not format, tax, or calculate change. Printing is out of scope.
import { Component, signal } from '@angular/core';
import {
PosReceipt,
PosReceiptIntent,
PosReceiptLine,
PosReceiptPayment,
PosReceiptTotals,
PosReceiptDocument,
} from '@retalia/pos-components';
@Component({
selector: 'app-receipt',
imports: [PosReceipt],
template: `
<pos-receipt
[lines]="lines()"
[totals]="totals()"
[payments]="payments()"
[document]="document()"
(action)="onReceiptAction($event)"
/>
`,
})
export class Receipt {
readonly lines = signal<readonly PosReceiptLine[]>([]);
readonly totals = signal<PosReceiptTotals>({
gross: 0,
net: 0,
vat: 0,
discount: 0,
due: 0,
currency: 'EUR',
});
readonly payments = signal<readonly PosReceiptPayment[]>([]);
readonly document = signal<PosReceiptDocument>({
number: '',
receiptReady: false,
});
onReceiptAction(intent: PosReceiptIntent): void {
if (intent.type !== 'receipt.newSale') {
return;
}
// Host envelope binding maps receipt.newSale → newSale.
}
}| | |
|---|---|
| Selector | pos-receipt |
| Input lines | Envelope basket.lines[] as given (lineId, sku, name, qty, unitPrice, lineTotal, vat, struckThrough) |
| Input totals | Envelope totals as given (gross, net, vat, discount, due, currency) |
| Input payments | Envelope payments[] as given (tender, amount, tendered, change, state) |
| Input document | Envelope document as given (number, receiptReady) |
| Input disabled | Optional lock while the host is busy |
| Output action | Typed intent: receipt.newSale |
