@retalia/pos-components 0.0.3 → 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 (45) hide show
  1. package/eslint.config.js +32 -0
  2. package/ng-package.json +14 -0
  3. package/package.json +17 -31
  4. package/src/lib/basket/pos-basket.html +124 -0
  5. package/src/lib/basket/pos-basket.scss +285 -0
  6. package/src/lib/basket/pos-basket.spec.ts +198 -0
  7. package/src/lib/basket/pos-basket.stories.ts +114 -0
  8. package/src/lib/basket/pos-basket.ts +142 -0
  9. package/src/lib/item-entry/pos-item-entry.html +41 -0
  10. package/src/lib/item-entry/pos-item-entry.scss +145 -0
  11. package/src/lib/item-entry/pos-item-entry.spec.ts +123 -0
  12. package/src/lib/item-entry/pos-item-entry.stories.ts +31 -0
  13. package/src/lib/item-entry/pos-item-entry.ts +81 -0
  14. package/src/lib/login/pos-login.html +82 -0
  15. package/src/lib/login/pos-login.scss +238 -0
  16. package/src/lib/login/pos-login.spec.ts +89 -0
  17. package/src/lib/login/pos-login.stories.ts +36 -0
  18. package/src/lib/login/pos-login.ts +150 -0
  19. package/src/lib/receipt/pos-receipt.html +116 -0
  20. package/src/lib/receipt/pos-receipt.scss +367 -0
  21. package/src/lib/receipt/pos-receipt.spec.ts +188 -0
  22. package/src/lib/receipt/pos-receipt.stories.ts +163 -0
  23. package/src/lib/receipt/pos-receipt.ts +129 -0
  24. package/src/lib/tender/pos-tender.html +79 -0
  25. package/src/lib/tender/pos-tender.scss +318 -0
  26. package/src/lib/tender/pos-tender.spec.ts +150 -0
  27. package/src/lib/tender/pos-tender.stories.ts +47 -0
  28. package/src/lib/tender/pos-tender.ts +154 -0
  29. package/src/lib/tokens/pos-theme-css.spec.ts +75 -0
  30. package/src/lib/tokens/pos-theme-css.ts +123 -0
  31. package/src/lib/tokens/pos-token-catalog.ts +514 -0
  32. package/src/lib/tokens/pos-tokens.html +351 -0
  33. package/src/lib/tokens/pos-tokens.scss +437 -0
  34. package/src/lib/tokens/pos-tokens.spec.ts +164 -0
  35. package/src/lib/tokens/pos-tokens.stories.ts +22 -0
  36. package/src/lib/tokens/pos-tokens.ts +208 -0
  37. package/src/public-api.ts +37 -0
  38. package/tsconfig.lib.json +14 -0
  39. package/tsconfig.lib.prod.json +11 -0
  40. package/tsconfig.spec.json +11 -0
  41. package/fesm2022/retalia-pos-components.mjs +0 -1089
  42. package/fesm2022/retalia-pos-components.mjs.map +0 -1
  43. package/types/retalia-pos-components.d.ts +0 -300
  44. /package/{styles → src/styles}/styles.css +0 -0
  45. /package/{styles → src/styles}/tokens.css +0 -0
