@retalia/pos-components 0.0.2 → 0.0.4

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.
Files changed (46) hide show
  1. package/README.md +303 -44
  2. package/eslint.config.js +32 -0
  3. package/ng-package.json +14 -0
  4. package/package.json +17 -28
  5. package/src/lib/basket/pos-basket.html +124 -0
  6. package/src/lib/basket/pos-basket.scss +285 -0
  7. package/src/lib/basket/pos-basket.spec.ts +198 -0
  8. package/src/lib/basket/pos-basket.stories.ts +114 -0
  9. package/src/lib/basket/pos-basket.ts +142 -0
  10. package/src/lib/item-entry/pos-item-entry.html +41 -0
  11. package/src/lib/item-entry/pos-item-entry.scss +145 -0
  12. package/src/lib/item-entry/pos-item-entry.spec.ts +123 -0
  13. package/src/lib/item-entry/pos-item-entry.stories.ts +31 -0
  14. package/src/lib/item-entry/pos-item-entry.ts +81 -0
  15. package/src/lib/login/pos-login.html +82 -0
  16. package/src/lib/login/pos-login.scss +238 -0
  17. package/src/lib/login/pos-login.spec.ts +89 -0
  18. package/src/lib/login/pos-login.stories.ts +36 -0
  19. package/src/lib/login/pos-login.ts +150 -0
  20. package/src/lib/receipt/pos-receipt.html +116 -0
  21. package/src/lib/receipt/pos-receipt.scss +367 -0
  22. package/src/lib/receipt/pos-receipt.spec.ts +188 -0
  23. package/src/lib/receipt/pos-receipt.stories.ts +163 -0
  24. package/src/lib/receipt/pos-receipt.ts +129 -0
  25. package/src/lib/tender/pos-tender.html +79 -0
  26. package/src/lib/tender/pos-tender.scss +318 -0
  27. package/src/lib/tender/pos-tender.spec.ts +150 -0
  28. package/src/lib/tender/pos-tender.stories.ts +47 -0
  29. package/src/lib/tender/pos-tender.ts +154 -0
  30. package/src/lib/tokens/pos-theme-css.spec.ts +75 -0
  31. package/src/lib/tokens/pos-theme-css.ts +123 -0
  32. package/src/lib/tokens/pos-token-catalog.ts +514 -0
  33. package/src/lib/tokens/pos-tokens.html +351 -0
  34. package/src/lib/tokens/pos-tokens.scss +437 -0
  35. package/src/lib/tokens/pos-tokens.spec.ts +164 -0
  36. package/src/lib/tokens/pos-tokens.stories.ts +22 -0
  37. package/src/lib/tokens/pos-tokens.ts +208 -0
  38. package/src/public-api.ts +37 -0
  39. package/src/styles/styles.css +1 -0
  40. package/src/styles/tokens.css +57 -0
  41. package/tsconfig.lib.json +14 -0
  42. package/tsconfig.lib.prod.json +11 -0
  43. package/tsconfig.spec.json +11 -0
  44. package/fesm2022/retalia-pos-components.mjs +0 -97
  45. package/fesm2022/retalia-pos-components.mjs.map +0 -1
  46. package/types/retalia-pos-components.d.ts +0 -16
