@retalia/pos-components 0.0.12 → 0.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,442 +1,410 @@
1
- # @retalia/pos-components
2
-
3
- Presentational Adaptive POS Angular components. State in, typed intents out — no service calls.
4
-
5
- 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.
6
-
7
- Requires **Angular 21** (`@angular/core` and `@angular/common` `^21.2.0`).
8
-
9
- ## Install
10
-
11
- ```bash
12
- npm i @retalia/pos-components
13
- ```
14
-
15
- ## Services
16
-
17
- Import from `@retalia/pos-components/services`, never from a `pos-*` component.
18
-
19
- ```ts
20
- import {
21
- ClerkAuthService,
22
- POS_API_CONFIG,
23
- PosCommandsClient,
24
- posApiInterceptor,
25
- } from '@retalia/pos-components/services';
26
- ```
27
-
28
- | | |
29
- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
30
- | `posApiInterceptor` | Reads `portalApiToken` + `contextShopId` cookies and sets `Authorization` / `Shop-Context`, plus `Clerk-Context` when `POS_API_CONFIG.getClerkContext` returns a clerk number. Provide via `provideHttpClient(withInterceptors([posApiInterceptor]))`. |
31
- | `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`. |
32
- | `PosCommandsClient` | `addItem` / `changeQuantity` / `clearBasket` / `tenderCash` / `newSale` — each returns a `PosStateEnvelope`. |
33
-
34
- 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`.
35
-
36
- HeadlessAPI puts the operator on the envelope `session.clerkId` from `Clerk-Context`. There is no cookie for it; the host returns the signed-in clerk from `getClerkContext` after its password check passes (null before sign-in and after sign-out):
37
-
38
- ```ts
39
- {
40
- provide: POS_API_CONFIG,
41
- useFactory: () => {
42
- const store = inject(EnvelopeStore); // wherever the host keeps the signed-in clerk
43
- return { ...DEFAULT_POS_API_CONFIG, getClerkContext: () => store.clerk()?.clerkNumber ?? null };
44
- },
45
- }
46
- ```
47
-
48
- ## Styles
49
-
50
- Add the package stylesheet once in the host app (e.g. `angular.json` `styles`):
51
-
52
- ```text
53
- node_modules/@retalia/pos-components/styles/styles.css
54
- ```
55
-
56
- Theme 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. Brand wordmarks use `--pos-letter-spacing-brand`, tight titles use `--pos-letter-spacing-tight`, keypad keys use `--pos-shadow-key`, and menus use `--pos-shadow-overlay`. The masked PIN keeps `--pos-letter-spacing-display`.
57
-
58
- 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.
59
-
60
- ## Tokens gallery
61
-
62
- ```ts
63
- import { Component } from '@angular/core';
64
- import { PosTokens } from '@retalia/pos-components';
65
-
66
- @Component({
67
- selector: 'app-theme-lab',
68
- imports: [PosTokens],
69
- template: `<pos-tokens />`,
70
- })
71
- export class ThemeLab {}
72
- ```
73
-
74
- Host the exported file **after** the package stylesheet so `--pos-*` overrides win:
75
-
76
- ```text
77
- node_modules/@retalia/pos-components/styles/styles.css
78
- src/styles/pos-theme.css
79
- ```
80
-
81
- | | |
82
- | -------- | ------------------------------------------------------------ |
83
- | Selector | `pos-tokens` |
84
- | Purpose | Documentation / theme lab — not for production POS screens |
85
- | Export | Complete `:root { --pos-*: … }` CSS for the host POS |
86
- | Import | Previously exported theme CSS, applied as lab overrides only |
87
-
88
- ## Login
89
-
90
- Keypad login UI — presentational only. It does not call auth services.
91
-
92
- 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.
93
-
94
- ```ts
95
- import { Component, signal } from '@angular/core';
96
- import { PosLogin, PosLoginIntent, PosLoginSessionState } from '@retalia/pos-components';
97
-
98
- @Component({
99
- selector: 'app-root',
100
- imports: [PosLogin],
101
- template: `
102
- <pos-login
103
- [session]="session()"
104
- [errorMessage]="errorMessage()"
105
- (action)="onLoginAction($event)"
106
- />
107
- `,
108
- })
109
- export class App {
110
- readonly session = signal<PosLoginSessionState>({ status: 'signedOut' });
111
- readonly errorMessage = signal('');
112
-
113
- onLoginAction(intent: PosLoginIntent): void {
114
- if (intent.type !== 'login.submit') {
115
- return;
116
- }
117
- // Host (or package ClerkAuthService) calls the API, then:
118
- // this.session.set({ status: 'busy' });
119
- // this.session.set({ status: 'signedIn', displayName: clerk.name });
120
- // this.errorMessage.set('Invalid clerk or password');
121
- }
122
- }
123
- ```
124
-
125
- | | |
126
- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
127
- | Selector | `pos-login` |
128
- | Input `session` | Display-only login/session state: `signedOut` \| `busy` \| `signedIn`, optional `displayName` |
129
- | Input `errorMessage` | Optional error text |
130
- | Input `showAside` | Shop context beside the keypad. Default `true`. Set `false` for the keypad alone |
131
- | Input `bordered` | Frame around the login shell. Default `false` |
132
- | Input `showBrandImage`, `brandImage`, `brandLabel` | Same brand rules as the header. `brandLabel` defaults to `RETALIA`. Empty hides the word. Empty `brandImage` uses the built-in mark |
133
- | Input `locationName`, `terminalLine`, `shopName`, `posNumber` | Defaults `Address1`, `Address2`, `shop 1`, and `001`. Empty hides that line or tile |
134
- | Date | Always the PC's local date. `dateLabel` is the caption only |
135
- | Output `action` | Typed intents: `login.key`, `login.next`, `login.submit`, `login.back`, … |
136
-
137
- ## Basket
138
-
139
- Presentational sale basket — renders `basket.lines[]` and `totals` exactly as the host envelope provides them. It does not price, tax, or discount. Quantity and void only emit intents; the host (or server) updates the envelope. Clearing the sale is a transaction action bar control, not a basket button. The host still dispatches `basket.clear`.
140
-
141
- ```ts
142
- import { Component, signal } from '@angular/core';
143
- import {
144
- PosBasket,
145
- PosBasketIntent,
146
- PosBasketLine,
147
- PosBasketTotals,
148
- } from '@retalia/pos-components';
149
-
150
- @Component({
151
- selector: 'app-sale',
152
- imports: [PosBasket],
153
- template: `
154
- <pos-basket [lines]="lines()" [totals]="totals()" (action)="onBasketAction($event)" />
155
- `,
156
- })
157
- export class Sale {
158
- readonly lines = signal<readonly PosBasketLine[]>([]);
159
- readonly totals = signal<PosBasketTotals>({
160
- gross: 0,
161
- net: 0,
162
- vat: 0,
163
- discount: 0,
164
- due: 0,
165
- currency: 'EUR',
166
- });
167
-
168
- onBasketAction(intent: PosBasketIntent): void {
169
- // Host envelope binding maps:
170
- // basket.changeQuantity → change-quantity
171
- // basket.voidLine → void-line
172
- // Clear sale (`basket.clear`) is dispatched from the transaction action bar.
173
- }
174
- }
175
- ```
176
-
177
- | | |
178
- | ---------------- | --------------------------------------------------------------------------------------------------------------------- |
179
- | Selector | `pos-basket` |
180
- | Input `lines` | Envelope `basket.lines[]` as given (`lineId`, `sku`, `name`, `qty`, `unitPrice`, `lineTotal`, `vat`, `struckThrough`) |
181
- | Input `totals` | Envelope `totals` as given (`gross`, `net`, `vat`, `discount`, `due`, `currency`) |
182
- | Input `disabled` | Optional lock while the host is busy |
183
- | Input `bordered` | Panel frame. Default `false` |
184
- | Output `action` | Typed intents: `basket.changeQuantity`, `basket.voidLine`. `basket.clear` remains a host intent for the action bar |
185
-
186
- ## Header
187
-
188
- Placeable sale chrome. The host passes the title, shop or other context, and clerk name. The menu beside the clerk initial currently offers sign out only. That click emits an intent; the host clears the session and navigates. The component does not know where it sits in the layout.
189
-
190
- ```html
191
- <pos-header
192
- [clerkName]="clerkName()"
193
- [clerkRole]="clerkRole()"
194
- [disabled]="busy()"
195
- (action)="onHeader($event)"
196
- />
197
- ```
198
-
199
- | | |
200
- | ---------------------- | -------------------------------------------------------------------------------------------------------- |
201
- | Selector | `pos-header` |
202
- | Input `showBrandImage` | Shows an image before the wordmark. Defaults to true. False hides the image, including the built-in mark |
203
- | Input `brandImage` | Image URL. Empty, while the image is shown, uses the built-in Retalia mark |
204
- | Input `brandLabel` | Text after the image. Empty shows no text |
205
- | Input `title` | Screen title. Omitted from the bar when empty |
206
- | Input `context` | Secondary label, such as the shop name |
207
- | Input `clerkName` | Signed-in clerk. The bar shows an initial and the name, and hides the block when empty |
208
- | Input `clerkRole` | Role label under the name, such as Clerk or Manager. Shown in lowercase |
209
- | Input `signOutLabel` | Menu item label. Defaults to `Portal Logout` |
210
- | Input `menuLabel` | Accessible name for the menu button. Defaults to `Menu` |
211
- | Input `canSignOut` | Hides the menu button when false |
212
- | Input `disabled` | Disables sign out while the host is busy |
213
- | Output `action` | `header.signOut` |
214
-
215
- ## Transaction action bar
216
-
217
- Sale commands for the open basket. Cancel and Cash are drawn by the bar, in the danger and success colors, when the envelope lists `clearBasket` or `tender`. The host can still project extra controls into the same row. Cancel emits `actionBar.clearBasket`. The host maps that to `basket.clear`. Cash emits `actionBar.tender`.
218
-
219
- ```html
220
- <pos-transaction-action-bar
221
- [availableActions]="availableActions()"
222
- [disabled]="busy()"
223
- (action)="onActionBar($event)"
224
- >
225
- <button type="button" (click)="onPark()">Park</button>
226
- </pos-transaction-action-bar>
227
- ```
228
-
229
- | | |
230
- | ----------------------- | -------------------------------------------------------------------------- |
231
- | Selector | `pos-transaction-action-bar` |
232
- | Input `availableActions` | `clearBasket` shows Cancel. `tender` shows Cash |
233
- | Input `disabled` | Disables Cancel and Cash while the host is busy |
234
- | Output `action` | `actionBar.clearBasket` or `actionBar.tender` |
235
- | Content | Optional extra controls, laid out in the same wrapping row |
236
-
237
- ## Item entry
238
-
239
- 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.
240
-
241
- ```ts
242
- import { Component, signal } from '@angular/core';
243
- import { PosItemEntry, PosItemEntryIntent } from '@retalia/pos-components';
244
-
245
- @Component({
246
- selector: 'app-sale',
247
- imports: [PosItemEntry],
248
- template: `
249
- <pos-item-entry
250
- [disabled]="busy()"
251
- [errorMessage]="errorMessage()"
252
- (action)="onItemEntryAction($event)"
253
- />
254
- `,
255
- })
256
- export class Sale {
257
- readonly busy = signal(false);
258
- readonly errorMessage = signal('');
259
-
260
- onItemEntryAction(intent: PosItemEntryIntent): void {
261
- // Host envelope binding maps:
262
- // itemEntry.submit → item-entry (payload: reference)
263
- }
264
- }
265
- ```
266
-
267
- | | |
268
- | --------------------- | ------------------------------------------------------------------------------------------------------ |
269
- | Selector | `pos-item-entry` |
270
- | Input `disabled` | Optional lock while the host is busy (e.g. AddItem in flight) |
271
- | Input `bordered` | Panel frame. Default `false` |
272
- | Input `errorMessage` | Optional host-provided error text (lookup failures belong to the host) |
273
- | Input `label` | Accessible name for the field. Default `Item`. Not shown on the bar |
274
- | Input `autofocus` | Focus the field on render so a scanner can type immediately (default `true`) |
275
- | Input `placeholder` | Field hint. Default `Scan barcode, search products` |
276
- | Input `scannerStatus` | Status in the chip at the right of the field. Default `scanner ready` |
277
- | Input `focusHotkey` | Key that focuses the field, shown beside the chip. Default `F2`. Empty hides the hint and the shortcut |
278
- | Output `action` | Typed intent: `itemEntry.submit` with `reference` |
279
-
280
- Enter in the field submits the reference. `F2` moves focus back to the field from anywhere else on the page, matching the POS proof of concept. The label is the accessible name and is not shown on the bar.
281
-
282
- ## Workspace
283
-
284
- Logged-in sale shell. It places the header, item lookup, basket, and action bar. The default stack matches the sale screen: header, item lookup, a flexible basket, then the action bar. The host still owns each region's state and intents; the workspace only arranges them.
285
-
286
- `editing` draws a Components menu above the screen, plus a dashed outline, a drag handle, a settings control, and a height bar on each region that is placed. Open the menu and drag a component onto the screen, or press it, and it stacks in the left column. The menu scrolls, so a long list stays usable. Drag those panes after that to move them. While one is moving, the right side is highlighted. Drop on the right side and that region docks there at full height. Drop a pane above that docked column to span both columns. Full width is also a setting. A pane fills leftover height only when Flexible height is on, which sets its weight above 0. Weight 0 stays the content height. Visible hides that pane on the till and keeps it in the editor so it can be shown again. The X removes that pane: a library component goes back to the Components menu, and an empty slot is deleted. `fullWidth: true` and `span: 'top'` are the top placement. `span: 'bottom'` is the bottom placement. Drag the bar under a region to share leftover height, or the divider between columns to change the split. Arrow keys do the same from the keyboard. Undo restores the previous arrangement. Default layout puts the sale stack back: header, item lookup, basket, then the action bar. New clears every region off the screen and returns the library components to the Components menu. The arrangement used when the host does not pass a layout is still that sale stack.
287
-
288
- Custom container adds the next slot, `empty-1`, then `empty-2`, and so on. On screen they are Custom container 1, Custom container 2, and so on. That slot is only a space. The consuming POS fills it by projecting a pane with the same id. Until that content is projected, the slot keeps the custom-container name. A region that is not listed in `layout.panes` stays off the screen, including while editing.
289
-
290
- `layoutChange` reports the arrangement when a component is added, moved, resized, removed, or the layout is reset. This package does not save it. Export layout downloads that arrangement as `pos-workspace-layout.json` and emits `layoutExport`. Import layout reads that same file and applies it. Pass the object back as `layout` from the consuming POS, or call `parseWorkspaceLayout` on the file text. On a narrow screen the regions stack in one column, with the header kept at the top and the action bar at the bottom.
291
-
292
- ```html
293
- <pos-workspace [editing]="editing()" (layoutChange)="onLayout($event)">
294
- <pos-workspace-pane paneId="header">
295
- <pos-header [clerkName]="clerkName()" (action)="onHeader($event)" />
296
- </pos-workspace-pane>
297
- <pos-workspace-pane paneId="itemEntry">
298
- <pos-item-entry (action)="onItemEntry($event)" />
299
- </pos-workspace-pane>
300
- <pos-workspace-pane paneId="basket">
301
- <pos-basket [lines]="lines()" [totals]="totals()" (action)="onBasket($event)" />
302
- </pos-workspace-pane>
303
- <pos-workspace-pane paneId="actionBar">
304
- <pos-transaction-action-bar
305
- [availableActions]="availableActions()"
306
- (action)="onActionBar($event)"
307
- />
308
- </pos-workspace-pane>
309
- <pos-workspace-pane paneId="empty-1">
310
- <app-custom-panel />
311
- </pos-workspace-pane>
312
- </pos-workspace>
313
- ```
314
-
315
- | | |
316
- | --------------------- | ------------------------------------------------------------------------ |
317
- | Selector | `pos-workspace` |
318
- | Input `editing` | Shows outlines, drag handles, and resize controls. Default `false` |
319
- | Input `layout` | Optional starting arrangement. Default is the sale stack |
320
- | Output `layoutChange` | The arrangement after an add, move, resize, remove, or reset. Not stored |
321
- | Output `layoutExport` | The arrangement from Export layout, ready to pass back as `layout` |
322
- | `parseWorkspaceLayout` | Reads an exported layout file back into a `layout` value |
323
- | Pane `paneId` | `header`, `itemEntry`, `basket`, `actionBar`, or `empty-1`, `empty-2`, … |
324
- | Pane `fullWidth` | On a placement in `layout`. Spans both columns above the split |
325
- | Pane `span` | `top` or `bottom`. Spans both columns on that edge of the split |
326
- | Pane `weight` | `0` sizes to content. Above `0`, the pane shares leftover height |
327
- | Pane `visible` | Omitted or `true` shows the pane. `false` hides it on the till |
328
-
329
- ## Tender
330
-
331
- Presentational cash tender — coins, notes, and quick amounts build the amount given. Pay Now emits `tender.cash`. Cancel emits `tender.cancel`. The host envelope sends the cash intent and passes back `changeDue`. The component never calculates change or takes payment. Card / electronic tender is out of scope.
332
-
333
- ```ts
334
- import { Component, signal } from '@angular/core';
335
- import { PosTender, PosTenderIntent } from '@retalia/pos-components';
336
-
337
- @Component({
338
- selector: 'app-tender',
339
- imports: [PosTender],
340
- template: `
341
- <pos-tender
342
- [amountDue]="amountDue()"
343
- [changeDue]="changeDue()"
344
- [currency]="currency()"
345
- [disabled]="busy()"
346
- [errorMessage]="errorMessage()"
347
- (action)="onTenderAction($event)"
348
- />
349
- `,
350
- })
351
- export class Tender {
352
- readonly amountDue = signal(12);
353
- readonly changeDue = signal<number | null>(null);
354
- readonly currency = signal('EUR');
355
- readonly busy = signal(false);
356
- readonly errorMessage = signal('');
357
-
358
- onTenderAction(intent: PosTenderIntent): void {
359
- if (intent.type !== 'tender.cash') {
360
- return;
361
- }
362
- // Host envelope binding maps tender.cash → tender intent,
363
- // then sets changeDue from the envelope response.
364
- // this.busy.set(true);
365
- // this.changeDue.set(envelope.changeDue);
366
- }
367
- }
368
- ```
369
-
370
- | | |
371
- | -------------------- | -------------------------------------------------------------------------------------------------- |
372
- | Selector | `pos-tender` |
373
- | Input `amountDue` | Envelope amount due as given — display only |
374
- | Input `changeDue` | Envelope change due as given (`null` until the host responds). Shown as received, never calculated |
375
- | Input `currency` | ISO currency code used only for display formatting |
376
- | Input `disabled` | Optional lock while the host is busy |
377
- | Input `errorMessage` | Optional error text |
378
- | Output `action` | `tender.cash` with the amount given, or `tender.cancel` |
379
-
380
- ## Receipt
381
-
382
- 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.
383
-
384
- ```ts
385
- import { Component, signal } from '@angular/core';
386
- import {
387
- PosReceipt,
388
- PosReceiptIntent,
389
- PosReceiptLine,
390
- PosReceiptPayment,
391
- PosReceiptTotals,
392
- PosReceiptDocument,
393
- } from '@retalia/pos-components';
394
-
395
- @Component({
396
- selector: 'app-receipt',
397
- imports: [PosReceipt],
398
- template: `
399
- <pos-receipt
400
- [lines]="lines()"
401
- [totals]="totals()"
402
- [payments]="payments()"
403
- [document]="document()"
404
- (action)="onReceiptAction($event)"
405
- />
406
- `,
407
- })
408
- export class Receipt {
409
- readonly lines = signal<readonly PosReceiptLine[]>([]);
410
- readonly totals = signal<PosReceiptTotals>({
411
- gross: 0,
412
- net: 0,
413
- vat: 0,
414
- discount: 0,
415
- due: 0,
416
- currency: 'EUR',
417
- });
418
- readonly payments = signal<readonly PosReceiptPayment[]>([]);
419
- readonly document = signal<PosReceiptDocument>({
420
- number: '',
421
- receiptReady: false,
422
- });
423
-
424
- onReceiptAction(intent: PosReceiptIntent): void {
425
- if (intent.type !== 'receipt.newSale') {
426
- return;
427
- }
428
- // Host envelope binding maps receipt.newSale → newSale.
429
- }
430
- }
431
- ```
432
-
433
- | | |
434
- | ---------------- | --------------------------------------------------------------------------------------------------------------------- |
435
- | Selector | `pos-receipt` |
436
- | Input `lines` | Envelope `basket.lines[]` as given (`lineId`, `sku`, `name`, `qty`, `unitPrice`, `lineTotal`, `vat`, `struckThrough`) |
437
- | Input `totals` | Envelope `totals` as given (`gross`, `net`, `vat`, `discount`, `due`, `currency`) |
438
- | Input `payments` | Envelope `payments[]` as given (`tender`, `amount`, `tendered`, `change`, `state`) |
439
- | Input `document` | Envelope `document` as given (`number`, `receiptReady`) |
440
- | Input `disabled` | Optional lock while the host is busy |
441
- | Input `bordered` | Panel frame. Default `false` |
442
- | Output `action` | Typed intent: `receipt.newSale` |
1
+ # @retalia/pos-components
2
+
3
+ Presentational Adaptive POS Angular components. State in, typed intents out — no service calls.
4
+
5
+ API clients live in **`@retalia/pos-sdk`**. Components never import that package. The host calls the SDK, then passes display state into the UI.
6
+
7
+ Requires **Angular 21** (`@angular/core` and `@angular/common` `^21.2.0`).
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm i @retalia/pos-components
13
+ npm i @retalia/pos-sdk
14
+ ```
15
+
16
+ ## Styles
17
+
18
+ Add the package stylesheet once in the host app (e.g. `angular.json` `styles`):
19
+
20
+ ```text
21
+ node_modules/@retalia/pos-components/styles/styles.css
22
+ ```
23
+
24
+ Theme 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. Brand wordmarks use `--pos-letter-spacing-brand`, tight titles use `--pos-letter-spacing-tight`, keypad keys use `--pos-shadow-key`, and menus use `--pos-shadow-overlay`. The masked PIN keeps `--pos-letter-spacing-display`.
25
+
26
+ 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.
27
+
28
+ ## Tokens gallery
29
+
30
+ ```ts
31
+ import { Component } from '@angular/core';
32
+ import { PosTokens } from '@retalia/pos-components';
33
+
34
+ @Component({
35
+ selector: 'app-theme-lab',
36
+ imports: [PosTokens],
37
+ template: `<pos-tokens />`,
38
+ })
39
+ export class ThemeLab {}
40
+ ```
41
+
42
+ Host the exported file **after** the package stylesheet so `--pos-*` overrides win:
43
+
44
+ ```text
45
+ node_modules/@retalia/pos-components/styles/styles.css
46
+ src/styles/pos-theme.css
47
+ ```
48
+
49
+ | | |
50
+ | -------- | ------------------------------------------------------------ |
51
+ | Selector | `pos-tokens` |
52
+ | Purpose | Documentation / theme lab — not for production POS screens |
53
+ | Export | Complete `:root { --pos-*: … }` CSS for the host POS |
54
+ | Import | Previously exported theme CSS, applied as lab overrides only |
55
+
56
+ ## Login
57
+
58
+ Keypad login UI — presentational only. It does not call auth services.
59
+
60
+ 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.
61
+
62
+ ```ts
63
+ import { Component, signal } from '@angular/core';
64
+ import { PosLogin, PosLoginIntent, PosLoginSessionState } from '@retalia/pos-components';
65
+
66
+ @Component({
67
+ selector: 'app-root',
68
+ imports: [PosLogin],
69
+ template: `
70
+ <pos-login
71
+ [session]="session()"
72
+ [errorMessage]="errorMessage()"
73
+ (action)="onLoginAction($event)"
74
+ />
75
+ `,
76
+ })
77
+ export class App {
78
+ readonly session = signal<PosLoginSessionState>({ status: 'signedOut' });
79
+ readonly errorMessage = signal('');
80
+
81
+ onLoginAction(intent: PosLoginIntent): void {
82
+ if (intent.type !== 'login.submit') {
83
+ return;
84
+ }
85
+ // Host (or @retalia/pos-sdk ClerkAuthService) calls the API, then:
86
+ // this.session.set({ status: 'busy' });
87
+ // this.session.set({ status: 'signedIn', displayName: clerk.name });
88
+ // this.errorMessage.set('Invalid clerk or password');
89
+ }
90
+ }
91
+ ```
92
+
93
+ | | |
94
+ | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
95
+ | Selector | `pos-login` |
96
+ | Input `session` | Display-only login/session state: `signedOut` \| `busy` \| `signedIn`, optional `displayName` |
97
+ | Input `errorMessage` | Optional error text |
98
+ | Input `showAside` | Shop context beside the keypad. Default `true`. Set `false` for the keypad alone |
99
+ | Input `bordered` | Frame around the login shell. Default `false` |
100
+ | Input `showBrandImage`, `brandImage`, `brandLabel` | Same brand rules as the header. `brandLabel` defaults to `RETALIA`. Empty hides the word. Empty `brandImage` uses the built-in mark |
101
+ | Input `locationName`, `terminalLine`, `shopName`, `posNumber` | Defaults `Address1`, `Address2`, `shop 1`, and `001`. Empty hides that line or tile |
102
+ | Date | Always the PC's local date. `dateLabel` is the caption only |
103
+ | Output `action` | Typed intents: `login.key`, `login.next`, `login.submit`, `login.back`, … |
104
+
105
+ ## Basket
106
+
107
+ Presentational sale basket — renders `basket.lines[]` and `totals` exactly as the host envelope provides them. It does not price, tax, or discount. Quantity and void only emit intents; the host (or server) updates the envelope. Clearing the sale is a transaction action bar control, not a basket button. The host still dispatches `basket.clear`.
108
+
109
+ ```ts
110
+ import { Component, signal } from '@angular/core';
111
+ import {
112
+ PosBasket,
113
+ PosBasketIntent,
114
+ PosBasketLine,
115
+ PosBasketTotals,
116
+ } from '@retalia/pos-components';
117
+
118
+ @Component({
119
+ selector: 'app-sale',
120
+ imports: [PosBasket],
121
+ template: `
122
+ <pos-basket [lines]="lines()" [totals]="totals()" (action)="onBasketAction($event)" />
123
+ `,
124
+ })
125
+ export class Sale {
126
+ readonly lines = signal<readonly PosBasketLine[]>([]);
127
+ readonly totals = signal<PosBasketTotals>({
128
+ gross: 0,
129
+ net: 0,
130
+ vat: 0,
131
+ discount: 0,
132
+ due: 0,
133
+ currency: 'EUR',
134
+ });
135
+
136
+ onBasketAction(intent: PosBasketIntent): void {
137
+ // Host envelope binding maps:
138
+ // basket.changeQuantity → change-quantity
139
+ // basket.voidLine → void-line
140
+ // Clear sale (`basket.clear`) is dispatched from the transaction action bar.
141
+ }
142
+ }
143
+ ```
144
+
145
+ | | |
146
+ | ---------------- | --------------------------------------------------------------------------------------------------------------------- |
147
+ | Selector | `pos-basket` |
148
+ | Input `lines` | Envelope `basket.lines[]` as given (`lineId`, `sku`, `name`, `qty`, `unitPrice`, `lineTotal`, `vat`, `struckThrough`) |
149
+ | Input `totals` | Envelope `totals` as given (`gross`, `net`, `vat`, `discount`, `due`, `currency`) |
150
+ | Input `disabled` | Optional lock while the host is busy |
151
+ | Input `bordered` | Panel frame. Default `false` |
152
+ | Output `action` | Typed intents: `basket.changeQuantity`, `basket.voidLine`. `basket.clear` remains a host intent for the action bar |
153
+
154
+ ## Header
155
+
156
+ Placeable sale chrome. The host passes the title, shop or other context, and clerk name. The menu beside the clerk initial currently offers sign out only. That click emits an intent; the host clears the session and navigates. The component does not know where it sits in the layout.
157
+
158
+ ```html
159
+ <pos-header
160
+ [clerkName]="clerkName()"
161
+ [clerkRole]="clerkRole()"
162
+ [disabled]="busy()"
163
+ (action)="onHeader($event)"
164
+ />
165
+ ```
166
+
167
+ | | |
168
+ | ---------------------- | -------------------------------------------------------------------------------------------------------- |
169
+ | Selector | `pos-header` |
170
+ | Input `showBrandImage` | Shows an image before the wordmark. Defaults to true. False hides the image, including the built-in mark |
171
+ | Input `brandImage` | Image URL. Empty, while the image is shown, uses the built-in Retalia mark |
172
+ | Input `brandLabel` | Text after the image. Empty shows no text |
173
+ | Input `title` | Screen title. Omitted from the bar when empty |
174
+ | Input `context` | Secondary label, such as the shop name |
175
+ | Input `clerkName` | Signed-in clerk. The bar shows an initial and the name, and hides the block when empty |
176
+ | Input `clerkRole` | Role label under the name, such as Clerk or Manager. Shown in lowercase |
177
+ | Input `signOutLabel` | Menu item label. Defaults to `Portal Logout` |
178
+ | Input `menuLabel` | Accessible name for the menu button. Defaults to `Menu` |
179
+ | Input `canSignOut` | Hides the menu button when false |
180
+ | Input `disabled` | Disables sign out while the host is busy |
181
+ | Output `action` | `header.signOut` |
182
+
183
+ ## Transaction action bar
184
+
185
+ Sale commands for the open basket. Cancel and Cash are drawn by the bar, in the danger and success colors, when the envelope lists `clearBasket` or `tender`. The host can still project extra controls into the same row. Cancel emits `actionBar.clearBasket`. The host maps that to `basket.clear`. Cash emits `actionBar.tender`.
186
+
187
+ ```html
188
+ <pos-transaction-action-bar
189
+ [availableActions]="availableActions()"
190
+ [disabled]="busy()"
191
+ (action)="onActionBar($event)"
192
+ >
193
+ <button type="button" (click)="onPark()">Park</button>
194
+ </pos-transaction-action-bar>
195
+ ```
196
+
197
+ | | |
198
+ | ----------------------- | -------------------------------------------------------------------------- |
199
+ | Selector | `pos-transaction-action-bar` |
200
+ | Input `availableActions` | `clearBasket` shows Cancel. `tender` shows Cash |
201
+ | Input `disabled` | Disables Cancel and Cash while the host is busy |
202
+ | Output `action` | `actionBar.clearBasket` or `actionBar.tender` |
203
+ | Content | Optional extra controls, laid out in the same wrapping row |
204
+
205
+ ## Item entry
206
+
207
+ 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.
208
+
209
+ ```ts
210
+ import { Component, signal } from '@angular/core';
211
+ import { PosItemEntry, PosItemEntryIntent } from '@retalia/pos-components';
212
+
213
+ @Component({
214
+ selector: 'app-sale',
215
+ imports: [PosItemEntry],
216
+ template: `
217
+ <pos-item-entry
218
+ [disabled]="busy()"
219
+ [errorMessage]="errorMessage()"
220
+ (action)="onItemEntryAction($event)"
221
+ />
222
+ `,
223
+ })
224
+ export class Sale {
225
+ readonly busy = signal(false);
226
+ readonly errorMessage = signal('');
227
+
228
+ onItemEntryAction(intent: PosItemEntryIntent): void {
229
+ // Host envelope binding maps:
230
+ // itemEntry.submit → item-entry (payload: reference)
231
+ }
232
+ }
233
+ ```
234
+
235
+ | | |
236
+ | --------------------- | ------------------------------------------------------------------------------------------------------ |
237
+ | Selector | `pos-item-entry` |
238
+ | Input `disabled` | Optional lock while the host is busy (e.g. AddItem in flight) |
239
+ | Input `bordered` | Panel frame. Default `false` |
240
+ | Input `errorMessage` | Optional host-provided error text (lookup failures belong to the host) |
241
+ | Input `label` | Accessible name for the field. Default `Item`. Not shown on the bar |
242
+ | Input `autofocus` | Focus the field on render so a scanner can type immediately (default `true`) |
243
+ | Input `placeholder` | Field hint. Default `Scan barcode, search products` |
244
+ | Input `scannerStatus` | Status in the chip at the right of the field. Default `scanner ready` |
245
+ | Input `focusHotkey` | Key that focuses the field, shown beside the chip. Default `F2`. Empty hides the hint and the shortcut |
246
+ | Output `action` | Typed intent: `itemEntry.submit` with `reference` |
247
+
248
+ Enter in the field submits the reference. `F2` moves focus back to the field from anywhere else on the page, matching the POS proof of concept. The label is the accessible name and is not shown on the bar.
249
+
250
+ ## Workspace
251
+
252
+ Logged-in sale shell. It places the header, item lookup, basket, and action bar. The default stack matches the sale screen: header, item lookup, a flexible basket, then the action bar. The host still owns each region's state and intents; the workspace only arranges them.
253
+
254
+ `editing` draws a Components menu above the screen, plus a dashed outline, a drag handle, a settings control, and a height bar on each region that is placed. Open the menu and drag a component onto the screen, or press it, and it stacks in the left column. The menu scrolls, so a long list stays usable. Drag those panes after that to move them. While one is moving, the right side is highlighted. Drop on the right side and that region docks there at full height. Drop a pane above that docked column to span both columns. Full width is also a setting. A pane fills leftover height only when Flexible height is on, which sets its weight above 0. Weight 0 stays the content height. Visible hides that pane on the till and keeps it in the editor so it can be shown again. The X removes that pane: a library component goes back to the Components menu, and an empty slot is deleted. `fullWidth: true` and `span: 'top'` are the top placement. `span: 'bottom'` is the bottom placement. Drag the bar under a region to share leftover height, or the divider between columns to change the split. Arrow keys do the same from the keyboard. Undo restores the previous arrangement. Default layout puts the sale stack back: header, item lookup, basket, then the action bar. New clears every region off the screen and returns the library components to the Components menu. The arrangement used when the host does not pass a layout is still that sale stack.
255
+
256
+ Custom container adds the next slot, `empty-1`, then `empty-2`, and so on. On screen they are Custom container 1, Custom container 2, and so on. That slot is only a space. The consuming POS fills it by projecting a pane with the same id. Until that content is projected, the slot keeps the custom-container name. A region that is not listed in `layout.panes` stays off the screen, including while editing.
257
+
258
+ `layoutChange` reports the arrangement when a component is added, moved, resized, removed, or the layout is reset. This package does not save it. Export layout downloads that arrangement as `pos-workspace-layout.json` and emits `layoutExport`. Import layout reads that same file and applies it. Pass the object back as `layout` from the consuming POS, or call `parseWorkspaceLayout` on the file text. On a narrow screen the regions stack in one column, with the header kept at the top and the action bar at the bottom.
259
+
260
+ ```html
261
+ <pos-workspace [editing]="editing()" (layoutChange)="onLayout($event)">
262
+ <pos-workspace-pane paneId="header">
263
+ <pos-header [clerkName]="clerkName()" (action)="onHeader($event)" />
264
+ </pos-workspace-pane>
265
+ <pos-workspace-pane paneId="itemEntry">
266
+ <pos-item-entry (action)="onItemEntry($event)" />
267
+ </pos-workspace-pane>
268
+ <pos-workspace-pane paneId="basket">
269
+ <pos-basket [lines]="lines()" [totals]="totals()" (action)="onBasket($event)" />
270
+ </pos-workspace-pane>
271
+ <pos-workspace-pane paneId="actionBar">
272
+ <pos-transaction-action-bar
273
+ [availableActions]="availableActions()"
274
+ (action)="onActionBar($event)"
275
+ />
276
+ </pos-workspace-pane>
277
+ <pos-workspace-pane paneId="empty-1">
278
+ <app-custom-panel />
279
+ </pos-workspace-pane>
280
+ </pos-workspace>
281
+ ```
282
+
283
+ | | |
284
+ | --------------------- | ------------------------------------------------------------------------ |
285
+ | Selector | `pos-workspace` |
286
+ | Input `editing` | Shows outlines, drag handles, and resize controls. Default `false` |
287
+ | Input `layout` | Optional starting arrangement. Default is the sale stack |
288
+ | Output `layoutChange` | The arrangement after an add, move, resize, remove, or reset. Not stored |
289
+ | Output `layoutExport` | The arrangement from Export layout, ready to pass back as `layout` |
290
+ | `parseWorkspaceLayout` | Reads an exported layout file back into a `layout` value |
291
+ | Pane `paneId` | `header`, `itemEntry`, `basket`, `actionBar`, or `empty-1`, `empty-2`, … |
292
+ | Pane `fullWidth` | On a placement in `layout`. Spans both columns above the split |
293
+ | Pane `span` | `top` or `bottom`. Spans both columns on that edge of the split |
294
+ | Pane `weight` | `0` sizes to content. Above `0`, the pane shares leftover height |
295
+ | Pane `visible` | Omitted or `true` shows the pane. `false` hides it on the till |
296
+
297
+ ## Tender
298
+
299
+ Presentational cash tender — coins, notes, and quick amounts build the amount given. Pay Now emits `tender.cash`. Cancel emits `tender.cancel`. The host envelope sends the cash intent and passes back `changeDue`. The component never calculates change or takes payment. Card / electronic tender is out of scope.
300
+
301
+ ```ts
302
+ import { Component, signal } from '@angular/core';
303
+ import { PosTender, PosTenderIntent } from '@retalia/pos-components';
304
+
305
+ @Component({
306
+ selector: 'app-tender',
307
+ imports: [PosTender],
308
+ template: `
309
+ <pos-tender
310
+ [amountDue]="amountDue()"
311
+ [changeDue]="changeDue()"
312
+ [currency]="currency()"
313
+ [disabled]="busy()"
314
+ [errorMessage]="errorMessage()"
315
+ (action)="onTenderAction($event)"
316
+ />
317
+ `,
318
+ })
319
+ export class Tender {
320
+ readonly amountDue = signal(12);
321
+ readonly changeDue = signal<number | null>(null);
322
+ readonly currency = signal('EUR');
323
+ readonly busy = signal(false);
324
+ readonly errorMessage = signal('');
325
+
326
+ onTenderAction(intent: PosTenderIntent): void {
327
+ if (intent.type !== 'tender.cash') {
328
+ return;
329
+ }
330
+ // Host envelope binding maps tender.cash → tender intent,
331
+ // then sets changeDue from the envelope response.
332
+ // this.busy.set(true);
333
+ // this.changeDue.set(envelope.changeDue);
334
+ }
335
+ }
336
+ ```
337
+
338
+ | | |
339
+ | -------------------- | -------------------------------------------------------------------------------------------------- |
340
+ | Selector | `pos-tender` |
341
+ | Input `amountDue` | Envelope amount due as given — display only |
342
+ | Input `changeDue` | Envelope change due as given (`null` until the host responds). Shown as received, never calculated |
343
+ | Input `currency` | ISO currency code used only for display formatting |
344
+ | Input `disabled` | Optional lock while the host is busy |
345
+ | Input `errorMessage` | Optional error text |
346
+ | Output `action` | `tender.cash` with the amount given, or `tender.cancel` |
347
+
348
+ ## Receipt
349
+
350
+ 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.
351
+
352
+ ```ts
353
+ import { Component, signal } from '@angular/core';
354
+ import {
355
+ PosReceipt,
356
+ PosReceiptIntent,
357
+ PosReceiptLine,
358
+ PosReceiptPayment,
359
+ PosReceiptTotals,
360
+ PosReceiptDocument,
361
+ } from '@retalia/pos-components';
362
+
363
+ @Component({
364
+ selector: 'app-receipt',
365
+ imports: [PosReceipt],
366
+ template: `
367
+ <pos-receipt
368
+ [lines]="lines()"
369
+ [totals]="totals()"
370
+ [payments]="payments()"
371
+ [document]="document()"
372
+ (action)="onReceiptAction($event)"
373
+ />
374
+ `,
375
+ })
376
+ export class Receipt {
377
+ readonly lines = signal<readonly PosReceiptLine[]>([]);
378
+ readonly totals = signal<PosReceiptTotals>({
379
+ gross: 0,
380
+ net: 0,
381
+ vat: 0,
382
+ discount: 0,
383
+ due: 0,
384
+ currency: 'EUR',
385
+ });
386
+ readonly payments = signal<readonly PosReceiptPayment[]>([]);
387
+ readonly document = signal<PosReceiptDocument>({
388
+ number: '',
389
+ receiptReady: false,
390
+ });
391
+
392
+ onReceiptAction(intent: PosReceiptIntent): void {
393
+ if (intent.type !== 'receipt.newSale') {
394
+ return;
395
+ }
396
+ // Host envelope binding maps receipt.newSale → newSale.
397
+ }
398
+ }
399
+ ```
400
+
401
+ | | |
402
+ | ---------------- | --------------------------------------------------------------------------------------------------------------------- |
403
+ | Selector | `pos-receipt` |
404
+ | Input `lines` | Envelope `basket.lines[]` as given (`lineId`, `sku`, `name`, `qty`, `unitPrice`, `lineTotal`, `vat`, `struckThrough`) |
405
+ | Input `totals` | Envelope `totals` as given (`gross`, `net`, `vat`, `discount`, `due`, `currency`) |
406
+ | Input `payments` | Envelope `payments[]` as given (`tender`, `amount`, `tendered`, `change`, `state`) |
407
+ | Input `document` | Envelope `document` as given (`number`, `receiptReady`) |
408
+ | Input `disabled` | Optional lock while the host is busy |
409
+ | Input `bordered` | Panel frame. Default `false` |
410
+ | Output `action` | Typed intent: `receipt.newSale` |