@@ -0,0 +1,129 @@
1
+ import {
2
+ ChangeDetectionStrategy,
3
+ Component,
4
+ computed,
5
+ input,
6
+ output,
7
+ } from '@angular/core';
8
+
9
+ export interface PosReceiptIntent {
10
+ readonly type: 'receipt.newSale';
11
+ }
12
+
13
+ /** Display-only VAT from the host envelope — not calculated here. */
14
+ export interface PosReceiptVat {
15
+ readonly rate: number;
16
+ readonly amount: number;
17
+ readonly included: boolean;
18
+ }
19
+
20
+ /** Display-only receipt line from the host envelope — not an API DTO. */
21
+ export interface PosReceiptLine {
22
+ readonly lineId: number;
23
+ readonly sku: string;
24
+ readonly name: string;
25
+ readonly qty: number;
26
+ readonly unitPrice: number;
27
+ readonly lineTotal: number;
28
+ readonly vat: PosReceiptVat;
29
+ readonly struckThrough: boolean;
30
+ }
31
+
32
+ /** Display-only totals from the host envelope — not calculated here. */
33
+ export interface PosReceiptTotals {
34
+ readonly gross: number;
35
+ readonly net: number;
36
+ readonly vat: number;
37
+ readonly discount: number;
38
+ readonly due: number;
39
+ readonly currency: string;
40
+ }
41
+
42
+ /** Display-only tender from the host envelope — not calculated here. */
43
+ export interface PosReceiptPayment {
44
+ readonly tender: string;
45
+ readonly amount: number;
46
+ readonly tendered: number;
47
+ readonly change: number;
48
+ readonly state: string;
49
+ }
50
+
51
+ /** Display-only document metadata from the host envelope. */
52
+ export interface PosReceiptDocument {
53
+ readonly number: string;
54
+ readonly receiptReady: boolean;
55
+ }
56
+
57
+ const EMPTY_TOTALS: PosReceiptTotals = {
58
+ gross: 0,
59
+ net: 0,
60
+ vat: 0,
61
+ discount: 0,
62
+ due: 0,
63
+ currency: 'EUR',
64
+ };
65
+
66
+ const EMPTY_DOCUMENT: PosReceiptDocument = {
67
+ number: '',
68
+ receiptReady: false,
69
+ };
70
+
71
+ @Component({
72
+ selector: 'pos-receipt',
73
+ changeDetection: ChangeDetectionStrategy.OnPush,
74
+ templateUrl: './pos-receipt.html',
75
+ styleUrl: './pos-receipt.scss',
76
+ host: {
77
+ class: 'pos-receipt',
78
+ },
79
+ })
80
+ export class PosReceipt {
81
+ readonly lines = input<readonly PosReceiptLine[]>([]);
82
+ readonly totals = input<PosReceiptTotals>(EMPTY_TOTALS);
83
+ readonly payments = input<readonly PosReceiptPayment[]>([]);
84
+ readonly document = input<PosReceiptDocument>(EMPTY_DOCUMENT);
85
+ readonly disabled = input(false);
86
+
87
+ readonly title = input('Receipt');
88
+ readonly emptyMessage = input('No receipt lines');
89
+ readonly preparingLabel = input('Preparing receipt…');
90
+ readonly readyLabel = input('Receipt ready');
91
+ readonly newSaleLabel = input('New sale');
92
+ readonly tenderLabel = input('Tender');
93
+ readonly amountLabel = input('Amount');
94
+ readonly tenderedLabel = input('Tendered');
95
+ readonly changeLabel = input('Change');
96
+
97
+ readonly action = output<PosReceiptIntent>();
98
+
99
+ protected readonly hasLines = computed(() => this.lines().length > 0);
100
+ protected readonly hasPayments = computed(() => this.payments().length > 0);
101
+ protected readonly hasDocumentNumber = computed(
102
+ () => this.document().number.trim().length > 0,
103
+ );
104
+
105
+ protected readonly statusText = computed(() =>
106
+ this.document().receiptReady
107
+ ? this.readyLabel()
108
+ : this.preparingLabel(),
109
+ );
110
+
111
+ protected readonly canStartNewSale = computed(() => !this.disabled());
112
+
113
+ protected formatMoney(amount: number): string {
114
+ return new Intl.NumberFormat(undefined, {
115
+ style: 'currency',
116
+ currency: this.totals().currency || 'EUR',
117
+ minimumFractionDigits: 2,
118
+ maximumFractionDigits: 2,
119
+ }).format(amount);
120
+ }
121
+
122
+ protected onNewSale(): void {
123
+ if (!this.canStartNewSale()) {
124
+ return;
125
+ }
126
+
127
+ this.action.emit({ type: 'receipt.newSale' });
128
+ }
129
+ }
@@ -0,0 +1,79 @@
1
+ <section
2
+ class="pos-tender__panel"
3
+ [attr.aria-busy]="disabled() ? true : null"
4
+ >
5
+ <header class="pos-tender__header">
6
+ <p class="pos-tender__kicker">Adaptive POS</p>
7
+ <h2 class="pos-tender__title">{{ title() }}</h2>
8
+ </header>
9
+
10
+ <div class="pos-tender__status" aria-live="polite">
11
+ @if (statusText()) {
12
+ <p>{{ statusText() }}</p>
13
+ }
14
+ @if (errorMessage()) {
15
+ <p class="pos-tender__error">{{ errorMessage() }}</p>
16
+ }
17
+ </div>
18
+
19
+ <div class="pos-tender__summary">
20
+ <dl class="pos-tender__due">
21
+ <div class="pos-tender__row">
22
+ <dt>{{ amountDueLabel() }}</dt>
23
+ <dd>{{ formatMoney(amountDue()) }}</dd>
24
+ </div>
25
+ </dl>
26
+
27
+ @if (hasChangeDue()) {
28
+ <div class="pos-tender__change" role="status" aria-live="polite">
29
+ <p class="pos-tender__change-label">{{ changeDueLabel() }}</p>
30
+ <p class="pos-tender__change-value">
31
+ {{ formatMoney(changeDue() ?? 0) }}
32
+ </p>
33
+ </div>
34
+ }
35
+ </div>
36
+
37
+ <div
38
+ class="pos-tender__display"
39
+ role="group"
40
+ [attr.aria-label]="tenderedLabel()"
41
+ >
42
+ <p class="pos-tender__display-label">{{ tenderedLabel() }}</p>
43
+ <p class="pos-tender__display-value" aria-live="polite">
44
+ <span class="pos-tender__currency">{{ currency() }}</span>
45
+ <span class="pos-tender__digits">{{ displayTendered() }}</span>
46
+ </p>
47
+ </div>
48
+
49
+ <div class="pos-tender__keypad" role="group" aria-label="Cash amount keypad">
50
+ @for (row of keypadRows; track $index) {
51
+ <div class="pos-tender__keypad-row">
52
+ @for (key of row; track key) {
53
+ <button
54
+ type="button"
55
+ class="pos-tender__key"
56
+ [class.pos-tender__key--action]="key === '←' || key === '.'"
57
+ [disabled]="disabled()"
58
+ [attr.aria-label]="key === '←' ? 'Backspace' : key === '.' ? 'Decimal point' : key"
59
+ (click)="onKeypad(key)"
60
+ >
61
+ {{ key }}
62
+ </button>
63
+ }
64
+ </div>
65
+ }
66
+ </div>
67
+
68
+ <div class="pos-tender__actions">
69
+ <button
70
+ type="button"
71
+ class="pos-tender__primary"
72
+ [class.pos-tender__primary--disabled]="!canTender()"
73
+ [disabled]="!canTender()"
74
+ (click)="onTender()"
75
+ >
76
+ {{ tenderLabel() }}
77
+ </button>
78
+ </div>
79
+ </section>
@@ -0,0 +1,318 @@
1
+ :host {
2
+ display: block;
3
+ width: 100%;
4
+ max-width: 42rem;
5
+ color: var(--pos-color-text);
6
+ font-family: var(--pos-font-family);
7
+ font-size: var(--pos-font-size-md);
8
+ }
9
+
10
+ .pos-tender__panel {
11
+ display: grid;
12
+ grid-template-areas:
13
+ 'header'
14
+ 'status'
15
+ 'summary'
16
+ 'display'
17
+ 'keypad'
18
+ 'actions';
19
+ gap: var(--pos-space-md);
20
+ padding: var(--pos-space-xl);
21
+ border: 1px solid var(--pos-color-border);
22
+ border-radius: var(--pos-radius-lg);
23
+ background:
24
+ radial-gradient(120% 80% at 100% 0%, rgb(0 194 178 / 0.08), transparent 55%),
25
+ var(--pos-color-panel);
26
+ }
27
+
28
+ .pos-tender__header {
29
+ grid-area: header;
30
+ display: flex;
31
+ flex-direction: column;
32
+ gap: var(--pos-space-sm);
33
+ }
34
+
35
+ .pos-tender__kicker {
36
+ margin: 0;
37
+ color: var(--pos-color-primary);
38
+ font-size: var(--pos-font-size-sm);
39
+ font-weight: var(--pos-font-weight-bold);
40
+ letter-spacing: 0.14em;
41
+ text-transform: uppercase;
42
+ }
43
+
44
+ .pos-tender__title {
45
+ margin: 0;
46
+ font-family: var(--pos-font-family-display);
47
+ font-size: var(--pos-font-size-xl);
48
+ font-weight: var(--pos-font-weight-bold);
49
+ letter-spacing: -0.03em;
50
+ line-height: 1.15;
51
+ }
52
+
53
+ .pos-tender__status {
54
+ grid-area: status;
55
+ min-height: 1.5rem;
56
+ display: flex;
57
+ flex-direction: column;
58
+ gap: var(--pos-space-sm);
59
+ }
60
+
61
+ .pos-tender__status p {
62
+ margin: 0;
63
+ font-size: var(--pos-font-size-sm);
64
+ font-weight: var(--pos-font-weight-semibold);
65
+ }
66
+
67
+ .pos-tender__error {
68
+ padding: var(--pos-space-sm) var(--pos-space-md);
69
+ border-left: 3px solid var(--pos-color-danger);
70
+ background: rgb(214 69 69 / 0.08);
71
+ color: var(--pos-color-danger);
72
+ }
73
+
74
+ .pos-tender__summary {
75
+ grid-area: summary;
76
+ display: flex;
77
+ flex-direction: column;
78
+ gap: var(--pos-space-md);
79
+ }
80
+
81
+ .pos-tender__due {
82
+ margin: 0;
83
+ }
84
+
85
+ .pos-tender__row {
86
+ display: flex;
87
+ justify-content: space-between;
88
+ align-items: baseline;
89
+ gap: var(--pos-space-md);
90
+ margin: 0;
91
+ padding: var(--pos-space-md) var(--pos-space-lg);
92
+ border: 1px solid var(--pos-color-border);
93
+ border-radius: var(--pos-radius-md);
94
+ background: var(--pos-color-panel-elevated);
95
+ }
96
+
97
+ .pos-tender__row dt,
98
+ .pos-tender__row dd {
99
+ margin: 0;
100
+ }
101
+
102
+ .pos-tender__row dt {
103
+ color: var(--pos-color-text-muted);
104
+ font-size: var(--pos-font-size-sm);
105
+ font-weight: var(--pos-font-weight-semibold);
106
+ }
107
+
108
+ .pos-tender__row dd {
109
+ font-family: var(--pos-font-family-display);
110
+ font-size: var(--pos-font-size-lg);
111
+ font-weight: var(--pos-font-weight-bold);
112
+ }
113
+
114
+ .pos-tender__display {
115
+ grid-area: display;
116
+ padding: var(--pos-space-lg) var(--pos-space-xl);
117
+ border: 1px solid var(--pos-color-border);
118
+ border-radius: var(--pos-radius-lg);
119
+ background: var(--pos-color-display);
120
+ box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.06);
121
+ }
122
+
123
+ .pos-tender__display-label {
124
+ margin: 0 0 var(--pos-space-sm);
125
+ color: rgb(232 237 242 / 0.7);
126
+ font-size: var(--pos-font-size-sm);
127
+ font-weight: var(--pos-font-weight-semibold);
128
+ letter-spacing: 0.08em;
129
+ text-transform: uppercase;
130
+ }
131
+
132
+ .pos-tender__display-value {
133
+ display: flex;
134
+ align-items: baseline;
135
+ justify-content: space-between;
136
+ gap: var(--pos-space-md);
137
+ margin: 0;
138
+ color: var(--pos-color-on-display);
139
+ font-family: var(--pos-font-family-display);
140
+ }
141
+
142
+ .pos-tender__currency {
143
+ font-size: var(--pos-font-size-sm);
144
+ font-weight: var(--pos-font-weight-bold);
145
+ letter-spacing: 0.12em;
146
+ }
147
+
148
+ .pos-tender__digits {
149
+ font-size: clamp(1.5rem, 4vw, 2rem);
150
+ font-weight: var(--pos-font-weight-semibold);
151
+ letter-spacing: 0.04em;
152
+ font-variant-numeric: tabular-nums;
153
+ }
154
+
155
+ .pos-tender__keypad {
156
+ grid-area: keypad;
157
+ display: flex;
158
+ flex-direction: column;
159
+ gap: var(--pos-space-md);
160
+ }
161
+
162
+ .pos-tender__keypad-row {
163
+ display: grid;
164
+ grid-template-columns: repeat(3, minmax(0, 1fr));
165
+ gap: var(--pos-space-md);
166
+ }
167
+
168
+ .pos-tender__key {
169
+ display: inline-flex;
170
+ align-items: center;
171
+ justify-content: center;
172
+ min-height: var(--pos-keypad-size);
173
+ border: 1px solid var(--pos-color-key-border);
174
+ border-radius: var(--pos-radius-md);
175
+ background: var(--pos-color-key);
176
+ color: var(--pos-color-text);
177
+ font-family: inherit;
178
+ font-size: 1.15rem;
179
+ font-weight: var(--pos-font-weight-bold);
180
+ cursor: pointer;
181
+ transition:
182
+ background 120ms ease,
183
+ color 120ms ease,
184
+ border-color 120ms ease,
185
+ transform 120ms ease;
186
+ -webkit-tap-highlight-color: transparent;
187
+ }
188
+
189
+ .pos-tender__key--action {
190
+ color: var(--pos-color-text-muted);
191
+ background: var(--pos-color-surface-muted);
192
+ }
193
+
194
+ .pos-tender__key:not(:disabled):active {
195
+ background: var(--pos-color-key-active);
196
+ border-color: var(--pos-color-key-active);
197
+ color: var(--pos-color-on-key-active);
198
+ transform: translateY(1px);
199
+ }
200
+
201
+ .pos-tender__key:disabled,
202
+ .pos-tender__primary:disabled {
203
+ cursor: not-allowed;
204
+ }
205
+
206
+ .pos-tender__key:disabled {
207
+ opacity: 0.55;
208
+ }
209
+
210
+ .pos-tender__key:focus-visible,
211
+ .pos-tender__primary:focus-visible {
212
+ outline: 2px solid var(--pos-color-primary);
213
+ outline-offset: 2px;
214
+ }
215
+
216
+ .pos-tender__change {
217
+ display: flex;
218
+ flex-direction: column;
219
+ gap: var(--pos-space-xs);
220
+ padding: var(--pos-space-md) var(--pos-space-lg);
221
+ border: 1px solid var(--pos-color-success);
222
+ border-radius: var(--pos-radius-md);
223
+ background: color-mix(in srgb, var(--pos-color-success) 14%, var(--pos-color-panel-elevated));
224
+ }
225
+
226
+ .pos-tender__change-label {
227
+ margin: 0;
228
+ color: var(--pos-color-text-muted);
229
+ font-size: var(--pos-font-size-sm);
230
+ font-weight: var(--pos-font-weight-semibold);
231
+ }
232
+
233
+ .pos-tender__change-value {
234
+ margin: 0;
235
+ color: var(--pos-color-text);
236
+ font-family: var(--pos-font-family-display);
237
+ font-size: var(--pos-font-size-xl);
238
+ font-weight: var(--pos-font-weight-bold);
239
+ font-variant-numeric: tabular-nums;
240
+ }
241
+
242
+ .pos-tender__actions {
243
+ grid-area: actions;
244
+ display: flex;
245
+ margin-top: 0;
246
+ }
247
+
248
+ .pos-tender__primary {
249
+ flex: 1 1 auto;
250
+ min-height: var(--pos-button-min-height);
251
+ border: 0;
252
+ border-radius: var(--pos-radius-md);
253
+ background: var(--pos-color-primary);
254
+ color: var(--pos-color-on-primary);
255
+ font: inherit;
256
+ font-size: 1.05rem;
257
+ font-weight: var(--pos-font-weight-bold);
258
+ letter-spacing: 0.04em;
259
+ text-transform: uppercase;
260
+ cursor: pointer;
261
+ transition:
262
+ filter 120ms ease,
263
+ transform 120ms ease;
264
+ -webkit-tap-highlight-color: transparent;
265
+ }
266
+
267
+ .pos-tender__primary:not(:disabled):hover {
268
+ filter: brightness(0.97);
269
+ }
270
+
271
+ .pos-tender__primary:not(:disabled):active {
272
+ transform: translateY(1px);
273
+ }
274
+
275
+ .pos-tender__primary--disabled,
276
+ .pos-tender__primary:disabled {
277
+ background: var(--pos-color-disabled);
278
+ color: var(--pos-color-on-disabled);
279
+ filter: none;
280
+ transform: none;
281
+ }
282
+
283
+ @media (min-width: 768px) {
284
+ .pos-tender__panel {
285
+ grid-template-columns: minmax(0, 1fr) minmax(16rem, 1.1fr);
286
+ grid-template-areas:
287
+ 'header header'
288
+ 'status status'
289
+ 'summary keypad'
290
+ 'display keypad'
291
+ 'actions keypad';
292
+ gap: var(--pos-space-lg);
293
+ padding: var(--pos-space-2xl);
294
+ align-items: start;
295
+ }
296
+
297
+ .pos-tender__key {
298
+ min-height: var(--pos-keypad-size-md);
299
+ }
300
+
301
+ .pos-tender__keypad {
302
+ align-self: start;
303
+ }
304
+ }
305
+
306
+ @media (min-width: 1100px) {
307
+ .pos-tender__key {
308
+ min-height: var(--pos-keypad-size-lg);
309
+ font-size: 1.25rem;
310
+ }
311
+ }
312
+
313
+ @media (prefers-reduced-motion: reduce) {
314
+ .pos-tender__key,
315
+ .pos-tender__primary {
316
+ transition: none;
317
+ }
318
+ }
@@ -0,0 +1,150 @@
1
+ import { ComponentFixture, TestBed } from '@angular/core/testing';
2
+
3
+ import { PosTender, PosTenderIntent } from './pos-tender';
4
+
5
+ function clickKey(fixture: ComponentFixture<PosTender>, key: string): void {
6
+ const keys = Array.from(
7
+ (fixture.nativeElement as HTMLElement).querySelectorAll('.pos-tender__key'),
8
+ ) as HTMLButtonElement[];
9
+ keys.find((button) => button.textContent?.trim() === key)?.click();
10
+ }
11
+
12
+ function enterAmount(
13
+ fixture: ComponentFixture<PosTender>,
14
+ keys: readonly string[],
15
+ ): void {
16
+ for (const key of keys) {
17
+ clickKey(fixture, key);
18
+ }
19
+ }
20
+
21
+ describe('PosTender', () => {
22
+ let fixture: ComponentFixture<PosTender>;
23
+ let component: PosTender;
24
+
25
+ beforeEach(async () => {
26
+ await TestBed.configureTestingModule({
27
+ imports: [PosTender],
28
+ }).compileComponents();
29
+
30
+ fixture = TestBed.createComponent(PosTender);
31
+ component = fixture.componentInstance;
32
+ await fixture.whenStable();
33
+ });
34
+
35
+ it('should render the cash tender title and amount due as received', async () => {
36
+ fixture.componentRef.setInput('amountDue', 12);
37
+ fixture.componentRef.setInput('currency', 'EUR');
38
+ await fixture.whenStable();
39
+
40
+ const compiled = fixture.nativeElement as HTMLElement;
41
+ expect(compiled.querySelector('.pos-tender__title')?.textContent).toContain(
42
+ 'Cash tender',
43
+ );
44
+ expect(compiled.querySelector('.pos-tender__due')?.textContent).toContain(
45
+ '12',
46
+ );
47
+ expect(compiled.querySelector('.pos-tender__change')).toBeNull();
48
+ });
49
+
50
+ it('should emit tender.cash with the entered amount and not calculate change', async () => {
51
+ const intents: PosTenderIntent[] = [];
52
+ component.action.subscribe((intent) => intents.push(intent));
53
+
54
+ fixture.componentRef.setInput('amountDue', 12);
55
+ await fixture.whenStable();
56
+
57
+ enterAmount(fixture, ['2', '0']);
58
+ await fixture.whenStable();
59
+
60
+ const tender = (fixture.nativeElement as HTMLElement).querySelector(
61
+ '.pos-tender__primary',
62
+ ) as HTMLButtonElement;
63
+ tender.click();
64
+ await fixture.whenStable();
65
+
66
+ expect(intents).toEqual([{ type: 'tender.cash', amount: 20 }]);
67
+ expect(
68
+ (fixture.nativeElement as HTMLElement).querySelector('.pos-tender__change'),
69
+ ).toBeNull();
70
+ });
71
+
72
+ it('should display change due from the envelope without calculating it', async () => {
73
+ fixture.componentRef.setInput('amountDue', 12);
74
+ fixture.componentRef.setInput('changeDue', 99);
75
+ fixture.componentRef.setInput('currency', 'EUR');
76
+ await fixture.whenStable();
77
+
78
+ const compiled = fixture.nativeElement as HTMLElement;
79
+ const change = compiled.querySelector('.pos-tender__change-value');
80
+ expect(change?.textContent).toContain('99');
81
+ expect(change?.textContent).not.toContain('8');
82
+ });
83
+
84
+ it('should display a zero change due from the envelope', async () => {
85
+ fixture.componentRef.setInput('changeDue', 0);
86
+ await fixture.whenStable();
87
+
88
+ const compiled = fixture.nativeElement as HTMLElement;
89
+ expect(compiled.querySelector('.pos-tender__change')).toBeTruthy();
90
+ expect(
91
+ compiled.querySelector('.pos-tender__change-value')?.textContent,
92
+ ).toContain('0');
93
+ });
94
+
95
+ it('should not emit while disabled', async () => {
96
+ const intents: PosTenderIntent[] = [];
97
+ component.action.subscribe((intent) => intents.push(intent));
98
+
99
+ fixture.componentRef.setInput('disabled', true);
100
+ await fixture.whenStable();
101
+
102
+ enterAmount(fixture, ['1', '0']);
103
+ const tender = (fixture.nativeElement as HTMLElement).querySelector(
104
+ '.pos-tender__primary',
105
+ ) as HTMLButtonElement;
106
+ tender.click();
107
+ await fixture.whenStable();
108
+
109
+ expect(tender.disabled).toBe(true);
110
+ expect(intents).toEqual([]);
111
+ expect(
112
+ (fixture.nativeElement as HTMLElement).querySelector('.pos-tender__status')
113
+ ?.textContent,
114
+ ).toContain('Processing tender');
115
+ });
116
+
117
+ it('should not emit when no cash amount has been entered', async () => {
118
+ const intents: PosTenderIntent[] = [];
119
+ component.action.subscribe((intent) => intents.push(intent));
120
+
121
+ const tender = (fixture.nativeElement as HTMLElement).querySelector(
122
+ '.pos-tender__primary',
123
+ ) as HTMLButtonElement;
124
+ tender.click();
125
+ await fixture.whenStable();
126
+
127
+ expect(tender.disabled).toBe(true);
128
+ expect(intents).toEqual([]);
129
+ });
130
+
131
+ it('should accept decimal cash amounts from the keypad', async () => {
132
+ const intents: PosTenderIntent[] = [];
133
+ component.action.subscribe((intent) => intents.push(intent));
134
+
135
+ enterAmount(fixture, ['1', '2', '.', '5']);
136
+ await fixture.whenStable();
137
+
138
+ const tender = (fixture.nativeElement as HTMLElement).querySelector(
139
+ '.pos-tender__primary',
140
+ ) as HTMLButtonElement;
141
+ tender.click();
142
+ await fixture.whenStable();
143
+
144
+ expect(intents).toEqual([{ type: 'tender.cash', amount: 12.5 }]);
145
+ expect(
146
+ (fixture.nativeElement as HTMLElement).querySelector('.pos-tender__digits')
147
+ ?.textContent,
148
+ ).toContain('12.5');
149
+ });
150
+ });