package/README.md CHANGED
@@ -1,44 +1,303 @@
1
- # @retalia/pos-components
2
-
3
- Presentational Adaptive POS Angular components. State in, typed intents out — no service calls.
4
-
5
- Requires **Angular 21** (`@angular/core` and `@angular/common` `^21.2.0`).
6
-
7
- ## Install
8
-
9
- ```bash
10
- npm i @retalia/pos-components
11
- ```
12
-
13
- ## Hello world
14
-
15
- Import the standalone component and put it in the template.
16
-
17
- ```ts
18
- import { Component } from '@angular/core';
19
- import { PosHelloWorld, HelloWorldIntent } from '@retalia/pos-components';
20
-
21
- @Component({
22
- selector: 'app-root',
23
- imports: [PosHelloWorld],
24
- template: `
25
- <pos-hello-world
26
- [label]="'Library connected'"
27
- (acknowledge)="onAcknowledge($event)"
28
- />
29
- `,
30
- })
31
- export class App {
32
- onAcknowledge(intent: HelloWorldIntent): void {
33
- console.log(intent.type); // 'helloWorld.acknowledge'
34
- }
35
- }
36
- ```
37
-
38
- | | |
39
- |---|---|
40
- | Selector | `pos-hello-world` |
41
- | Input `label` | Heading text (optional) |
42
- | Output `acknowledge` | `{ type: 'helloWorld.acknowledge' }` when the user clicks Acknowledge |
43
-
44
- If the card renders, the package installed and the host can consume components from this library.
1
+ # @retalia/pos-components
2
+
3
+ Presentational Adaptive POS Angular components. State in, typed intents out — no service calls.
4
+
5
+ Requires **Angular 21** (`@angular/core` and `@angular/common` `^21.2.0`).
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm i @retalia/pos-components
11
+ ```
12
+
13
+ ## Styles
14
+
15
+ Add the package stylesheet once in the host app (e.g. `angular.json` `styles`):
16
+
17
+ ```text
18
+ node_modules/@retalia/pos-components/styles/styles.css
19
+ ```
20
+
21
+ 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.
22
+
23
+ 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.
24
+
25
+ ## Tokens gallery
26
+
27
+ ```ts
28
+ import { Component } from '@angular/core';
29
+ import { PosTokens } from '@retalia/pos-components';
30
+
31
+ @Component({
32
+ selector: 'app-theme-lab',
33
+ imports: [PosTokens],
34
+ template: `<pos-tokens />`,
35
+ })
36
+ export class ThemeLab {}
37
+ ```
38
+
39
+ Host the exported file **after** the package stylesheet so `--pos-*` overrides win:
40
+
41
+ ```text
42
+ node_modules/@retalia/pos-components/styles/styles.css
43
+ src/styles/pos-theme.css
44
+ ```
45
+
46
+ | | |
47
+ |---|---|
48
+ | Selector | `pos-tokens` |
49
+ | Purpose | Documentation / theme lab — not for production POS screens |
50
+ | Export | Complete `:root { --pos-*: … }` CSS for the host POS |
51
+ | Import | Previously exported theme CSS, applied as lab overrides only |
52
+
53
+ ## Login
54
+
55
+ 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.
56
+
57
+ 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.
58
+
59
+ ```ts
60
+ import { Component, signal } from '@angular/core';
61
+ import {
62
+ PosLogin,
63
+ PosLoginIntent,
64
+ PosLoginSessionState,
65
+ } from '@retalia/pos-components';
66
+
67
+ @Component({
68
+ selector: 'app-root',
69
+ imports: [PosLogin],
70
+ template: `
71
+ <pos-login
72
+ [session]="session()"
73
+ [errorMessage]="errorMessage()"
74
+ (action)="onLoginAction($event)"
75
+ />
76
+ `,
77
+ })
78
+ export class App {
79
+ readonly session = signal<PosLoginSessionState>({ status: 'signedOut' });
80
+ readonly errorMessage = signal('');
81
+
82
+ onLoginAction(intent: PosLoginIntent): void {
83
+ if (intent.type !== 'login.submit') {
84
+ return;
85
+ }
86
+ // Host (or optional package AuthService) calls the API, then:
87
+ // this.session.set({ status: 'busy' });
88
+ // this.session.set({ status: 'signedIn', displayName: clerk.name });
89
+ // this.errorMessage.set('Invalid clerk or password');
90
+ }
91
+ }
92
+ ```
93
+
94
+ | | |
95
+ |---|---|
96
+ | Selector | `pos-login` |
97
+ | Input `session` | Display-only login/session state: `signedOut` \| `busy` \| `signedIn`, optional `displayName` |
98
+ | Input `errorMessage` | Optional error text |
99
+ | Output `action` | Typed intents: `login.key`, `login.next`, `login.submit`, `login.back`, … |
100
+
101
+ ## Basket
102
+
103
+ 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.
104
+
105
+ ```ts
106
+ import { Component, signal } from '@angular/core';
107
+ import {
108
+ PosBasket,
109
+ PosBasketIntent,
110
+ PosBasketLine,
111
+ PosBasketTotals,
112
+ } from '@retalia/pos-components';
113
+
114
+ @Component({
115
+ selector: 'app-sale',
116
+ imports: [PosBasket],
117
+ template: `
118
+ <pos-basket
119
+ [lines]="lines()"
120
+ [totals]="totals()"
121
+ (action)="onBasketAction($event)"
122
+ />
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
+ // basket.clear → clear-basket
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
+ | Output `action` | Typed intents: `basket.changeQuantity`, `basket.voidLine`, `basket.clear` |
152
+
153
+ ## Item entry
154
+
155
+ 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.
156
+
157
+ ```ts
158
+ import { Component, signal } from '@angular/core';
159
+ import { PosItemEntry, PosItemEntryIntent } from '@retalia/pos-components';
160
+
161
+ @Component({
162
+ selector: 'app-sale',
163
+ imports: [PosItemEntry],
164
+ template: `
165
+ <pos-item-entry
166
+ [disabled]="busy()"
167
+ [errorMessage]="errorMessage()"
168
+ (action)="onItemEntryAction($event)"
169
+ />
170
+ `,
171
+ })
172
+ export class Sale {
173
+ readonly busy = signal(false);
174
+ readonly errorMessage = signal('');
175
+
176
+ onItemEntryAction(intent: PosItemEntryIntent): void {
177
+ // Host envelope binding maps:
178
+ // itemEntry.submit → item-entry (payload: reference)
179
+ }
180
+ }
181
+ ```
182
+
183
+ | | |
184
+ |---|---|
185
+ | Selector | `pos-item-entry` |
186
+ | Input `disabled` | Optional lock while the host is busy (e.g. AddItem in flight) |
187
+ | Input `errorMessage` | Optional host-provided error text (lookup failures belong to the host) |
188
+ | Input `autofocus` | Focus the field on render so a scanner can type immediately (default `true`) |
189
+ | Output `action` | Typed intent: `itemEntry.submit` with `reference` |
190
+
191
+ ## Tender
192
+
193
+ 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.
194
+
195
+ ```ts
196
+ import { Component, signal } from '@angular/core';
197
+ import { PosTender, PosTenderIntent } from '@retalia/pos-components';
198
+
199
+ @Component({
200
+ selector: 'app-tender',
201
+ imports: [PosTender],
202
+ template: `
203
+ <pos-tender
204
+ [amountDue]="amountDue()"
205
+ [changeDue]="changeDue()"
206
+ [currency]="currency()"
207
+ [disabled]="busy()"
208
+ [errorMessage]="errorMessage()"
209
+ (action)="onTenderAction($event)"
210
+ />
211
+ `,
212
+ })
213
+ export class Tender {
214
+ readonly amountDue = signal(12);
215
+ readonly changeDue = signal<number | null>(null);
216
+ readonly currency = signal('EUR');
217
+ readonly busy = signal(false);
218
+ readonly errorMessage = signal('');
219
+
220
+ onTenderAction(intent: PosTenderIntent): void {
221
+ if (intent.type !== 'tender.cash') {
222
+ return;
223
+ }
224
+ // Host envelope binding maps tender.cash → tender intent,
225
+ // then sets changeDue from the envelope response.
226
+ // this.busy.set(true);
227
+ // this.changeDue.set(envelope.changeDue);
228
+ }
229
+ }
230
+ ```
231
+
232
+ | | |
233
+ |---|---|
234
+ | Selector | `pos-tender` |
235
+ | Input `amountDue` | Envelope amount due as given — display only |
236
+ | Input `changeDue` | Envelope change due as given (`null` until the host responds). Shown as received, never calculated |
237
+ | Input `currency` | ISO currency code used only for display formatting |
238
+ | Input `disabled` | Optional lock while the host is busy |
239
+ | Input `errorMessage` | Optional error text |
240
+ | Output `action` | Typed intent: `tender.cash` with the cashier-entered `amount` |
241
+
242
+ ## Receipt
243
+
244
+ 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.
245
+
246
+ ```ts
247
+ import { Component, signal } from '@angular/core';
248
+ import {
249
+ PosReceipt,
250
+ PosReceiptIntent,
251
+ PosReceiptLine,
252
+ PosReceiptPayment,
253
+ PosReceiptTotals,
254
+ PosReceiptDocument,
255
+ } from '@retalia/pos-components';
256
+
257
+ @Component({
258
+ selector: 'app-receipt',
259
+ imports: [PosReceipt],
260
+ template: `
261
+ <pos-receipt
262
+ [lines]="lines()"
263
+ [totals]="totals()"
264
+ [payments]="payments()"
265
+ [document]="document()"
266
+ (action)="onReceiptAction($event)"
267
+ />
268
+ `,
269
+ })
270
+ export class Receipt {
271
+ readonly lines = signal<readonly PosReceiptLine[]>([]);
272
+ readonly totals = signal<PosReceiptTotals>({
273
+ gross: 0,
274
+ net: 0,
275
+ vat: 0,
276
+ discount: 0,
277
+ due: 0,
278
+ currency: 'EUR',
279
+ });
280
+ readonly payments = signal<readonly PosReceiptPayment[]>([]);
281
+ readonly document = signal<PosReceiptDocument>({
282
+ number: '',
283
+ receiptReady: false,
284
+ });
285
+
286
+ onReceiptAction(intent: PosReceiptIntent): void {
287
+ if (intent.type !== 'receipt.newSale') {
288
+ return;
289
+ }
290
+ // Host envelope binding maps receipt.newSale → newSale.
291
+ }
292
+ }
293
+ ```
294
+
295
+ | | |
296
+ |---|---|
297
+ | Selector | `pos-receipt` |
298
+ | Input `lines` | Envelope `basket.lines[]` as given (`lineId`, `sku`, `name`, `qty`, `unitPrice`, `lineTotal`, `vat`, `struckThrough`) |
299
+ | Input `totals` | Envelope `totals` as given (`gross`, `net`, `vat`, `discount`, `due`, `currency`) |
300
+ | Input `payments` | Envelope `payments[]` as given (`tender`, `amount`, `tendered`, `change`, `state`) |
301
+ | Input `document` | Envelope `document` as given (`number`, `receiptReady`) |
302
+ | Input `disabled` | Optional lock while the host is busy |
303
+ | Output `action` | Typed intent: `receipt.newSale` |
@@ -0,0 +1,32 @@
1
+ // @ts-check
2
+ const { defineConfig } = require('eslint/config');
3
+ const rootConfig = require('../../eslint.config.js');
4
+
5
+ module.exports = defineConfig([
6
+ ...rootConfig,
7
+ {
8
+ files: ['**/*.ts'],
9
+ rules: {
10
+ '@angular-eslint/directive-selector': [
11
+ 'error',
12
+ {
13
+ type: 'attribute',
14
+ prefix: 'pos',
15
+ style: 'camelCase',
16
+ },
17
+ ],
18
+ '@angular-eslint/component-selector': [
19
+ 'error',
20
+ {
21
+ type: 'element',
22
+ prefix: 'pos',
23
+ style: 'kebab-case',
24
+ },
25
+ ],
26
+ },
27
+ },
28
+ {
29
+ files: ['**/*.html'],
30
+ rules: {},
31
+ },
32
+ ]);
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
3
+ "dest": "../../dist/pos-components",
4
+ "lib": {
5
+ "entryFile": "src/public-api.ts"
6
+ },
7
+ "assets": [
8
+ {
9
+ "glob": "**/*",
10
+ "input": "src/styles",
11
+ "output": "styles"
12
+ }
13
+ ]
14
+ }
package/package.json CHANGED
@@ -1,28 +1,17 @@
1
- {
2
- "name": "@retalia/pos-components",
3
- "version": "0.0.2",
4
- "publishConfig": {
5
- "access": "public",
6
- "registry": "https://registry.npmjs.org/"
7
- },
8
- "peerDependencies": {
9
- "@angular/common": "^21.2.0",
10
- "@angular/core": "^21.2.0"
11
- },
12
- "dependencies": {
13
- "tslib": "^2.3.0"
14
- },
15
- "sideEffects": false,
16
- "module": "fesm2022/retalia-pos-components.mjs",
17
- "typings": "types/retalia-pos-components.d.ts",
18
- "exports": {
19
- "./package.json": {
20
- "default": "./package.json"
21
- },
22
- ".": {
23
- "types": "./types/retalia-pos-components.d.ts",
24
- "default": "./fesm2022/retalia-pos-components.mjs"
25
- }
26
- },
27
- "type": "module"
28
- }
1
+ {
2
+ "name": "@retalia/pos-components",
3
+ "version": "0.0.4",
4
+ "publishConfig": {
5
+ "access": "public",
6
+ "registry": "https://registry.npmjs.org/"
7
+ },
8
+ "peerDependencies": {
9
+ "@angular/common": "^21.2.0",
10
+ "@angular/core": "^21.2.0"
11
+ },
12
+ "dependencies": {
13
+ "tslib": "^2.3.0"
14
+ },
15
+ "sideEffects": ["**/*.css"],
16
+ "style": "./styles/styles.css"
17
+ }
@@ -0,0 +1,124 @@
1
+ <section
2
+ class="pos-basket__panel"
3
+ [attr.aria-busy]="disabled() ? true : null"
4
+ >
5
+ <header class="pos-basket__header">
6
+ <h2 class="pos-basket__title">{{ title() }}</h2>
7
+ <p class="pos-basket__count">
8
+ {{ lines().length }}
9
+ {{ lines().length === 1 ? 'line' : 'lines' }}
10
+ </p>
11
+ </header>
12
+
13
+ <ul class="pos-basket__lines" aria-label="Basket lines">
14
+ @if (!hasLines()) {
15
+ <li class="pos-basket__empty">{{ emptyMessage() }}</li>
16
+ } @else {
17
+ @for (line of lines(); track line.lineId) {
18
+ <li>
19
+ <article
20
+ class="pos-basket__line"
21
+ [class.pos-basket__line--voided]="line.struckThrough"
22
+ >
23
+ <div class="pos-basket__line-info">
24
+ <h3 class="pos-basket__line-name">{{ line.name }}</h3>
25
+ <p class="pos-basket__line-sku">{{ line.sku }}</p>
26
+ @if (line.struckThrough) {
27
+ <p class="pos-basket__line-voided">Voided</p>
28
+ }
29
+ </div>
30
+
31
+ <div
32
+ class="pos-basket__stepper"
33
+ role="group"
34
+ [attr.aria-label]="'Quantity for ' + line.name"
35
+ >
36
+ <button
37
+ type="button"
38
+ class="pos-basket__qty-btn"
39
+ aria-label="Decrease quantity"
40
+ [disabled]="!canDecrement(line)"
41
+ (click)="onDecrement(line)"
42
+ >
43
+
44
+ </button>
45
+ <span class="pos-basket__qty" aria-live="polite">{{ line.qty }}</span>
46
+ <button
47
+ type="button"
48
+ class="pos-basket__qty-btn"
49
+ aria-label="Increase quantity"
50
+ [disabled]="!canChangeQuantity(line)"
51
+ (click)="onIncrement(line)"
52
+ >
53
+ +
54
+ </button>
55
+ </div>
56
+
57
+ <div class="pos-basket__line-prices">
58
+ <p class="pos-basket__unit-price">{{ formatMoney(line.unitPrice) }}</p>
59
+ <p class="pos-basket__line-total">{{ formatMoney(line.lineTotal) }}</p>
60
+ </div>
61
+
62
+ <button
63
+ type="button"
64
+ class="pos-basket__void"
65
+ [attr.aria-label]="'Void ' + line.name"
66
+ [disabled]="!canVoid(line)"
67
+ (click)="onVoid(line)"
68
+ >
69
+ <svg
70
+ xmlns="http://www.w3.org/2000/svg"
71
+ width="18"
72
+ height="18"
73
+ viewBox="0 0 24 24"
74
+ fill="none"
75
+ aria-hidden="true"
76
+ >
77
+ <path
78
+ d="M6 6l12 12M18 6 6 18"
79
+ stroke="currentColor"
80
+ stroke-width="2"
81
+ stroke-linecap="round"
82
+ />
83
+ </svg>
84
+ </button>
85
+ </article>
86
+ </li>
87
+ }
88
+ }
89
+ </ul>
90
+
91
+ <footer class="pos-basket__footer">
92
+ <dl class="pos-basket__totals" aria-live="polite">
93
+ <div class="pos-basket__total-row">
94
+ <dt>Gross</dt>
95
+ <dd>{{ formatMoney(totals().gross) }}</dd>
96
+ </div>
97
+ <div class="pos-basket__total-row">
98
+ <dt>Net</dt>
99
+ <dd>{{ formatMoney(totals().net) }}</dd>
100
+ </div>
101
+ <div class="pos-basket__total-row">
102
+ <dt>VAT</dt>
103
+ <dd>{{ formatMoney(totals().vat) }}</dd>
104
+ </div>
105
+ <div class="pos-basket__total-row">
106
+ <dt>Discount</dt>
107
+ <dd>{{ formatMoney(totals().discount) }}</dd>
108
+ </div>
109
+ <div class="pos-basket__total-row pos-basket__total-row--due">
110
+ <dt>Due</dt>
111
+ <dd>{{ formatMoney(totals().due) }}</dd>
112
+ </div>
113
+ </dl>
114
+
115
+ <button
116
+ type="button"
117
+ class="pos-basket__clear"
118
+ [disabled]="!canClear()"
119
+ (click)="onClear()"
120
+ >
121
+ {{ clearLabel() }}
122
+ </button>
123
+ </footer>
124
+ </section>