@12-apps/payments-frontend 1.7.1 → 1.8.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 +17 -6
- package/src/components/checkout/buyer-fields.ts +117 -0
- package/src/components/checkout/buyer-info-form.tsx +154 -85
- package/src/components/checkout/card-instruments.ts +65 -21
- package/src/components/checkout/checkout-flow.tsx +9 -2
- package/src/components/checkout/checkout-steps.tsx +58 -26
- package/src/components/checkout/client-context.tsx +57 -0
- package/src/components/checkout/client.ts +18 -110
- package/src/components/checkout/method-capability.ts +60 -10
- package/src/components/checkout/navigate-context.tsx +40 -0
- package/src/components/checkout/transport.ts +191 -0
- package/src/components/checkout/types.ts +27 -0
- package/src/components/checkout/ui.tsx +15 -3
- package/src/components/checkout/use-card-checkout.ts +30 -13
- package/src/components/checkout/use-checkout-controller.ts +64 -20
- package/src/components/checkout/use-payment-polling.ts +7 -3
- package/src/flows/copy.ts +57 -0
- package/src/flows/create-payment-flows.tsx +196 -0
- package/src/flows/runtime.tsx +149 -0
- package/src/flows/screens-buyer.tsx +170 -0
- package/src/flows/screens-hosted.tsx +149 -0
- package/src/flows/screens-pay.tsx +178 -0
- package/src/flows/types.ts +179 -0
- package/src/index.ts +35 -0
package/package.json
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/payments-frontend",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"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.",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./src/index.ts"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
-
"clean": "rm -rf node_modules coverage",
|
|
10
|
+
"clean": "rm -rf node_modules coverage storybook-static",
|
|
11
11
|
"test": "node ../../../scripts/vitest-with-teardown.mjs run",
|
|
12
12
|
"test:watch": "vitest watch",
|
|
13
13
|
"lint": "eslint src --max-warnings 0",
|
|
14
14
|
"check-types": "tsc --noEmit",
|
|
15
|
-
"typecheck": "tsc --noEmit"
|
|
15
|
+
"typecheck": "tsc --noEmit",
|
|
16
|
+
"dev": "storybook dev -p 6007",
|
|
17
|
+
"storybook:build": "storybook build"
|
|
16
18
|
},
|
|
17
19
|
"dependencies": {
|
|
18
|
-
"@12-apps/payments-backend": "^1.
|
|
20
|
+
"@12-apps/payments-backend": "^1.8.1",
|
|
19
21
|
"react-qr-code": "^2.2.0"
|
|
20
22
|
},
|
|
21
23
|
"peerDependencies": {
|
|
@@ -26,20 +28,27 @@
|
|
|
26
28
|
"react-dom": ">=19.0.0"
|
|
27
29
|
},
|
|
28
30
|
"devDependencies": {
|
|
31
|
+
"@12-apps/eslint-config": "^1.9.1",
|
|
32
|
+
"@12-apps/typescript-config": "^1.9.1",
|
|
29
33
|
"@emotion/react": "^11.14.0",
|
|
30
34
|
"@emotion/styled": "^11.14.0",
|
|
31
35
|
"@mui/material": "^6.5.0",
|
|
32
|
-
"@
|
|
33
|
-
"@
|
|
36
|
+
"@storybook/addon-docs": "^9.1.10",
|
|
37
|
+
"@storybook/addon-links": "^9.1.10",
|
|
38
|
+
"@storybook/builder-vite": "^9.1.10",
|
|
39
|
+
"@storybook/react-vite": "^9.1.10",
|
|
34
40
|
"@testing-library/react": "^16.1.0",
|
|
35
41
|
"@types/react": "19.2.2",
|
|
36
42
|
"@types/react-dom": "19.2.2",
|
|
37
43
|
"eslint": "^9.39.1",
|
|
44
|
+
"eslint-plugin-storybook": "9.1.10",
|
|
38
45
|
"eslint-plugin-test-flakiness": "^1.4.0",
|
|
39
46
|
"jsdom": "^25.0.1",
|
|
40
47
|
"react": "^19.2.0",
|
|
41
48
|
"react-dom": "^19.2.0",
|
|
49
|
+
"storybook": "^9.1.10",
|
|
42
50
|
"typescript": "^5.9.2",
|
|
51
|
+
"vite": "^6.4.1",
|
|
43
52
|
"vitest": "^3.2.4"
|
|
44
53
|
},
|
|
45
54
|
"engines": {
|
|
@@ -63,6 +72,8 @@
|
|
|
63
72
|
"*.mjs",
|
|
64
73
|
"*.md",
|
|
65
74
|
"!eslint.config.js",
|
|
75
|
+
"!.storybook/**",
|
|
76
|
+
"!src/stories/**",
|
|
66
77
|
"!**/__tests__/**",
|
|
67
78
|
"!**/tests/**",
|
|
68
79
|
"!**/*.test.*",
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHAT THE BUYER MUST BE ASKED, derived from the chain's own declaration
|
|
3
|
+
* (FUT-595, wired into the browser by FUT-741).
|
|
4
|
+
*
|
|
5
|
+
* The backend has published `customerSchema` per chain entry since FUT-740 and
|
|
6
|
+
* validates every charge against it. The browser ignored it and asked for a CPF
|
|
7
|
+
* and nothing else — right for one provider, wrong for a store whose provider
|
|
8
|
+
* wants a mobile, and wrong in the expensive direction: the buyer finishes the
|
|
9
|
+
* form, presses Pagar, and gets a 400 naming a field there was never an input
|
|
10
|
+
* for. That is the third FUT-740 critical, one layer up.
|
|
11
|
+
*
|
|
12
|
+
* ## The degrade direction is the whole safety property
|
|
13
|
+
*
|
|
14
|
+
* A chain that declares nothing is NOT a chain that asks for nothing. An older
|
|
15
|
+
* host, a hand-written config, a config that failed to load — all of them
|
|
16
|
+
* arrive here as "no declaration", and the honest reading of that is "I do not
|
|
17
|
+
* know", not "nothing is needed". So the fallback is today's behaviour: CPF,
|
|
18
|
+
* required. Over-asking costs the buyer one field they may not have needed;
|
|
19
|
+
* under-asking costs them a completed form and a refused charge.
|
|
20
|
+
*
|
|
21
|
+
* ## The union, not the head
|
|
22
|
+
*
|
|
23
|
+
* Every entry the walk may reach gets a vote, because the form is filled ONCE,
|
|
24
|
+
* before the first attempt. `required` takes the strictest answer and the TYPE
|
|
25
|
+
* takes the narrower rule (`MOBILE` ⊂ `PHONE`) for the same reason: a value
|
|
26
|
+
* that satisfies only the laxer member strands the buyer on the stricter one.
|
|
27
|
+
* This mirrors the server's `unionCustomerFields` exactly — deliberately, since
|
|
28
|
+
* the two must not disagree about what a chain needs.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { validateCpf } from "../../card";
|
|
32
|
+
|
|
33
|
+
import type { CheckoutChainLink, CheckoutCustomerField, PaymentMethod } from "./types";
|
|
34
|
+
|
|
35
|
+
/** What a chain that declared nothing is assumed to need — today's behaviour. */
|
|
36
|
+
const CPF_REQUIRED: CheckoutCustomerField = { key: "taxId", type: "CPF", required: true };
|
|
37
|
+
|
|
38
|
+
/** The fields one entry asks for when charging via `method`. */
|
|
39
|
+
function fieldsOf(
|
|
40
|
+
link: CheckoutChainLink,
|
|
41
|
+
method: PaymentMethod | null,
|
|
42
|
+
): readonly CheckoutCustomerField[] {
|
|
43
|
+
const declared = link.customerSchema ?? [];
|
|
44
|
+
// No method chosen yet (the Dados step opens before the picker): collect the
|
|
45
|
+
// union across every method, which is the up-front collection FUT-595 asks
|
|
46
|
+
// for — a form that has to be re-opened after the method is picked is the
|
|
47
|
+
// double typing this exists to remove.
|
|
48
|
+
if (method === null) return declared;
|
|
49
|
+
return declared.filter((field) => !field.methods || field.methods.includes(method));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Which type accepts a SUBSET of the other's values. `MOBILE` ⊂ `PHONE`. */
|
|
53
|
+
function narrowerType(
|
|
54
|
+
a: CheckoutCustomerField["type"],
|
|
55
|
+
b: CheckoutCustomerField["type"],
|
|
56
|
+
): CheckoutCustomerField["type"] {
|
|
57
|
+
if (a === "PHONE" && b === "MOBILE") return "MOBILE";
|
|
58
|
+
return a;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Merge one declaration into the running union. */
|
|
62
|
+
function absorb(
|
|
63
|
+
byKey: Map<CheckoutCustomerField["key"], CheckoutCustomerField>,
|
|
64
|
+
field: CheckoutCustomerField,
|
|
65
|
+
): void {
|
|
66
|
+
const existing = byKey.get(field.key);
|
|
67
|
+
if (!existing) {
|
|
68
|
+
byKey.set(field.key, { key: field.key, type: field.type, required: field.required });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
byKey.set(field.key, {
|
|
72
|
+
...existing,
|
|
73
|
+
type: narrowerType(existing.type, field.type),
|
|
74
|
+
required: existing.required || field.required,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The buyer fields to collect for this chain and method.
|
|
80
|
+
*
|
|
81
|
+
* An entry with NO declaration makes the answer uncertain, so the CPF is folded
|
|
82
|
+
* in as required — see the module doc. That is deliberately not the same as
|
|
83
|
+
* "the chain is empty": an empty chain means the store cannot charge at all,
|
|
84
|
+
* and the caller renders the unavailable screen rather than a form.
|
|
85
|
+
*/
|
|
86
|
+
export function buyerFieldsFor(
|
|
87
|
+
chain: readonly CheckoutChainLink[] | undefined,
|
|
88
|
+
method: PaymentMethod | null,
|
|
89
|
+
): CheckoutCustomerField[] {
|
|
90
|
+
if (!chain || chain.length === 0) return [CPF_REQUIRED];
|
|
91
|
+
const byKey = new Map<CheckoutCustomerField["key"], CheckoutCustomerField>();
|
|
92
|
+
// An UNDECLARED entry is not a silent one. It is an entry whose requirements
|
|
93
|
+
// this browser cannot see, and the safe reading of that is the pre-FUT-595
|
|
94
|
+
// rule it would have been charged under.
|
|
95
|
+
if (chain.some((link) => link.customerSchema === undefined)) absorb(byKey, CPF_REQUIRED);
|
|
96
|
+
for (const field of chain.flatMap((link) => fieldsOf(link, method))) absorb(byKey, field);
|
|
97
|
+
return [...byKey.values()];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Whether a value satisfies one field's declared rule. */
|
|
101
|
+
const RULES: Record<CheckoutCustomerField["type"], (value: string) => boolean> = {
|
|
102
|
+
NAME: (value) => value.trim().length >= 2,
|
|
103
|
+
EMAIL: (value) => /^[^\s@]+@[^\s@]+$/.test(value) && /\.[^\s@.]+$/.test(value),
|
|
104
|
+
// DDD + an 8-digit landline or a 9-digit mobile, mirroring the server's rule.
|
|
105
|
+
PHONE: (value) => /^\d{10,11}$/.test(value.replace(/\D/g, "")),
|
|
106
|
+
MOBILE: (value) => /^\d{2}9\d{8}$/.test(value.replace(/\D/g, "")),
|
|
107
|
+
// The real check-digit rule, not a length test: the same validator the CPF
|
|
108
|
+
// input has always used, so the form and the gate cannot disagree.
|
|
109
|
+
CPF: (value) => validateCpf(value) === undefined,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** Whether a declared field is satisfied by what the buyer typed. */
|
|
113
|
+
export function fieldSatisfied(field: CheckoutCustomerField, value: string | undefined): boolean {
|
|
114
|
+
const typed = (value ?? "").trim();
|
|
115
|
+
if (!typed) return !field.required;
|
|
116
|
+
return RULES[field.type](typed);
|
|
117
|
+
}
|
|
@@ -3,7 +3,8 @@ import type { JSX } from "react";
|
|
|
3
3
|
|
|
4
4
|
import { formatCpf, validateCpf } from "../../card";
|
|
5
5
|
|
|
6
|
-
import
|
|
6
|
+
import { fieldSatisfied } from "./buyer-fields";
|
|
7
|
+
import type { BuyerField, BuyerInfo, CheckoutCustomerField } from "./types";
|
|
7
8
|
import { useCheckoutComponents } from "./ui";
|
|
8
9
|
|
|
9
10
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
@@ -18,121 +19,189 @@ interface BuyerFieldErrors {
|
|
|
18
19
|
phone?: string;
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
/** The four inputs this form can render, keyed by the schema's own key. */
|
|
23
|
+
const INPUTS = {
|
|
24
|
+
taxId: {
|
|
25
|
+
label: "CPF",
|
|
26
|
+
testId: "buyer-cpf",
|
|
27
|
+
errorKey: "cpf",
|
|
28
|
+
props: {
|
|
29
|
+
type: "text",
|
|
30
|
+
inputMode: "numeric",
|
|
31
|
+
autoComplete: "off",
|
|
32
|
+
placeholder: "000.000.000-00",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
name: { label: "Nome", testId: "buyer-name", errorKey: "name", props: { type: "text", autoComplete: "name" } },
|
|
36
|
+
email: { label: "E-mail", testId: "buyer-email", errorKey: "email", props: { type: "email", autoComplete: "email" } },
|
|
37
|
+
phone: { label: "Telefone", testId: "buyer-phone", errorKey: "phone", props: { type: "tel", autoComplete: "tel" } },
|
|
38
|
+
} as const;
|
|
39
|
+
|
|
40
|
+
type InputKey = keyof typeof INPUTS;
|
|
41
|
+
|
|
42
|
+
/** The order the inputs are shown in — CPF first, as it always has been. */
|
|
43
|
+
const FIELD_ORDER: readonly InputKey[] = ["taxId", "name", "email", "phone"];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The contact fields the checkout offers regardless of what any provider asks
|
|
47
|
+
* for: they are the RECEIPT's, not the charge's. A chain that requires none of
|
|
48
|
+
* them still gets them as optional inputs, which is what they have always been.
|
|
49
|
+
*/
|
|
50
|
+
const RECEIPT_FIELDS: readonly InputKey[] = ["name", "email", "phone"];
|
|
51
|
+
|
|
52
|
+
/** What the buyer typed for one key. */
|
|
53
|
+
function valueOf(buyer: BuyerInfo, key: InputKey): string {
|
|
54
|
+
return buyer[key] ?? "";
|
|
55
|
+
}
|
|
56
|
+
|
|
21
57
|
/**
|
|
22
58
|
* Per-field error, overlaying any server-flagged error on top of the local format
|
|
23
59
|
* checks — so a failed "pay" attempt highlights the exact input.
|
|
24
60
|
*/
|
|
25
|
-
function deriveErrors(
|
|
61
|
+
function deriveErrors(
|
|
62
|
+
value: BuyerInfo,
|
|
63
|
+
fieldError: FieldError,
|
|
64
|
+
required: ReadonlySet<InputKey>,
|
|
65
|
+
): BuyerFieldErrors {
|
|
26
66
|
const override = (field: BuyerField): string | undefined =>
|
|
27
67
|
fieldError?.field === field ? fieldError.message : undefined;
|
|
28
68
|
const localEmail =
|
|
29
69
|
value.email && !EMAIL_PATTERN.test(value.email) ? "E-mail inválido." : undefined;
|
|
70
|
+
const missing = (key: InputKey, message: string): string | undefined =>
|
|
71
|
+
required.has(key) && !valueOf(value, key).trim() ? message : undefined;
|
|
30
72
|
return {
|
|
31
73
|
cpf: override("cpf") ?? (value.taxId ? validateCpf(value.taxId) : undefined),
|
|
32
|
-
email: override("email") ?? localEmail,
|
|
33
|
-
name: override("name"),
|
|
34
|
-
phone: override("phone"),
|
|
74
|
+
email: override("email") ?? localEmail ?? missing("email", "E-mail obrigatório."),
|
|
75
|
+
name: override("name") ?? missing("name", "Nome obrigatório."),
|
|
76
|
+
phone: override("phone") ?? missing("phone", "Telefone obrigatório."),
|
|
35
77
|
};
|
|
36
78
|
}
|
|
37
79
|
|
|
38
80
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
81
|
+
* The instruction line above the inputs, worded from what the chain actually
|
|
82
|
+
* declared. It used to name the CPF unconditionally, which is wrong the moment
|
|
83
|
+
* a store's provider wants something else (or nothing).
|
|
42
84
|
*/
|
|
43
|
-
|
|
85
|
+
function instructionFor(required: ReadonlySet<InputKey>): string {
|
|
86
|
+
const names = FIELD_ORDER.filter((key) => required.has(key)).map((key) =>
|
|
87
|
+
key === "taxId" ? "CPF" : INPUTS[key].label.toLowerCase(),
|
|
88
|
+
);
|
|
89
|
+
if (names.length === 0) {
|
|
90
|
+
return "Nome, e-mail e telefone são opcionais — usados apenas para o comprovante.";
|
|
91
|
+
}
|
|
92
|
+
return (
|
|
93
|
+
`Informe seu ${names.join(", ")} (${names.length === 1 ? "obrigatório" : "obrigatórios"} ` +
|
|
94
|
+
"para o pagamento). Os demais campos são opcionais — usados apenas para o comprovante."
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Which inputs to render, and which of them are required. */
|
|
99
|
+
function resolveShape(fields: readonly CheckoutCustomerField[] | undefined): {
|
|
100
|
+
shown: InputKey[];
|
|
101
|
+
required: Set<InputKey>;
|
|
102
|
+
} {
|
|
103
|
+
// No declaration reaching this component means the pre-FUT-595 form: CPF
|
|
104
|
+
// required, receipt fields optional. `buyerFieldsFor` degrades the same way,
|
|
105
|
+
// so a caller that derived its fields from a chain lands here too.
|
|
106
|
+
const declared = fields ?? [{ key: "taxId" as const, type: "CPF" as const, required: true }];
|
|
107
|
+
const required = new Set<InputKey>(
|
|
108
|
+
declared.filter((field) => field.required).map((field) => field.key),
|
|
109
|
+
);
|
|
110
|
+
const declaredKeys = new Set<InputKey>(declared.map((field) => field.key));
|
|
111
|
+
const shown = FIELD_ORDER.filter(
|
|
112
|
+
(key) => declaredKeys.has(key) || RECEIPT_FIELDS.includes(key),
|
|
113
|
+
);
|
|
114
|
+
return { shown, required };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Whether every declared requirement is met — the "Continuar" gate's question. */
|
|
118
|
+
export function buyerFormComplete(
|
|
119
|
+
buyer: BuyerInfo,
|
|
120
|
+
fields: readonly CheckoutCustomerField[],
|
|
121
|
+
): CheckoutCustomerField | null {
|
|
122
|
+
return fields.find((field) => !fieldSatisfied(field, buyer[field.key])) ?? null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** One declaration-driven input, rendered through the host's slot. */
|
|
126
|
+
function BuyerInput({
|
|
127
|
+
fieldKey,
|
|
44
128
|
value,
|
|
129
|
+
required,
|
|
130
|
+
error,
|
|
45
131
|
onChange,
|
|
46
|
-
fieldError,
|
|
47
132
|
}: {
|
|
133
|
+
fieldKey: InputKey;
|
|
48
134
|
value: BuyerInfo;
|
|
135
|
+
required: boolean;
|
|
136
|
+
error?: string;
|
|
49
137
|
onChange: (buyer: BuyerInfo) => void;
|
|
50
|
-
fieldError?: FieldError;
|
|
51
138
|
}): JSX.Element {
|
|
52
|
-
const { Input
|
|
53
|
-
const
|
|
54
|
-
|
|
139
|
+
const { Input } = useCheckoutComponents();
|
|
140
|
+
const spec = INPUTS[fieldKey];
|
|
55
141
|
return (
|
|
56
|
-
<
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
onChange={(event) => onChange({ ...value, taxId: formatCpf(event.target.value) })}
|
|
76
|
-
data-testid="buyer-cpf"
|
|
77
|
-
/>
|
|
78
|
-
|
|
79
|
-
<OptionalContactFields value={value} onChange={onChange} errors={errors} />
|
|
80
|
-
</Box>
|
|
142
|
+
<Input
|
|
143
|
+
label={spec.label}
|
|
144
|
+
variant="outlined"
|
|
145
|
+
size="md"
|
|
146
|
+
fullWidth
|
|
147
|
+
required={required}
|
|
148
|
+
{...spec.props}
|
|
149
|
+
value={valueOf(value, fieldKey)}
|
|
150
|
+
error={Boolean(error)}
|
|
151
|
+
helperText={error}
|
|
152
|
+
onChange={(event) =>
|
|
153
|
+
onChange({
|
|
154
|
+
...value,
|
|
155
|
+
// The CPF keeps its progressive mask; everything else is taken as typed.
|
|
156
|
+
[fieldKey]: fieldKey === "taxId" ? formatCpf(event.target.value) : event.target.value,
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
data-testid={spec.testId}
|
|
160
|
+
/>
|
|
81
161
|
);
|
|
82
162
|
}
|
|
83
163
|
|
|
84
|
-
/**
|
|
85
|
-
|
|
164
|
+
/**
|
|
165
|
+
* Buyer contact at checkout, with WHICH fields are required decided by the
|
|
166
|
+
* store's provider chain (FUT-595) rather than hard-coded.
|
|
167
|
+
*
|
|
168
|
+
* Passing no `fields` is the pre-FUT-595 form, verbatim: CPF required, name /
|
|
169
|
+
* e-mail / telefone optional and used only for the receipt. That is also what
|
|
170
|
+
* `buyerFieldsFor` produces for a chain that declares nothing, so a caller
|
|
171
|
+
* cannot accidentally land on "ask nothing".
|
|
172
|
+
*/
|
|
173
|
+
export function BuyerInfoForm({
|
|
86
174
|
value,
|
|
87
175
|
onChange,
|
|
88
|
-
|
|
176
|
+
fieldError,
|
|
177
|
+
fields,
|
|
89
178
|
}: {
|
|
90
179
|
value: BuyerInfo;
|
|
91
180
|
onChange: (buyer: BuyerInfo) => void;
|
|
92
|
-
|
|
181
|
+
fieldError?: FieldError;
|
|
182
|
+
/** The chain's resolved declaration; absent ⇒ CPF-required (never "nothing"). */
|
|
183
|
+
fields?: readonly CheckoutCustomerField[];
|
|
93
184
|
}): JSX.Element {
|
|
94
|
-
const {
|
|
185
|
+
const { Text } = useCheckoutComponents();
|
|
186
|
+
const { shown, required } = resolveShape(fields);
|
|
187
|
+
const errors = deriveErrors(value, fieldError ?? null, required);
|
|
188
|
+
|
|
95
189
|
return (
|
|
96
|
-
|
|
97
|
-
<
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
type="email"
|
|
113
|
-
variant="outlined"
|
|
114
|
-
size="md"
|
|
115
|
-
fullWidth
|
|
116
|
-
autoComplete="email"
|
|
117
|
-
value={value.email ?? ""}
|
|
118
|
-
error={Boolean(errors.email)}
|
|
119
|
-
helperText={errors.email}
|
|
120
|
-
onChange={(event) => onChange({ ...value, email: event.target.value })}
|
|
121
|
-
data-testid="buyer-email"
|
|
122
|
-
/>
|
|
123
|
-
<Input
|
|
124
|
-
label="Telefone"
|
|
125
|
-
type="tel"
|
|
126
|
-
variant="outlined"
|
|
127
|
-
size="md"
|
|
128
|
-
fullWidth
|
|
129
|
-
autoComplete="tel"
|
|
130
|
-
value={value.phone ?? ""}
|
|
131
|
-
error={Boolean(errors.phone)}
|
|
132
|
-
helperText={errors.phone}
|
|
133
|
-
onChange={(event) => onChange({ ...value, phone: event.target.value })}
|
|
134
|
-
data-testid="buyer-phone"
|
|
135
|
-
/>
|
|
136
|
-
</>
|
|
190
|
+
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
|
191
|
+
<Text variant="caption" size="xs" color="secondary" as="p">
|
|
192
|
+
{instructionFor(required)}
|
|
193
|
+
</Text>
|
|
194
|
+
|
|
195
|
+
{shown.map((key) => (
|
|
196
|
+
<BuyerInput
|
|
197
|
+
key={key}
|
|
198
|
+
fieldKey={key}
|
|
199
|
+
value={value}
|
|
200
|
+
required={required.has(key)}
|
|
201
|
+
error={errors[INPUTS[key].errorKey]}
|
|
202
|
+
onChange={onChange}
|
|
203
|
+
/>
|
|
204
|
+
))}
|
|
205
|
+
</Box>
|
|
137
206
|
);
|
|
138
207
|
}
|
|
@@ -11,6 +11,17 @@ import { refreshCardPublicKey } from "./client";
|
|
|
11
11
|
import type { CardChainLink } from "./method-capability";
|
|
12
12
|
import type { SavedCardMeta } from "./types";
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* The order-scoped key refresh, as a parameter (FUT-741).
|
|
16
|
+
*
|
|
17
|
+
* The self-heal is a call to OUR OWN mount, so it has to go through whichever
|
|
18
|
+
* transport the surrounding checkout was bound to. Defaulted to the unbound
|
|
19
|
+
* module function, which is exactly what it always called.
|
|
20
|
+
*/
|
|
21
|
+
export type RefreshBrowserKey = (input: {
|
|
22
|
+
orderId: string;
|
|
23
|
+
}) => Promise<Result<{ publicKey: string | null }>>;
|
|
24
|
+
|
|
14
25
|
/**
|
|
15
26
|
* TOKENIZATION for the buyer's card — one instrument per provider the charge
|
|
16
27
|
* may reach (FUT-563).
|
|
@@ -39,12 +50,13 @@ async function tokenizeNewCard(
|
|
|
39
50
|
config: CardTokenizationConfig,
|
|
40
51
|
orderId: string,
|
|
41
52
|
onKeyRefreshed: (key: string) => void,
|
|
53
|
+
refreshKey: RefreshBrowserKey,
|
|
42
54
|
): Promise<Result<CardToken>> {
|
|
43
55
|
const first = await tokenizeForCheckout(card, config);
|
|
44
56
|
if (first.ok || !config.publicKey) return first;
|
|
45
57
|
if (config.provider === null || tokenizerFor(config.provider) !== "pagbank-sdk") return first;
|
|
46
58
|
|
|
47
|
-
const refreshed = await
|
|
59
|
+
const refreshed = await refreshKey({ orderId });
|
|
48
60
|
if (refreshed.ok && refreshed.data.publicKey && refreshed.data.publicKey !== config.publicKey) {
|
|
49
61
|
onKeyRefreshed(refreshed.data.publicKey);
|
|
50
62
|
return tokenizeForCheckout(card, { ...config, publicKey: refreshed.data.publicKey });
|
|
@@ -166,6 +178,46 @@ function mintingConfig(
|
|
|
166
178
|
return mintable.some((link) => link.provider === config.provider) ? config : mintable[0]!;
|
|
167
179
|
}
|
|
168
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Mint for the whole chain at once, and say which entry the bare token is from.
|
|
183
|
+
*
|
|
184
|
+
* Head and tail mint CONCURRENTLY. Sequentially, one unreachable backup
|
|
185
|
+
* acquirer held a healthy head's charge for as long as the network stack
|
|
186
|
+
* allowed — the failover feature blocking on the provider it exists to fall
|
|
187
|
+
* back to. The head keeps no deadline: it is the provider being paid, and its
|
|
188
|
+
* self-heal is a second round trip of our own. That self-heal also stays
|
|
189
|
+
* PagBank-only — `tokenizeNewCard` gates on the scheme — so a chain headed
|
|
190
|
+
* elsewhere cannot ask for somebody else's key.
|
|
191
|
+
*/
|
|
192
|
+
async function mintEveryEntry(input: {
|
|
193
|
+
card: CardDetails;
|
|
194
|
+
entries: readonly CardChainLink[];
|
|
195
|
+
config: CardTokenizationConfig;
|
|
196
|
+
orderId: string;
|
|
197
|
+
onKeyRefreshed: (key: string) => void;
|
|
198
|
+
refreshKey: RefreshBrowserKey;
|
|
199
|
+
timeoutMs: number;
|
|
200
|
+
}): Promise<{ headToken: Result<CardToken>; minted: Record<string, CardToken> }> {
|
|
201
|
+
const head = mintingConfig(input.config, input.entries);
|
|
202
|
+
const rest = input.entries.filter((link) => link.provider !== head.provider);
|
|
203
|
+
const [headToken, tail] = await Promise.all([
|
|
204
|
+
tokenizeNewCard(input.card, head, input.orderId, input.onKeyRefreshed, input.refreshKey),
|
|
205
|
+
mintChainInstruments(input.card, rest, input.timeoutMs),
|
|
206
|
+
]);
|
|
207
|
+
// A failure in the tail is not fatal: that provider is simply one the walk
|
|
208
|
+
// will skip.
|
|
209
|
+
const minted = { ...tail };
|
|
210
|
+
if (headToken.ok && head.provider) minted[head.provider] = headToken.data;
|
|
211
|
+
return { headToken, minted };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The instruments, reduced to the provider→token map the charge body carries. */
|
|
215
|
+
function tokenMapOf(minted: Record<string, CardToken>): Record<string, string> {
|
|
216
|
+
return Object.fromEntries(
|
|
217
|
+
Object.entries(minted).map(([provider, instrument]) => [provider, instrument.token]),
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
169
221
|
/** The charge token for a new card (tokenize + self-heal), plus optional save-meta. */
|
|
170
222
|
export async function resolveNewCardToken(
|
|
171
223
|
card: CardDetails,
|
|
@@ -176,36 +228,28 @@ export async function resolveNewCardToken(
|
|
|
176
228
|
chain: readonly CardChainLink[],
|
|
177
229
|
/** Per-entry mint deadline. Overridable so tests need not wait it out. */
|
|
178
230
|
timeoutMs: number = MINT_TIMEOUT_MS,
|
|
231
|
+
/** The bound key refresh (FUT-741); defaults to the unbound module call. */
|
|
232
|
+
refreshKey: RefreshBrowserKey = refreshCardPublicKey,
|
|
179
233
|
): Promise<Result<CardInstruments>> {
|
|
180
234
|
// No chain served (an older host, or a fetch blip): the active provider
|
|
181
235
|
// alone, exactly the pre-FUT-563 behaviour.
|
|
182
236
|
const entries = chain.length > 0 ? chain : [{ ...config, mintable: true }];
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const [headToken, tail] = await Promise.all([
|
|
193
|
-
tokenizeNewCard(card, head, orderId, onKeyRefreshed),
|
|
194
|
-
mintChainInstruments(card, rest, timeoutMs),
|
|
195
|
-
]);
|
|
196
|
-
// A failure in the tail is not fatal: that provider is simply one the walk
|
|
197
|
-
// will skip.
|
|
198
|
-
const minted = { ...tail };
|
|
199
|
-
if (headToken.ok && head.provider) minted[head.provider] = headToken.data;
|
|
237
|
+
const { headToken, minted } = await mintEveryEntry({
|
|
238
|
+
card,
|
|
239
|
+
entries,
|
|
240
|
+
config,
|
|
241
|
+
orderId,
|
|
242
|
+
onKeyRefreshed,
|
|
243
|
+
refreshKey,
|
|
244
|
+
timeoutMs,
|
|
245
|
+
});
|
|
200
246
|
|
|
201
247
|
// Refused only when NO entry could be minted for. While one still can, the
|
|
202
248
|
// charge goes out and the entries we hold nothing for are skipped by name.
|
|
203
249
|
const anyMinted = Object.values(minted);
|
|
204
250
|
if (!headToken.ok && anyMinted.length === 0) return headToken;
|
|
205
251
|
const primary = headToken.ok ? headToken.data : anyMinted[0]!;
|
|
206
|
-
const tokensByProvider =
|
|
207
|
-
Object.entries(minted).map(([provider, instrument]) => [provider, instrument.token]),
|
|
208
|
-
);
|
|
252
|
+
const tokensByProvider = tokenMapOf(minted);
|
|
209
253
|
return ok({
|
|
210
254
|
token: primary.token,
|
|
211
255
|
// Sent whenever the WALK has more than one provider to reach — counted on
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Box } from "@mui/material";
|
|
2
|
-
import type
|
|
2
|
+
import { useMemo, type JSX, type ReactNode } from "react";
|
|
3
3
|
|
|
4
|
+
import { buyerFieldsFor } from "./buyer-fields";
|
|
4
5
|
import { DadosStep, EmptyCart, PaymentStep } from "./checkout-steps";
|
|
5
6
|
import { ArrowBackIcon } from "./icons";
|
|
6
7
|
import { PaymentStatus } from "./payment-status";
|
|
@@ -118,7 +119,12 @@ function ProgressHeader({ step, completed }: { step: string; completed: Set<stri
|
|
|
118
119
|
*/
|
|
119
120
|
function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Element {
|
|
120
121
|
const { cart, defaultBuyer, comanda, taxIdOnFile = false, providerConfig, tenantSlug, confirmationExtra, ...ports } = props;
|
|
121
|
-
|
|
122
|
+
// Resolved for NO method on purpose (FUT-595): the Dados step opens before
|
|
123
|
+
// the picker, and the form is filled once — so it asks for the union of what
|
|
124
|
+
// any chain member may need rather than re-opening after the choice. A chain
|
|
125
|
+
// that declares nothing degrades to CPF-required, never to "ask nothing".
|
|
126
|
+
const buyerFields = useMemo(() => buyerFieldsFor(providerConfig?.chain, null), [providerConfig]);
|
|
127
|
+
const c = useCheckoutController(ports, defaultBuyer, taxIdOnFile, buyerFields);
|
|
122
128
|
|
|
123
129
|
// A comanda settlement pays already-sent kitchen items — the cart is
|
|
124
130
|
// legitimately empty here, so the empty-cart guard only applies to cart mode.
|
|
@@ -147,6 +153,7 @@ function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Ele
|
|
|
147
153
|
errorField={c.errorField}
|
|
148
154
|
onContinue={c.goToPayment}
|
|
149
155
|
cartTotals={cart}
|
|
156
|
+
buyerFields={buyerFields}
|
|
150
157
|
discountLines={cart.discountLines}
|
|
151
158
|
totalOverride={comandaTotalOverride(comanda)}
|
|
152
159
|
/>
|