@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
@@ -0,0 +1,285 @@
1
+ :host {
2
+ display: flex;
3
+ flex-direction: column;
4
+ min-height: 0;
5
+ width: 100%;
6
+ height: 100%;
7
+ color: var(--pos-color-text);
8
+ font-family: var(--pos-font-family);
9
+ font-size: var(--pos-font-size-md);
10
+ }
11
+
12
+ .pos-basket__panel {
13
+ display: flex;
14
+ flex-direction: column;
15
+ min-height: 0;
16
+ height: 100%;
17
+ padding: var(--pos-space-lg);
18
+ border: 1px solid var(--pos-color-border);
19
+ border-radius: var(--pos-radius-lg);
20
+ background: var(--pos-color-panel);
21
+ }
22
+
23
+ .pos-basket__header {
24
+ display: flex;
25
+ align-items: baseline;
26
+ justify-content: space-between;
27
+ gap: var(--pos-space-md);
28
+ margin-bottom: var(--pos-space-md);
29
+ }
30
+
31
+ .pos-basket__title {
32
+ margin: 0;
33
+ font-family: var(--pos-font-family-display);
34
+ font-size: var(--pos-font-size-xl);
35
+ font-weight: var(--pos-font-weight-bold);
36
+ letter-spacing: -0.03em;
37
+ }
38
+
39
+ .pos-basket__count {
40
+ margin: 0;
41
+ color: var(--pos-color-text-muted);
42
+ font-size: var(--pos-font-size-sm);
43
+ font-weight: var(--pos-font-weight-semibold);
44
+ }
45
+
46
+ .pos-basket__lines {
47
+ display: flex;
48
+ flex: 1 1 auto;
49
+ flex-direction: column;
50
+ gap: var(--pos-space-sm);
51
+ min-height: 8rem;
52
+ margin: 0;
53
+ padding: 0;
54
+ overflow-y: auto;
55
+ list-style: none;
56
+ }
57
+
58
+ .pos-basket__empty {
59
+ margin: auto 0;
60
+ padding: var(--pos-space-2xl) var(--pos-space-md);
61
+ color: var(--pos-color-text-muted);
62
+ text-align: center;
63
+ }
64
+
65
+ .pos-basket__line {
66
+ display: grid;
67
+ grid-template-columns: minmax(0, 1fr) auto;
68
+ grid-template-areas:
69
+ 'info void'
70
+ 'stepper prices';
71
+ align-items: center;
72
+ gap: var(--pos-space-sm) var(--pos-space-md);
73
+ padding: var(--pos-space-md);
74
+ border: 1px solid var(--pos-color-border);
75
+ border-radius: var(--pos-radius-md);
76
+ background: var(--pos-color-panel-elevated);
77
+ }
78
+
79
+ .pos-basket__line-info {
80
+ grid-area: info;
81
+ min-width: 0;
82
+ }
83
+
84
+ .pos-basket__line-name {
85
+ margin: 0;
86
+ font-size: var(--pos-font-size-md);
87
+ font-weight: var(--pos-font-weight-semibold);
88
+ overflow: hidden;
89
+ text-overflow: ellipsis;
90
+ white-space: nowrap;
91
+ }
92
+
93
+ .pos-basket__line-sku,
94
+ .pos-basket__line-voided {
95
+ margin: var(--pos-space-xs) 0 0;
96
+ font-size: var(--pos-font-size-sm);
97
+ }
98
+
99
+ .pos-basket__line-sku {
100
+ color: var(--pos-color-text-muted);
101
+ }
102
+
103
+ .pos-basket__line-voided {
104
+ color: var(--pos-color-danger);
105
+ font-weight: var(--pos-font-weight-semibold);
106
+ }
107
+
108
+ .pos-basket__stepper {
109
+ grid-area: stepper;
110
+ display: inline-flex;
111
+ align-items: center;
112
+ gap: var(--pos-space-xs);
113
+ width: fit-content;
114
+ padding: var(--pos-space-xs);
115
+ border: 1px solid var(--pos-color-border);
116
+ border-radius: var(--pos-radius-md);
117
+ background: var(--pos-color-surface);
118
+ }
119
+
120
+ .pos-basket__qty-btn,
121
+ .pos-basket__void {
122
+ display: inline-flex;
123
+ align-items: center;
124
+ justify-content: center;
125
+ margin: 0;
126
+ border: 0;
127
+ background: transparent;
128
+ color: var(--pos-color-text);
129
+ font: inherit;
130
+ cursor: pointer;
131
+ -webkit-tap-highlight-color: transparent;
132
+ }
133
+
134
+ .pos-basket__qty-btn {
135
+ min-width: 2.75rem;
136
+ min-height: 2.75rem;
137
+ border-radius: var(--pos-radius-sm);
138
+ font-size: var(--pos-font-size-lg);
139
+ font-weight: var(--pos-font-weight-bold);
140
+ }
141
+
142
+ .pos-basket__qty-btn:not(:disabled):active,
143
+ .pos-basket__void:not(:disabled):active {
144
+ background: var(--pos-color-key-active);
145
+ color: var(--pos-color-on-key-active);
146
+ }
147
+
148
+ .pos-basket__qty {
149
+ min-width: 1.75rem;
150
+ font-family: var(--pos-font-family-display);
151
+ font-weight: var(--pos-font-weight-bold);
152
+ text-align: center;
153
+ }
154
+
155
+ .pos-basket__line-prices {
156
+ grid-area: prices;
157
+ display: flex;
158
+ flex-direction: column;
159
+ align-items: flex-end;
160
+ gap: var(--pos-space-xs);
161
+ }
162
+
163
+ .pos-basket__unit-price {
164
+ margin: 0;
165
+ color: var(--pos-color-text-muted);
166
+ font-size: var(--pos-font-size-sm);
167
+ }
168
+
169
+ .pos-basket__line-total {
170
+ margin: 0;
171
+ font-family: var(--pos-font-family-display);
172
+ font-weight: var(--pos-font-weight-bold);
173
+ }
174
+
175
+ .pos-basket__void {
176
+ grid-area: void;
177
+ justify-self: end;
178
+ min-width: 2.75rem;
179
+ min-height: 2.75rem;
180
+ border: 1px solid var(--pos-color-border);
181
+ border-radius: var(--pos-radius-md);
182
+ color: var(--pos-color-text-muted);
183
+ }
184
+
185
+ .pos-basket__qty-btn:disabled,
186
+ .pos-basket__void:disabled,
187
+ .pos-basket__clear:disabled {
188
+ cursor: not-allowed;
189
+ opacity: 0.55;
190
+ }
191
+
192
+ .pos-basket__qty-btn:focus-visible,
193
+ .pos-basket__void:focus-visible,
194
+ .pos-basket__clear:focus-visible {
195
+ outline: 2px solid var(--pos-color-primary);
196
+ outline-offset: 2px;
197
+ }
198
+
199
+ .pos-basket__line--voided .pos-basket__line-name,
200
+ .pos-basket__line--voided .pos-basket__line-sku,
201
+ .pos-basket__line--voided .pos-basket__line-prices {
202
+ color: var(--pos-color-text-muted);
203
+ text-decoration: line-through;
204
+ }
205
+
206
+ .pos-basket__footer {
207
+ display: flex;
208
+ flex-direction: column;
209
+ gap: var(--pos-space-md);
210
+ margin-top: var(--pos-space-md);
211
+ padding-top: var(--pos-space-md);
212
+ border-top: 1px solid var(--pos-color-border);
213
+ }
214
+
215
+ .pos-basket__totals {
216
+ display: flex;
217
+ flex-direction: column;
218
+ gap: var(--pos-space-sm);
219
+ margin: 0;
220
+ }
221
+
222
+ .pos-basket__total-row {
223
+ display: flex;
224
+ justify-content: space-between;
225
+ gap: var(--pos-space-md);
226
+ margin: 0;
227
+ color: var(--pos-color-text-muted);
228
+ font-size: var(--pos-font-size-sm);
229
+ }
230
+
231
+ .pos-basket__total-row dt,
232
+ .pos-basket__total-row dd {
233
+ margin: 0;
234
+ }
235
+
236
+ .pos-basket__total-row--due {
237
+ padding-top: var(--pos-space-sm);
238
+ border-top: 1px solid var(--pos-color-border);
239
+ color: var(--pos-color-text);
240
+ font-family: var(--pos-font-family-display);
241
+ font-size: var(--pos-font-size-lg);
242
+ font-weight: var(--pos-font-weight-bold);
243
+ }
244
+
245
+ .pos-basket__clear {
246
+ min-height: var(--pos-button-min-height);
247
+ border: 0;
248
+ border-radius: var(--pos-radius-md);
249
+ background: var(--pos-color-danger);
250
+ color: var(--pos-color-on-danger);
251
+ font: inherit;
252
+ font-weight: var(--pos-font-weight-bold);
253
+ letter-spacing: 0.04em;
254
+ text-transform: uppercase;
255
+ cursor: pointer;
256
+ -webkit-tap-highlight-color: transparent;
257
+ }
258
+
259
+ .pos-basket__clear:not(:disabled):active {
260
+ transform: translateY(1px);
261
+ }
262
+
263
+ .pos-basket__clear:disabled {
264
+ background: var(--pos-color-disabled);
265
+ color: var(--pos-color-on-disabled);
266
+ transform: none;
267
+ }
268
+
269
+ @media (min-width: 768px) {
270
+ .pos-basket__panel {
271
+ padding: var(--pos-space-xl);
272
+ }
273
+
274
+ .pos-basket__line {
275
+ grid-template-columns: minmax(0, 1fr) auto auto auto;
276
+ grid-template-areas: 'info stepper prices void';
277
+ gap: var(--pos-space-md);
278
+ }
279
+ }
280
+
281
+ @media (prefers-reduced-motion: reduce) {
282
+ .pos-basket__clear {
283
+ transform: none;
284
+ }
285
+ }
@@ -0,0 +1,198 @@
1
+ import { ComponentFixture, TestBed } from '@angular/core/testing';
2
+
3
+ import {
4
+ PosBasket,
5
+ PosBasketIntent,
6
+ PosBasketLine,
7
+ PosBasketTotals,
8
+ } from './pos-basket';
9
+
10
+ const COFFEE_LINE: PosBasketLine = {
11
+ lineId: 1,
12
+ sku: 'SKU-001',
13
+ name: 'Coffee 250g',
14
+ qty: 2,
15
+ unitPrice: 6,
16
+ lineTotal: 12,
17
+ vat: { rate: 0.25, amount: 2.4, included: true },
18
+ struckThrough: false,
19
+ };
20
+
21
+ const MID_SALE_TOTALS: PosBasketTotals = {
22
+ gross: 12,
23
+ net: 9.6,
24
+ vat: 2.4,
25
+ discount: 0,
26
+ due: 12,
27
+ currency: 'EUR',
28
+ };
29
+
30
+ describe('PosBasket', () => {
31
+ let fixture: ComponentFixture<PosBasket>;
32
+ let component: PosBasket;
33
+
34
+ beforeEach(async () => {
35
+ await TestBed.configureTestingModule({
36
+ imports: [PosBasket],
37
+ }).compileComponents();
38
+
39
+ fixture = TestBed.createComponent(PosBasket);
40
+ component = fixture.componentInstance;
41
+ await fixture.whenStable();
42
+ });
43
+
44
+ it('should render the empty basket state by default', () => {
45
+ const compiled = fixture.nativeElement as HTMLElement;
46
+ expect(compiled.querySelector('.pos-basket__title')?.textContent).toContain(
47
+ 'Basket',
48
+ );
49
+ expect(compiled.querySelector('.pos-basket__empty')?.textContent).toContain(
50
+ 'Basket is empty',
51
+ );
52
+ });
53
+
54
+ it('should render lines and totals exactly as received without calculating', async () => {
55
+ const mismatched: PosBasketLine = {
56
+ ...COFFEE_LINE,
57
+ qty: 2,
58
+ unitPrice: 6,
59
+ lineTotal: 99,
60
+ };
61
+ const totals: PosBasketTotals = {
62
+ ...MID_SALE_TOTALS,
63
+ gross: 50,
64
+ due: 40,
65
+ };
66
+
67
+ fixture.componentRef.setInput('lines', [mismatched]);
68
+ fixture.componentRef.setInput('totals', totals);
69
+ await fixture.whenStable();
70
+
71
+ const compiled = fixture.nativeElement as HTMLElement;
72
+ expect(
73
+ compiled.querySelector('.pos-basket__line-name')?.textContent,
74
+ ).toContain('Coffee 250g');
75
+ expect(
76
+ compiled.querySelector('.pos-basket__line-sku')?.textContent,
77
+ ).toContain('SKU-001');
78
+ expect(compiled.querySelector('.pos-basket__qty')?.textContent?.trim()).toBe(
79
+ '2',
80
+ );
81
+ expect(
82
+ compiled.querySelector('.pos-basket__line-total')?.textContent,
83
+ ).toContain('99');
84
+ expect(
85
+ compiled.querySelector('.pos-basket__line-total')?.textContent,
86
+ ).not.toContain('12');
87
+
88
+ const due = compiled.querySelector('.pos-basket__total-row--due dd');
89
+ expect(due?.textContent).toContain('40');
90
+ expect(due?.textContent).not.toContain('50');
91
+ });
92
+
93
+ it('should emit change-quantity when the cashier increments a line', async () => {
94
+ const intents: PosBasketIntent[] = [];
95
+ component.action.subscribe((intent) => intents.push(intent));
96
+
97
+ fixture.componentRef.setInput('lines', [COFFEE_LINE]);
98
+ fixture.componentRef.setInput('totals', MID_SALE_TOTALS);
99
+ await fixture.whenStable();
100
+
101
+ const increase = (
102
+ fixture.nativeElement as HTMLElement
103
+ ).querySelectorAll('.pos-basket__qty-btn')[1] as HTMLButtonElement;
104
+ increase.click();
105
+ await fixture.whenStable();
106
+
107
+ expect(intents).toEqual([
108
+ { type: 'basket.changeQuantity', lineId: 1, qty: 3 },
109
+ ]);
110
+ });
111
+
112
+ it('should emit change-quantity when the cashier decrements a line', async () => {
113
+ const intents: PosBasketIntent[] = [];
114
+ component.action.subscribe((intent) => intents.push(intent));
115
+
116
+ fixture.componentRef.setInput('lines', [COFFEE_LINE]);
117
+ fixture.componentRef.setInput('totals', MID_SALE_TOTALS);
118
+ await fixture.whenStable();
119
+
120
+ const decrease = (
121
+ fixture.nativeElement as HTMLElement
122
+ ).querySelector('.pos-basket__qty-btn') as HTMLButtonElement;
123
+ decrease.click();
124
+ await fixture.whenStable();
125
+
126
+ expect(intents).toEqual([
127
+ { type: 'basket.changeQuantity', lineId: 1, qty: 1 },
128
+ ]);
129
+ });
130
+
131
+ it('should emit void-line for the selected line', async () => {
132
+ const intents: PosBasketIntent[] = [];
133
+ component.action.subscribe((intent) => intents.push(intent));
134
+
135
+ fixture.componentRef.setInput('lines', [COFFEE_LINE]);
136
+ fixture.componentRef.setInput('totals', MID_SALE_TOTALS);
137
+ await fixture.whenStable();
138
+
139
+ const voidBtn = (fixture.nativeElement as HTMLElement).querySelector(
140
+ '.pos-basket__void',
141
+ ) as HTMLButtonElement;
142
+ voidBtn.click();
143
+ await fixture.whenStable();
144
+
145
+ expect(intents).toEqual([{ type: 'basket.voidLine', lineId: 1 }]);
146
+ });
147
+
148
+ it('should emit clear-basket when the cashier clears', async () => {
149
+ const intents: PosBasketIntent[] = [];
150
+ component.action.subscribe((intent) => intents.push(intent));
151
+
152
+ fixture.componentRef.setInput('lines', [COFFEE_LINE]);
153
+ fixture.componentRef.setInput('totals', MID_SALE_TOTALS);
154
+ await fixture.whenStable();
155
+
156
+ const clear = (fixture.nativeElement as HTMLElement).querySelector(
157
+ '.pos-basket__clear',
158
+ ) as HTMLButtonElement;
159
+ clear.click();
160
+ await fixture.whenStable();
161
+
162
+ expect(intents).toEqual([{ type: 'basket.clear' }]);
163
+ });
164
+
165
+ it('should not emit while disabled, and should disable void on struck-through lines', async () => {
166
+ const intents: PosBasketIntent[] = [];
167
+ component.action.subscribe((intent) => intents.push(intent));
168
+
169
+ fixture.componentRef.setInput('lines', [
170
+ { ...COFFEE_LINE, struckThrough: true },
171
+ ]);
172
+ fixture.componentRef.setInput('totals', MID_SALE_TOTALS);
173
+ fixture.componentRef.setInput('disabled', true);
174
+ await fixture.whenStable();
175
+
176
+ const compiled = fixture.nativeElement as HTMLElement;
177
+ const increase = compiled.querySelectorAll(
178
+ '.pos-basket__qty-btn',
179
+ )[1] as HTMLButtonElement;
180
+ const voidBtn = compiled.querySelector(
181
+ '.pos-basket__void',
182
+ ) as HTMLButtonElement;
183
+ const clear = compiled.querySelector(
184
+ '.pos-basket__clear',
185
+ ) as HTMLButtonElement;
186
+
187
+ increase.click();
188
+ voidBtn.click();
189
+ clear.click();
190
+ await fixture.whenStable();
191
+
192
+ expect(increase.disabled).toBe(true);
193
+ expect(voidBtn.disabled).toBe(true);
194
+ expect(clear.disabled).toBe(true);
195
+ expect(intents).toEqual([]);
196
+ expect(compiled.querySelector('.pos-basket__line--voided')).toBeTruthy();
197
+ });
198
+ });
@@ -0,0 +1,114 @@
1
+ import type { Meta, StoryObj } from '@storybook/angular-vite';
2
+
3
+ import { PosBasket, PosBasketLine, PosBasketTotals } from './pos-basket';
4
+
5
+ const coffee: PosBasketLine = {
6
+ lineId: 1,
7
+ sku: 'SKU-001',
8
+ name: 'Coffee 250g',
9
+ qty: 2,
10
+ unitPrice: 6,
11
+ lineTotal: 12,
12
+ vat: { rate: 0.25, amount: 2.4, included: true },
13
+ struckThrough: false,
14
+ };
15
+
16
+ const pastry: PosBasketLine = {
17
+ lineId: 2,
18
+ sku: 'SKU-014',
19
+ name: 'Almond croissant',
20
+ qty: 1,
21
+ unitPrice: 3.5,
22
+ lineTotal: 3.5,
23
+ vat: { rate: 0.2, amount: 0.58, included: true },
24
+ struckThrough: false,
25
+ };
26
+
27
+ const midSaleTotals: PosBasketTotals = {
28
+ gross: 12,
29
+ net: 9.6,
30
+ vat: 2.4,
31
+ discount: 0,
32
+ due: 12,
33
+ currency: 'EUR',
34
+ };
35
+
36
+ const meta: Meta<PosBasket> = {
37
+ title: 'Basket',
38
+ component: PosBasket,
39
+ args: {
40
+ lines: [],
41
+ totals: {
42
+ gross: 0,
43
+ net: 0,
44
+ vat: 0,
45
+ discount: 0,
46
+ due: 0,
47
+ currency: 'EUR',
48
+ },
49
+ disabled: false,
50
+ },
51
+ };
52
+
53
+ export default meta;
54
+
55
+ type Story = StoryObj<PosBasket>;
56
+
57
+ export const Empty: Story = {};
58
+
59
+ export const MidSale: Story = {
60
+ args: {
61
+ lines: [coffee],
62
+ totals: midSaleTotals,
63
+ },
64
+ };
65
+
66
+ export const MultipleLines: Story = {
67
+ args: {
68
+ lines: [coffee, pastry],
69
+ totals: {
70
+ gross: 15.5,
71
+ net: 12.52,
72
+ vat: 2.98,
73
+ discount: 0,
74
+ due: 15.5,
75
+ currency: 'EUR',
76
+ },
77
+ },
78
+ };
79
+
80
+ export const VoidedLine: Story = {
81
+ args: {
82
+ lines: [{ ...coffee, struckThrough: true }, pastry],
83
+ totals: {
84
+ gross: 3.5,
85
+ net: 2.92,
86
+ vat: 0.58,
87
+ discount: 0,
88
+ due: 3.5,
89
+ currency: 'EUR',
90
+ },
91
+ },
92
+ };
93
+
94
+ export const AsReceivedNotCalculated: Story = {
95
+ args: {
96
+ lines: [{ ...coffee, qty: 2, unitPrice: 6, lineTotal: 99 }],
97
+ totals: {
98
+ gross: 50,
99
+ net: 40,
100
+ vat: 10,
101
+ discount: 5,
102
+ due: 45,
103
+ currency: 'EUR',
104
+ },
105
+ },
106
+ };
107
+
108
+ export const Disabled: Story = {
109
+ args: {
110
+ lines: [coffee],
111
+ totals: midSaleTotals,
112
+ disabled: true,
113
+ },
114
+ };