@12-apps/payments-frontend 3.26.0 → 3.27.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/payments-frontend",
3
- "version": "3.26.0",
3
+ "version": "3.27.1",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Browser half of the vendor-agnostic payments platform: plug-and-play MUI components for the per-provider settings page (credential form from each provider's schema, masked hints, verify/enable) and the checkout page (PIX QR + polling, card tokenization, hosted-checkout redirect), plus the headless hooks and fetch clients they build on. Talks only to the host's payments HTTP surface — never to a provider directly. Microfrontend-ready: no app coupling, host injects theme and auth.",
@@ -9,6 +9,7 @@
9
9
  "./locales": "./src/locales.ts",
10
10
  "./manifest": "./src/manifest/index.ts",
11
11
  "./manifest/web": "./src/manifest/web.ts",
12
+ "./tender-split": "./src/tender-split/index.ts",
12
13
  "./package.json": "./package.json"
13
14
  },
14
15
  "scripts": {
@@ -59,3 +59,7 @@ export const ScheduleIcon = makeIcon([
59
59
  export const PersonOutlineIcon = makeIcon([
60
60
  'M12 5.9c1.16 0 2.1.94 2.1 2.1s-.94 2.1-2.1 2.1S9.9 9.16 9.9 8s.94-2.1 2.1-2.1m0 9c2.97 0 6.1 1.46 6.1 2.1v1.1H5.9V17c0-.64 3.13-2.1 6.1-2.1M12 4C9.79 4 8 5.79 8 8s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4m0 9c-2.67 0-8 1.34-8 4v3h16v-3c0-2.66-5.33-4-8-4',
61
61
  ]);
62
+
63
+ export const CloseIcon = makeIcon([
64
+ 'M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z',
65
+ ]);
package/src/locales.ts CHANGED
@@ -27,6 +27,9 @@ import { PT_BR_PLATFORM_HOMOLOGACAO_COPY } from './components/platform/pt-BR';
27
27
  import { EN_US_PAYMENTS_SETTINGS_COPY } from './components/settings-en-US';
28
28
  import type { PaymentsSettingsCopy } from './components/settings-copy';
29
29
  import { PT_BR_PAYMENTS_SETTINGS_COPY } from './components/settings-pt-BR';
30
+ import type { TenderSplitCopy } from './tender-split/copy';
31
+ import { EN_US_TENDER_SPLIT_COPY } from './tender-split/en-US';
32
+ import { PT_BR_TENDER_SPLIT_COPY } from './tender-split/pt-BR';
30
33
 
31
34
  /**
32
35
  * Every surface this half renders, in both languages, keyed by tag.
@@ -54,6 +57,15 @@ export const ACTIVATION_STEP_COPY = {
54
57
  'en-US': EN_US_ACTIVATION_STEP_COPY,
55
58
  } as const satisfies LocalePack<ActivationStepCopy>;
56
59
 
60
+ /**
61
+ * `TenderSplit`'s words (`./tender-split`) — read by the OPERATOR recording
62
+ * money taken in person, so they resolve from the operator's preference.
63
+ */
64
+ export const TENDER_SPLIT_COPY = {
65
+ 'pt-BR': PT_BR_TENDER_SPLIT_COPY,
66
+ 'en-US': EN_US_TENDER_SPLIT_COPY,
67
+ } as const satisfies LocalePack<TenderSplitCopy>;
68
+
57
69
  export const CARD_COPY = {
58
70
  'pt-BR': PT_BR_CARD_COPY,
59
71
  'en-US': EN_US_CARD_COPY,
@@ -113,3 +125,4 @@ export {
113
125
  export { EN_US_CHECKOUT_SCREENS_COPY } from './components/checkout/screens-en-US';
114
126
  export { EN_US_PAYMENTS_SETTINGS_COPY } from './components/settings-en-US';
115
127
  export { EN_US_PLATFORM_HOMOLOGACAO_COPY } from './components/platform/en-US';
128
+ export { EN_US_TENDER_SPLIT_COPY } from './tender-split/en-US';
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Every word `TenderSplit` puts in front of the operator.
3
+ *
4
+ * Required, with no defaults — this package's own doctrine (see
5
+ * `../card/copy.ts`): a default in one host's language reads as finished to
6
+ * the next host until somebody sees it. The tenders' own names are not here
7
+ * either; they arrive with each {@link TenderOption}, because which tenders a
8
+ * store takes, and what it calls them, is the host's catalogue.
9
+ *
10
+ * The functions receive amounts ALREADY formatted in the host's locale and
11
+ * currency, so no sentence here builds a number itself.
12
+ */
13
+ export interface TenderSplitCopy {
14
+ /** Beside the amount already carried by the picks — "Lançado". */
15
+ readonly launchedLabel: string;
16
+ /** Beside the amount still unpaid — "Falta". */
17
+ readonly missingLabel: string;
18
+ /** Once the picks cover the bill — "Fechou". */
19
+ readonly coveredLabel: string;
20
+ /** The picks pass the bill with nothing to hand back — "Passou R$ 5,00". */
21
+ readonly over: (amount: string) => string;
22
+ /** Names the progress bar for a screen reader. */
23
+ readonly progressLabel: string;
24
+ /** Beside the currency sign of a tender that takes an equal share — "dividido". */
25
+ readonly sharedHint: string;
26
+ /** Names a tender's amount field for a screen reader — "Valor em Dinheiro". */
27
+ readonly amountLabel: (tender: string) => string;
28
+ /** Names the control that drops a tender — "Tirar Dinheiro". */
29
+ readonly remove: (tender: string) => string;
30
+ /** The line naming the change handed back — "Troco". */
31
+ readonly changeLabel: string;
32
+ /** The confirm, before any tender is picked — "Escolha a forma". */
33
+ readonly pickFirst: string;
34
+ /** The confirm, while part of the bill is unpaid — "Falta R$ 5,00". */
35
+ readonly missing: (amount: string) => string;
36
+ /** The confirm, while a typed amount is not an amount. */
37
+ readonly invalid: string;
38
+ /** The confirm, once the bill is covered — "Confirmar R$ 82,00". */
39
+ readonly confirm: (amount: string) => string;
40
+ readonly cancel: string;
41
+ }
@@ -0,0 +1,19 @@
1
+ import type { TenderSplitCopy } from './copy';
2
+
3
+ /** `TenderSplit` in en-US — a named pack, never a default (see `./copy`). */
4
+ export const EN_US_TENDER_SPLIT_COPY: TenderSplitCopy = {
5
+ launchedLabel: 'Recorded',
6
+ missingLabel: 'Left',
7
+ coveredLabel: 'Covered',
8
+ over: (amount) => `Over by ${amount}`,
9
+ progressLabel: 'How much of the bill is recorded',
10
+ sharedHint: 'split',
11
+ amountLabel: (tender) => `Amount in ${tender}`,
12
+ remove: (tender) => `Remove ${tender}`,
13
+ changeLabel: 'Change',
14
+ pickFirst: 'Pick a method',
15
+ missing: (amount) => `${amount} left`,
16
+ invalid: 'Invalid amount',
17
+ confirm: (amount) => `Confirm ${amount}`,
18
+ cancel: 'Cancel',
19
+ };
@@ -0,0 +1,26 @@
1
+ /**
2
+ * `@12-apps/payments-frontend/tender-split` — "how was it paid?" for money
3
+ * taken in person: the tenders as cards, an equal split as they are tapped,
4
+ * typed amounts that stay put, change for the tender that gives it.
5
+ *
6
+ * Its own entry rather than the root barrel's: it needs none of the online
7
+ * payments client, and a host asking only this question should not pull it.
8
+ */
9
+ export { TenderSplit, type TenderSplitProps } from './tender-split';
10
+ export type { TenderOption } from './tender-card';
11
+ export type { TenderSplitCopy } from './copy';
12
+ export {
13
+ parseAmount,
14
+ readTenderSplit,
15
+ shareEqually,
16
+ withPicked,
17
+ withTyped,
18
+ withoutPicked,
19
+ type TenderPick,
20
+ type TenderSplitAnswer,
21
+ type TenderSplitLeg,
22
+ type TenderSplitOutcome,
23
+ type TenderSplitState,
24
+ } from './math';
25
+ export { PT_BR_TENDER_SPLIT_COPY } from './pt-BR';
26
+ export { EN_US_TENDER_SPLIT_COPY } from './en-US';
@@ -0,0 +1,242 @@
1
+ /**
2
+ * The arithmetic behind `TenderSplit` — which part of a bill each picked
3
+ * tender carries, and what the host is sent — kept free of React so it is
4
+ * tested on its own.
5
+ *
6
+ * ## Two kinds of pick
7
+ *
8
+ * A tender the operator only TAPPED is automatic: every automatic pick takes
9
+ * an equal share of what the typed picks leave, and the cent a division cannot
10
+ * place goes to the one tapped last (100 → 50/50 → 33,33/33,33/33,34). A
11
+ * tender whose amount the operator TYPED is fixed: it is never recalculated,
12
+ * not even when it is emptied (an empty field is a typed zero), until the pick
13
+ * is removed. That is the whole rule, and it is how an amount is said out
14
+ * loud: "cinquenta no dinheiro, o resto no cartão".
15
+ *
16
+ * ## What the host is sent
17
+ *
18
+ * One leg per tender carrying money, in the order they were picked. The LAST
19
+ * automatic leg goes without an amount — it is "the rest", divided by the
20
+ * server against the total it composes, so a total that moved between this
21
+ * screen and the write lands on that leg instead of being refused. With no
22
+ * automatic leg every amount is named and they add up to the total exactly.
23
+ *
24
+ * A tender flagged `givesChange` (cash) may be typed above what the bill
25
+ * leaves: the excess is change handed back, reported beside the legs and
26
+ * taken off that leg, never sent as money received.
27
+ */
28
+
29
+ /** One picked tender: `typed` is what was typed, `null` while automatic. */
30
+ export interface TenderPick<T extends string> {
31
+ readonly tender: T;
32
+ readonly typed: string | null;
33
+ }
34
+
35
+ /** One part of the payment. No `amountCents` means "whatever is left". */
36
+ export interface TenderSplitLeg<T extends string> {
37
+ readonly tender: T;
38
+ readonly amountCents?: number;
39
+ }
40
+
41
+ /** What `TenderSplit` answers with. */
42
+ export interface TenderSplitAnswer<T extends string> {
43
+ readonly legs: readonly TenderSplitLeg<T>[];
44
+ /** Handed back to the payer; already taken off the leg that received it. */
45
+ readonly changeCents: number;
46
+ }
47
+
48
+ /**
49
+ * Where the picks stand against the bill.
50
+ *
51
+ * - `EMPTY`: nothing picked yet.
52
+ * - `INVALID`: a typed amount is not an amount.
53
+ * - `SHORT`: the picks leave part of the bill unpaid.
54
+ * - `OVER`: the picks pass the bill and no change-giving tender explains it.
55
+ * - `READY`: the bill is covered; `answer` is set.
56
+ */
57
+ export type TenderSplitOutcome = 'EMPTY' | 'INVALID' | 'SHORT' | 'OVER' | 'READY';
58
+
59
+ export interface TenderSplitState<T extends string> {
60
+ /** Cents each pick carries: its typed amount, or its automatic share. */
61
+ readonly amounts: ReadonlyMap<T, number>;
62
+ readonly launchedCents: number;
63
+ /** The bill minus what the picks carry — negative when they pass it. */
64
+ readonly restCents: number;
65
+ readonly changeCents: number;
66
+ readonly outcome: TenderSplitOutcome;
67
+ readonly answer: TenderSplitAnswer<T> | null;
68
+ }
69
+
70
+ /** Tap a tender: it joins as automatic. A tender already picked is kept. */
71
+ export function withPicked<T extends string>(
72
+ picks: readonly TenderPick<T>[],
73
+ tender: T,
74
+ ): TenderPick<T>[] {
75
+ if (picks.some((pick) => pick.tender === tender)) return [...picks];
76
+ return [...picks, { tender, typed: null }];
77
+ }
78
+
79
+ /** Remove a tender — the one way a typed amount stops being fixed. */
80
+ export function withoutPicked<T extends string>(
81
+ picks: readonly TenderPick<T>[],
82
+ tender: T,
83
+ ): TenderPick<T>[] {
84
+ return picks.filter((pick) => pick.tender !== tender);
85
+ }
86
+
87
+ /** Type into a tender's field: from here on its amount is fixed. */
88
+ export function withTyped<T extends string>(
89
+ picks: readonly TenderPick<T>[],
90
+ tender: T,
91
+ typed: string,
92
+ shown = '',
93
+ ): TenderPick<T>[] {
94
+ return picks.map((pick) =>
95
+ pick.tender === tender ? { tender, typed: firstTyping(pick, typed, shown) } : pick,
96
+ );
97
+ }
98
+
99
+ /**
100
+ * What an automatic card's field holds once the operator types into it.
101
+ *
102
+ * The field shows the card's share and selects it on focus, so a keystroke
103
+ * replaces it. Where the selection does not take (iOS Safari), the keystroke
104
+ * lands after the share instead — "41,00" + "5" → "41,005" — so on that FIRST
105
+ * edit the share still sitting in front of the new characters is dropped.
106
+ * `shown` is the share as the field displayed it.
107
+ */
108
+ function firstTyping<T extends string>(pick: TenderPick<T>, typed: string, shown: string): string {
109
+ if (pick.typed !== null || shown === '' || typed === shown) return typed;
110
+ return typed.startsWith(shown) ? typed.slice(shown.length) : typed;
111
+ }
112
+
113
+ /**
114
+ * Typed money → whole cents; `0` for an empty field, `null` for text that is
115
+ * not an amount.
116
+ *
117
+ * Reads either separator, whatever the locale: a separator followed by one or
118
+ * two final digits is the decimal one and every other is grouping, so
119
+ * "50", "50,5", "50.50", "1.234,56" and "1,234.56" all read as written. A
120
+ * currency sign in front ("R$ 12,00", "$12") is ignored; a minus is refused.
121
+ */
122
+ export function parseAmount(typed: string): number | null {
123
+ if (typed.includes('-')) return null;
124
+ const bare = typed.trim().replace(/^[^\d.,]+/u, '').replace(/\s/gu, '');
125
+ if (bare === '') return typed.trim() === '' ? 0 : null;
126
+ return /^[\d.,]+$/u.test(bare) ? centsOf(bare) : null;
127
+ }
128
+
129
+ /** Digits and separators → cents, or `null` when they are not an amount. */
130
+ function centsOf(typed: string): number | null {
131
+ // A separator typed last is a decimal on its way ("50," while typing "50,50").
132
+ const bare = typed.replace(/[.,]$/u, '');
133
+ const decimal = /[.,](\d{1,2})$/u.exec(bare);
134
+ const whole = wholeUnits(decimal === null ? bare : bare.slice(0, decimal.index));
135
+ if (whole === null || (whole === '' && decimal === null)) return null;
136
+ const fraction = decimal === null ? '00' : decimal[1]!.padEnd(2, '0');
137
+ return Number(whole || '0') * 100 + Number(fraction);
138
+ }
139
+
140
+ /** The digits before the decimal separator; grouping only in threes, so "1,2,3" is refused. */
141
+ function wholeUnits(grouped: string): string | null {
142
+ if (!/^(\d+|\d{1,3}([.,]\d{3})+)?$/u.test(grouped)) return null;
143
+ return grouped.replace(/[.,]/gu, '');
144
+ }
145
+
146
+ /** `pool` cents in `parts` equal shares; the leftover cents go to the last. */
147
+ export function shareEqually(pool: number, parts: number): number[] {
148
+ if (parts <= 0) return [];
149
+ const base = Math.floor(Math.max(0, pool) / parts);
150
+ const shares = Array.from({ length: parts }, () => base);
151
+ shares[parts - 1] = Math.max(0, pool) - base * (parts - 1);
152
+ return shares;
153
+ }
154
+
155
+ /** Where the picks stand against a bill of `totalCents`. */
156
+ export function readTenderSplit<T extends string>(
157
+ picks: readonly TenderPick<T>[],
158
+ totalCents: number,
159
+ givesChange: (tender: T) => boolean,
160
+ ): TenderSplitState<T> {
161
+ const amounts = amountsOf(picks, totalCents);
162
+ const sum = [...amounts.values()].reduce((total, cents) => total + cents, 0);
163
+ const restCents = totalCents - sum;
164
+ const base = { amounts, launchedCents: Math.min(sum, totalCents), restCents };
165
+ const outcome = outcomeOf(picks, restCents);
166
+ if (outcome === 'SHORT' || outcome === 'EMPTY' || outcome === 'INVALID') {
167
+ return { ...base, changeCents: 0, outcome, answer: null };
168
+ }
169
+ const changeFrom = outcome === 'OVER' ? changeTaker(picks, amounts, -restCents, givesChange) : null;
170
+ if (outcome === 'OVER' && changeFrom === null) {
171
+ return { ...base, changeCents: 0, outcome, answer: null };
172
+ }
173
+ const changeCents = changeFrom === null ? 0 : -restCents;
174
+ const legs = legsOf(picks, amounts, changeFrom, changeCents);
175
+ return { ...base, changeCents, outcome: 'READY', answer: { legs, changeCents } };
176
+ }
177
+
178
+ function amountsOf<T extends string>(
179
+ picks: readonly TenderPick<T>[],
180
+ totalCents: number,
181
+ ): Map<T, number> {
182
+ const typed = picks.filter((pick) => pick.typed !== null);
183
+ const typedSum = typed.reduce((sum, pick) => sum + (parseAmount(pick.typed!) ?? 0), 0);
184
+ const automatic = picks.filter((pick) => pick.typed === null);
185
+ const shares = shareEqually(totalCents - typedSum, automatic.length);
186
+ const amounts = new Map<T, number>();
187
+ for (const pick of picks) {
188
+ amounts.set(
189
+ pick.tender,
190
+ pick.typed === null ? shares[automatic.indexOf(pick)]! : parseAmount(pick.typed) ?? 0,
191
+ );
192
+ }
193
+ return amounts;
194
+ }
195
+
196
+ function outcomeOf<T extends string>(
197
+ picks: readonly TenderPick<T>[],
198
+ restCents: number,
199
+ ): TenderSplitOutcome {
200
+ if (picks.length === 0) return 'EMPTY';
201
+ if (picks.some((pick) => pick.typed !== null && parseAmount(pick.typed) === null)) {
202
+ return 'INVALID';
203
+ }
204
+ if (restCents > 0) return 'SHORT';
205
+ return restCents < 0 ? 'OVER' : 'READY';
206
+ }
207
+
208
+ /** The typed change-giving tender that received more than the excess. */
209
+ function changeTaker<T extends string>(
210
+ picks: readonly TenderPick<T>[],
211
+ amounts: ReadonlyMap<T, number>,
212
+ excess: number,
213
+ givesChange: (tender: T) => boolean,
214
+ ): T | null {
215
+ const taker = picks.find(
216
+ (pick) => pick.typed !== null && givesChange(pick.tender) && amounts.get(pick.tender)! > excess,
217
+ );
218
+ return taker?.tender ?? null;
219
+ }
220
+
221
+ function legsOf<T extends string>(
222
+ picks: readonly TenderPick<T>[],
223
+ amounts: ReadonlyMap<T, number>,
224
+ changeFrom: T | null,
225
+ changeCents: number,
226
+ ): TenderSplitLeg<T>[] {
227
+ const carrying = picks
228
+ .map((pick) => ({
229
+ pick,
230
+ cents: amounts.get(pick.tender)! - (pick.tender === changeFrom ? changeCents : 0),
231
+ }))
232
+ .filter((entry) => entry.cents > 0);
233
+ // A bill of zero leaves every tender carrying nothing; the last one tapped
234
+ // still answers, as "the rest" of nothing, which a server accepts.
235
+ if (carrying.length === 0) return [{ tender: picks[picks.length - 1]!.tender }];
236
+ const open = [...carrying].reverse().find((entry) => entry.pick.typed === null);
237
+ return carrying.map((entry) =>
238
+ entry === open
239
+ ? { tender: entry.pick.tender }
240
+ : { tender: entry.pick.tender, amountCents: entry.cents },
241
+ );
242
+ }
@@ -0,0 +1,19 @@
1
+ import type { TenderSplitCopy } from './copy';
2
+
3
+ /** `TenderSplit` in pt-BR — a named pack, never a default (see `./copy`). */
4
+ export const PT_BR_TENDER_SPLIT_COPY: TenderSplitCopy = {
5
+ launchedLabel: 'Lançado',
6
+ missingLabel: 'Falta',
7
+ coveredLabel: 'Fechou',
8
+ over: (amount) => `Passou ${amount}`,
9
+ progressLabel: 'Quanto da conta já foi lançado',
10
+ sharedHint: 'dividido',
11
+ amountLabel: (tender) => `Valor em ${tender}`,
12
+ remove: (tender) => `Tirar ${tender}`,
13
+ changeLabel: 'Troco',
14
+ pickFirst: 'Escolha a forma',
15
+ missing: (amount) => `Falta ${amount}`,
16
+ invalid: 'Valor inválido',
17
+ confirm: (amount) => `Confirmar ${amount}`,
18
+ cancel: 'Cancelar',
19
+ };
@@ -0,0 +1,198 @@
1
+ 'use client';
2
+
3
+ import { Box, ButtonBase, IconButton, InputBase, Typography } from '@mui/material';
4
+ import { alpha } from '@mui/material/styles';
5
+ import type { FocusEvent, ReactNode } from 'react';
6
+
7
+ import { CloseIcon } from '../components/checkout/icons';
8
+ import type { TenderSplitCopy } from './copy';
9
+
10
+ /**
11
+ * One tender the host takes in person.
12
+ *
13
+ * `label` and `icon` are the host's own words and glyph: which tenders a store
14
+ * takes and what it calls them is its catalogue, never this package's.
15
+ * `givesChange` marks the tender that may be typed above the bill, the excess
16
+ * being change handed back — cash, in every host so far.
17
+ */
18
+ export interface TenderOption<T extends string> {
19
+ readonly id: T;
20
+ readonly label: string;
21
+ /** The glyph on the card; `null` for a host that draws none. */
22
+ readonly icon: ReactNode;
23
+ readonly givesChange: boolean;
24
+ }
25
+
26
+ const CARD_RADIUS = 1.5;
27
+
28
+ /** A tender nobody picked yet: one tap picks it. */
29
+ export function TenderChoice<T extends string>({
30
+ tender,
31
+ testId,
32
+ autoFocus,
33
+ onPick,
34
+ }: {
35
+ tender: TenderOption<T>;
36
+ testId: string;
37
+ /** The card just removed comes back as a choice and keeps the focus. */
38
+ autoFocus: boolean;
39
+ onPick: () => void;
40
+ }) {
41
+ return (
42
+ <ButtonBase
43
+ onClick={onPick}
44
+ autoFocus={autoFocus}
45
+ data-testid={testId}
46
+ sx={{
47
+ display: 'flex',
48
+ flexDirection: 'column',
49
+ alignItems: 'flex-start',
50
+ justifyContent: 'flex-start',
51
+ gap: 0.75,
52
+ p: 1.5,
53
+ minHeight: 74,
54
+ textAlign: 'left',
55
+ borderRadius: CARD_RADIUS,
56
+ border: 1.5,
57
+ borderColor: 'divider',
58
+ color: 'text.primary',
59
+ transition: 'border-color .12s, background-color .12s',
60
+ '&:hover': { borderColor: 'primary.main' },
61
+ '&.Mui-focusVisible': { outline: 2, outlineColor: 'primary.main', outlineOffset: 2 },
62
+ }}
63
+ >
64
+ <TenderHeading tender={tender} />
65
+ </ButtonBase>
66
+ );
67
+ }
68
+
69
+ /**
70
+ * A picked tender: its amount, typed or shared, and the one control that
71
+ * lets a typed amount go.
72
+ */
73
+ export function PickedTender<T extends string>({
74
+ tender,
75
+ value,
76
+ shared,
77
+ currencySign,
78
+ copy,
79
+ testId,
80
+ autoFocus,
81
+ onType,
82
+ onRemove,
83
+ }: {
84
+ tender: TenderOption<T>;
85
+ value: string;
86
+ shared: boolean;
87
+ currencySign: string;
88
+ copy: TenderSplitCopy;
89
+ testId: string;
90
+ /** The card just picked takes the focus, so its amount can be typed at once. */
91
+ autoFocus: boolean;
92
+ onType: (typed: string) => void;
93
+ onRemove: () => void;
94
+ }) {
95
+ return (
96
+ <Box
97
+ data-testid={testId}
98
+ sx={(theme) => ({
99
+ position: 'relative',
100
+ display: 'flex',
101
+ flexDirection: 'column',
102
+ gap: 1,
103
+ p: 1.5,
104
+ minWidth: 0,
105
+ borderRadius: CARD_RADIUS,
106
+ border: 1.5,
107
+ borderColor: 'primary.main',
108
+ bgcolor: alpha(theme.palette.primary.main, 0.08),
109
+ })}
110
+ >
111
+ <TenderHeading tender={tender} />
112
+ <IconButton
113
+ size="small"
114
+ aria-label={copy.remove(tender.label)}
115
+ onClick={onRemove}
116
+ data-testid={`${testId}-remove`}
117
+ sx={{ position: 'absolute', top: (t) => t.spacing(0.5), right: (t) => t.spacing(0.5), color: 'text.secondary' }}
118
+ >
119
+ <CloseIcon fontSize="small" />
120
+ </IconButton>
121
+ <AmountField
122
+ label={copy.amountLabel(tender.label)}
123
+ value={value}
124
+ currencySign={currencySign}
125
+ autoFocus={autoFocus}
126
+ testId={`${testId}-amount`}
127
+ onType={onType}
128
+ />
129
+ {/* Under the field rather than beside it, where a narrow card had no room
130
+ for it and the amount both. */}
131
+ {shared ? (
132
+ <Typography variant="caption" color="text.secondary" sx={{ mt: -0.5 }}>
133
+ {copy.sharedHint}
134
+ </Typography>
135
+ ) : null}
136
+ </Box>
137
+ );
138
+ }
139
+
140
+ /** A picked card's amount: the currency sign in front, never squeezed. */
141
+ function AmountField({
142
+ label,
143
+ value,
144
+ currencySign,
145
+ autoFocus,
146
+ testId,
147
+ onType,
148
+ }: {
149
+ label: string;
150
+ value: string;
151
+ currencySign: string;
152
+ autoFocus: boolean;
153
+ testId: string;
154
+ onType: (typed: string) => void;
155
+ }) {
156
+ return (
157
+ <InputBase
158
+ value={value}
159
+ onChange={(event) => onType(event.target.value)}
160
+ onFocus={(event: FocusEvent<HTMLInputElement>) => event.target.select()}
161
+ autoFocus={autoFocus}
162
+ inputProps={{
163
+ inputMode: 'decimal',
164
+ 'aria-label': label,
165
+ 'data-testid': testId,
166
+ }}
167
+ startAdornment={
168
+ // Never shrunk: squeezed by the amount beside it, the sign read "R."
169
+ <Typography variant="caption" color="text.secondary" sx={{ mr: 0.5, flexShrink: 0 }}>
170
+ {currencySign}
171
+ </Typography>
172
+ }
173
+ sx={{
174
+ borderBottom: 1.5,
175
+ borderColor: 'divider',
176
+ fontWeight: 500,
177
+ fontVariantNumeric: 'tabular-nums',
178
+ '&.Mui-focused': { borderColor: 'primary.main' },
179
+ '& input': { textAlign: 'right', py: 0.375, minWidth: 0 },
180
+ }}
181
+ />
182
+ );
183
+ }
184
+
185
+ function TenderHeading<T extends string>({ tender }: { tender: TenderOption<T> }) {
186
+ return (
187
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, pr: 3, minWidth: 0 }}>
188
+ {tender.icon === null ? null : (
189
+ <Box component="span" aria-hidden sx={{ display: 'flex', color: 'primary.main' }}>
190
+ {tender.icon}
191
+ </Box>
192
+ )}
193
+ <Typography component="span" sx={{ fontWeight: 500, overflowWrap: 'anywhere' }}>
194
+ {tender.label}
195
+ </Typography>
196
+ </Box>
197
+ );
198
+ }
@@ -0,0 +1,302 @@
1
+ 'use client';
2
+
3
+ import { Box, Button, LinearProgress, Stack, Typography } from '@mui/material';
4
+ import { alpha } from '@mui/material/styles';
5
+ import { useMemo, useState } from 'react';
6
+
7
+ import type { TenderSplitCopy } from './copy';
8
+ import {
9
+ readTenderSplit,
10
+ withPicked,
11
+ withTyped,
12
+ withoutPicked,
13
+ type TenderPick,
14
+ type TenderSplitAnswer,
15
+ type TenderSplitState,
16
+ } from './math';
17
+ import { PickedTender, TenderChoice, type TenderOption } from './tender-card';
18
+
19
+ /**
20
+ * "How was it paid?" for money taken in person — one screen whatever the mix.
21
+ *
22
+ * The tenders are cards. Tapping one picks it and it takes the bill; tapping a
23
+ * second splits the bill between the two, a third in three, the leftover cent
24
+ * going to the last one tapped. Typing into a card fixes that amount, and the
25
+ * cards still automatic share what it leaves; only removing the card lets a
26
+ * typed amount go (the rule, and what is sent, are in `./math`). A bar above
27
+ * says how much is recorded and how much is missing, and the confirm names the
28
+ * same figure, so it is never a disabled button with no reason on it.
29
+ *
30
+ * The component knows no store and no language: the host passes its tenders
31
+ * (names, glyphs, which one gives change), the words, and the locale and
32
+ * currency every amount is formatted in. It renders no dialog chrome either —
33
+ * a host puts it inside its own dialog, sheet or page.
34
+ */
35
+ export interface TenderSplitProps<T extends string> {
36
+ readonly tenders: readonly TenderOption<T>[];
37
+ /** The bill, in whole cents of `currency`. */
38
+ readonly totalCents: number;
39
+ readonly copy: TenderSplitCopy;
40
+ /** BCP 47 tag every amount is formatted in, e.g. `pt-BR`. */
41
+ readonly locale: string;
42
+ /** ISO 4217 code, e.g. `BRL`. */
43
+ readonly currency: string;
44
+ readonly onConfirm: (answer: TenderSplitAnswer<T>) => void;
45
+ /** Omit to render no cancel control. */
46
+ readonly onCancel?: () => void;
47
+ /** Prefix of every test id — `<id>-option-<tender>`, `<id>-confirm`, … */
48
+ readonly dataTestId?: string;
49
+ }
50
+
51
+ export function TenderSplit<T extends string>({
52
+ tenders,
53
+ totalCents,
54
+ copy,
55
+ locale,
56
+ currency,
57
+ onConfirm,
58
+ onCancel,
59
+ dataTestId = 'tender-split',
60
+ }: TenderSplitProps<T>) {
61
+ const [picks, setPicks] = useState<TenderPick<T>[]>([]);
62
+ // The tender whose card was just removed: its choice takes the focus back,
63
+ // so a keyboard never falls to the top of the dialog.
64
+ const [removed, setRemoved] = useState<T | null>(null);
65
+ const money = useMemo(() => moneyFormats(locale, currency), [locale, currency]);
66
+ const changing = useMemo(
67
+ () => new Set(tenders.filter((tender) => tender.givesChange).map((t) => t.id)),
68
+ [tenders],
69
+ );
70
+ // A pick the host no longer offers (its list refetched mid-question) is not
71
+ // on screen, so it is not sent either.
72
+ const offered = picks.filter((pick) => tenders.some((tender) => tender.id === pick.tender));
73
+ const state = readTenderSplit(offered, totalCents, (tender) => changing.has(tender));
74
+ const lastPicked = offered[offered.length - 1]?.tender;
75
+ return (
76
+ <Stack spacing={2} data-testid={dataTestId}>
77
+ <Progress state={state} totalCents={totalCents} copy={copy} money={money} testId={dataTestId} />
78
+ <Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 1.25 }}>
79
+ {tenders.map((tender) => {
80
+ const pick = offered.find((candidate) => candidate.tender === tender.id);
81
+ const shown = money.amount(state.amounts.get(tender.id) ?? 0);
82
+ return pick === undefined ? (
83
+ <TenderChoice
84
+ key={tender.id}
85
+ tender={tender}
86
+ testId={`${dataTestId}-option-${tender.id}`}
87
+ autoFocus={tender.id === removed}
88
+ onPick={() => setPicks((current) => withPicked(current, tender.id))}
89
+ />
90
+ ) : (
91
+ <PickedTender
92
+ key={tender.id}
93
+ tender={tender}
94
+ value={pick.typed ?? shown}
95
+ shared={pick.typed === null}
96
+ currencySign={money.sign}
97
+ copy={copy}
98
+ testId={`${dataTestId}-picked-${tender.id}`}
99
+ autoFocus={tender.id === lastPicked}
100
+ onType={(typed) => setPicks((current) => withTyped(current, tender.id, typed, shown))}
101
+ onRemove={() => {
102
+ setRemoved(tender.id);
103
+ setPicks((current) => withoutPicked(current, tender.id));
104
+ }}
105
+ />
106
+ );
107
+ })}
108
+ </Box>
109
+ {state.changeCents > 0 ? (
110
+ <ChangeLine cents={state.changeCents} copy={copy} money={money} testId={dataTestId} />
111
+ ) : null}
112
+ <Actions
113
+ label={confirmText(state, totalCents, copy, money)}
114
+ onConfirm={state.answer === null ? null : () => onConfirm(state.answer!)}
115
+ onCancel={onCancel}
116
+ copy={copy}
117
+ testId={dataTestId}
118
+ />
119
+ </Stack>
120
+ );
121
+ }
122
+
123
+ function ChangeLine({
124
+ cents,
125
+ copy,
126
+ money,
127
+ testId,
128
+ }: {
129
+ cents: number;
130
+ copy: TenderSplitCopy;
131
+ money: MoneyFormats;
132
+ testId: string;
133
+ }) {
134
+ return (
135
+ <Box
136
+ data-testid={`${testId}-change`}
137
+ sx={(theme) => ({
138
+ display: 'flex',
139
+ justifyContent: 'space-between',
140
+ px: 1.5,
141
+ py: 1,
142
+ borderRadius: 1,
143
+ bgcolor: alpha(theme.palette.success.main, 0.12),
144
+ color: 'success.main',
145
+ fontWeight: 500,
146
+ fontVariantNumeric: 'tabular-nums',
147
+ })}
148
+ >
149
+ <span>{copy.changeLabel}</span>
150
+ <span>{money.full(cents)}</span>
151
+ </Box>
152
+ );
153
+ }
154
+
155
+ /** Cancel (when the host handles it) and the confirm that names the figure. */
156
+ function Actions({
157
+ label,
158
+ onConfirm,
159
+ onCancel,
160
+ copy,
161
+ testId,
162
+ }: {
163
+ label: string;
164
+ onConfirm: (() => void) | null;
165
+ onCancel: (() => void) | undefined;
166
+ copy: TenderSplitCopy;
167
+ testId: string;
168
+ }) {
169
+ return (
170
+ <Box sx={{ display: 'flex', justifyContent: onCancel ? 'space-between' : 'flex-end', gap: 1 }}>
171
+ {onCancel ? (
172
+ <Button
173
+ variant="text"
174
+ color="inherit"
175
+ onClick={onCancel}
176
+ data-testid={`${testId}-cancel`}
177
+ sx={{ textTransform: 'none' }}
178
+ >
179
+ {copy.cancel}
180
+ </Button>
181
+ ) : null}
182
+ <Button
183
+ variant="contained"
184
+ disabled={onConfirm === null}
185
+ onClick={onConfirm ?? undefined}
186
+ data-testid={`${testId}-confirm`}
187
+ // The confirm reads a sentence with a figure in it — "Falta R$ 2,00" —
188
+ // which MUI's default capitals would shout.
189
+ sx={{ textTransform: 'none' }}
190
+ >
191
+ {label}
192
+ </Button>
193
+ </Box>
194
+ );
195
+ }
196
+
197
+ interface MoneyFormats {
198
+ /** An amount with its currency sign — "R$ 82,00". */
199
+ full: (cents: number) => string;
200
+ /** An amount as it sits in a field — "82,00". */
201
+ amount: (cents: number) => string;
202
+ /** The currency sign alone — "R$". */
203
+ sign: string;
204
+ }
205
+
206
+ function moneyFormats(locale: string, currency: string): MoneyFormats {
207
+ const full = new Intl.NumberFormat(locale, { style: 'currency', currency });
208
+ const amount = new Intl.NumberFormat(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
209
+ const sign = full.formatToParts(0).find((part) => part.type === 'currency')?.value ?? currency;
210
+ return {
211
+ full: (cents) => full.format(cents / 100),
212
+ amount: (cents) => amount.format(cents / 100),
213
+ sign,
214
+ };
215
+ }
216
+
217
+ function Progress<T extends string>({
218
+ state,
219
+ totalCents,
220
+ copy,
221
+ money,
222
+ testId,
223
+ }: {
224
+ state: TenderSplitState<T>;
225
+ totalCents: number;
226
+ copy: TenderSplitCopy;
227
+ money: MoneyFormats;
228
+ testId: string;
229
+ }) {
230
+ const covered = state.outcome === 'READY';
231
+ const percent = totalCents > 0 ? Math.min(100, (state.launchedCents / totalCents) * 100) : 0;
232
+ return (
233
+ <Stack spacing={0.75}>
234
+ <LinearProgress
235
+ variant="determinate"
236
+ value={percent}
237
+ color={covered ? 'success' : 'primary'}
238
+ aria-label={copy.progressLabel}
239
+ sx={{ height: 8, borderRadius: 1 }}
240
+ />
241
+ <Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 1 }}>
242
+ <Typography variant="caption" color="text.secondary" data-testid={`${testId}-launched`}>
243
+ {copy.launchedLabel} <b>{money.full(state.launchedCents)}</b>
244
+ </Typography>
245
+ <RestLabel state={state} copy={copy} money={money} testId={testId} />
246
+ </Box>
247
+ </Stack>
248
+ );
249
+ }
250
+
251
+ function RestLabel<T extends string>({
252
+ state,
253
+ copy,
254
+ money,
255
+ testId,
256
+ }: {
257
+ state: TenderSplitState<T>;
258
+ copy: TenderSplitCopy;
259
+ money: MoneyFormats;
260
+ testId: string;
261
+ }) {
262
+ const id = `${testId}-rest`;
263
+ if (state.outcome === 'READY') {
264
+ return (
265
+ <Typography variant="caption" color="success.main" fontWeight={500} data-testid={id}>
266
+ {copy.coveredLabel}
267
+ </Typography>
268
+ );
269
+ }
270
+ if (state.restCents < 0) {
271
+ return (
272
+ <Typography variant="caption" color="error" data-testid={id}>
273
+ {copy.over(money.full(-state.restCents))}
274
+ </Typography>
275
+ );
276
+ }
277
+ return (
278
+ <Typography variant="caption" color="text.secondary" data-testid={id}>
279
+ {copy.missingLabel} <b>{money.full(state.restCents)}</b>
280
+ </Typography>
281
+ );
282
+ }
283
+
284
+ function confirmText<T extends string>(
285
+ state: TenderSplitState<T>,
286
+ totalCents: number,
287
+ copy: TenderSplitCopy,
288
+ money: MoneyFormats,
289
+ ): string {
290
+ switch (state.outcome) {
291
+ case 'EMPTY':
292
+ return copy.pickFirst;
293
+ case 'INVALID':
294
+ return copy.invalid;
295
+ case 'SHORT':
296
+ return copy.missing(money.full(state.restCents));
297
+ case 'OVER':
298
+ return copy.over(money.full(-state.restCents));
299
+ case 'READY':
300
+ return copy.confirm(money.full(totalCents));
301
+ }
302
+ }