@12-apps/payments-frontend 2.0.0 → 3.0.0
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 +2 -2
- package/src/client.ts +31 -1
- package/src/components/ConnectionCard.tsx +234 -0
- package/src/components/CredentialFieldStack.tsx +55 -0
- package/src/components/CredentialFields.tsx +131 -19
- package/src/components/CredentialFormAlerts.tsx +37 -15
- package/src/components/EnvironmentTabs.tsx +72 -23
- package/src/components/OAuthPanel.tsx +166 -0
- package/src/components/PaymentProviderSettings.tsx +23 -3
- package/src/components/ProviderConnection.tsx +119 -77
- package/src/components/ProviderCredentialForm.tsx +40 -56
- package/src/components/ProviderPanel.tsx +54 -111
- package/src/components/ProviderSetupGuide.tsx +174 -54
- package/src/components/ProviderStatusBar.tsx +104 -24
- package/src/components/checkout/hosted-return.ts +8 -25
- package/src/components/credential-rules.ts +48 -12
- package/src/components/panel-tokens.ts +166 -0
- package/src/index.ts +3 -3
|
@@ -6,11 +6,14 @@ import {
|
|
|
6
6
|
Button,
|
|
7
7
|
IconButton,
|
|
8
8
|
Link,
|
|
9
|
-
Paper,
|
|
10
9
|
Stack,
|
|
11
10
|
Step,
|
|
11
|
+
StepConnector,
|
|
12
|
+
stepConnectorClasses,
|
|
12
13
|
StepLabel,
|
|
13
14
|
Stepper,
|
|
15
|
+
styled,
|
|
16
|
+
type StepIconProps,
|
|
14
17
|
TextField,
|
|
15
18
|
Typography,
|
|
16
19
|
} from '@mui/material';
|
|
@@ -18,6 +21,15 @@ import { useState, type ReactNode } from 'react';
|
|
|
18
21
|
|
|
19
22
|
import type { ProviderSetupGuide as Guide, SetupSection, SetupStep } from '@12-apps/payments-backend';
|
|
20
23
|
|
|
24
|
+
import {
|
|
25
|
+
BAR_MSG_SX,
|
|
26
|
+
BAR_SX,
|
|
27
|
+
BTN_PRIMARY_SX,
|
|
28
|
+
BTN_SECONDARY_SX,
|
|
29
|
+
PANEL_SX,
|
|
30
|
+
T,
|
|
31
|
+
} from './panel-tokens';
|
|
32
|
+
|
|
21
33
|
import { richText } from './rich-text';
|
|
22
34
|
|
|
23
35
|
/**
|
|
@@ -122,6 +134,39 @@ const BUTTON_SX = { textTransform: 'none' } as const;
|
|
|
122
134
|
|
|
123
135
|
type StepActions = ProviderSetupGuideProps['actions'];
|
|
124
136
|
|
|
137
|
+
/** A step's sentence, with the provider's own reference inline after it. */
|
|
138
|
+
function StepText({ text, link }: { text?: string; link?: SetupStep['link'] }) {
|
|
139
|
+
if (!text) return null;
|
|
140
|
+
return (
|
|
141
|
+
<Typography sx={{ fontSize: '13px', color: T.ink2, lineHeight: 1.5 }}>
|
|
142
|
+
{richText(text)}{' '}
|
|
143
|
+
{link ? (
|
|
144
|
+
<Link href={link.url} target="_blank" rel="noreferrer">
|
|
145
|
+
{link.label}
|
|
146
|
+
</Link>
|
|
147
|
+
) : null}
|
|
148
|
+
</Typography>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The panel's action bar: what this step is asking, and the button that answers.
|
|
154
|
+
*
|
|
155
|
+
* The sentence is not decoration. This is the one step no API can report, so
|
|
156
|
+
* the owner is being asked to vouch for work done somewhere else — and a bare
|
|
157
|
+
* button gives them nothing to weigh that against.
|
|
158
|
+
*/
|
|
159
|
+
function ConfirmBar({ action }: { action: { label: string; run: () => void } }) {
|
|
160
|
+
return (
|
|
161
|
+
<Box sx={BAR_SX} data-testid="payments-setup-confirm-bar">
|
|
162
|
+
<Typography sx={BAR_MSG_SX}>Confirme quando terminar do lado do provedor.</Typography>
|
|
163
|
+
<Button variant="contained" disableElevation sx={BTN_PRIMARY_SX} onClick={() => action.run()}>
|
|
164
|
+
{action.label}
|
|
165
|
+
</Button>
|
|
166
|
+
</Box>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
125
170
|
interface StepRowProps {
|
|
126
171
|
step: SetupStep;
|
|
127
172
|
actions: StepActions;
|
|
@@ -153,37 +198,21 @@ function WarningRow({ text }: { text: string }) {
|
|
|
153
198
|
function StepRow({ step, actions }: StepRowProps) {
|
|
154
199
|
const action = step.action ? actions?.[step.action] : undefined;
|
|
155
200
|
if (step.tone === 'warning') return <WarningRow text={step.text ?? ''} />;
|
|
201
|
+
// An action-only step IS the panel's action bar — see `SectionCard`.
|
|
202
|
+
if (action && !step.text) return <ConfirmBar action={action} />;
|
|
203
|
+
// A bordered row with the work on the left and the way to do it on the
|
|
204
|
+
// right, so a step reads as a thing to tick off rather than as a paragraph.
|
|
205
|
+
// The instructions on this screen are a CHECKLIST — each one is a piece of
|
|
206
|
+
// work the owner does somewhere else and comes back from.
|
|
156
207
|
return (
|
|
157
|
-
<Stack
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
) : null}
|
|
166
|
-
</Typography>
|
|
167
|
-
) : null}
|
|
168
|
-
{step.button ? (
|
|
169
|
-
<Box>
|
|
170
|
-
<Button
|
|
171
|
-
variant="outlined"
|
|
172
|
-
size="small"
|
|
173
|
-
href={step.button.url}
|
|
174
|
-
target="_blank"
|
|
175
|
-
rel="noreferrer"
|
|
176
|
-
sx={BUTTON_SX}
|
|
177
|
-
// The mark is the promise: this leaves the store and opens the
|
|
178
|
-
// provider's site. A button that reads the same as the in-page ones
|
|
179
|
-
// and then navigates away is a small betrayal, and here it lands on
|
|
180
|
-
// a screen that can CHANGE the tag.
|
|
181
|
-
endIcon={<Box component="span" aria-hidden sx={{ fontSize: '0.9em' }}>↗</Box>}
|
|
182
|
-
>
|
|
183
|
-
{step.button.label}
|
|
184
|
-
</Button>
|
|
185
|
-
</Box>
|
|
186
|
-
) : null}
|
|
208
|
+
<Stack
|
|
209
|
+
direction="row"
|
|
210
|
+
gap="12px"
|
|
211
|
+
alignItems="flex-start"
|
|
212
|
+
sx={{ border: `1px solid ${T.line}`, borderRadius: '9px', px: '14px', py: '12px' }}
|
|
213
|
+
>
|
|
214
|
+
<Stack spacing={1} sx={{ flex: 1, minWidth: 0 }}>
|
|
215
|
+
<StepText text={step.text} link={step.link} />
|
|
187
216
|
{action ? (
|
|
188
217
|
<Box>
|
|
189
218
|
<Button variant="contained" size="small" sx={BUTTON_SX} onClick={() => void action.run()}>
|
|
@@ -191,17 +220,88 @@ function StepRow({ step, actions }: StepRowProps) {
|
|
|
191
220
|
</Button>
|
|
192
221
|
</Box>
|
|
193
222
|
) : null}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
223
|
+
{step.copy ? (
|
|
224
|
+
<CopyField
|
|
225
|
+
label={step.copy.label}
|
|
226
|
+
text={step.copy.text}
|
|
227
|
+
collapsible={step.copy.collapsible}
|
|
228
|
+
/>
|
|
229
|
+
) : null}
|
|
230
|
+
</Stack>
|
|
231
|
+
{step.button ? (
|
|
232
|
+
<Button
|
|
233
|
+
size="small"
|
|
234
|
+
href={step.button.url}
|
|
235
|
+
target="_blank"
|
|
236
|
+
rel="noreferrer"
|
|
237
|
+
sx={{ ...BTN_SECONDARY_SX, px: '12px', py: '7px', fontSize: '12px', flexShrink: 0 }}
|
|
238
|
+
// The mark is the promise: this leaves the store and opens the
|
|
239
|
+
// provider's site. A button that reads the same as the in-page ones
|
|
240
|
+
// and then navigates away is a small betrayal, and here it lands on
|
|
241
|
+
// a screen that can CHANGE where the money goes.
|
|
242
|
+
endIcon={
|
|
243
|
+
<Box component="span" aria-hidden sx={{ fontSize: '0.9em' }}>
|
|
244
|
+
↗
|
|
245
|
+
</Box>
|
|
246
|
+
}
|
|
247
|
+
>
|
|
248
|
+
{step.button.label}
|
|
249
|
+
</Button>
|
|
200
250
|
) : null}
|
|
201
251
|
</Stack>
|
|
202
252
|
);
|
|
203
253
|
}
|
|
204
254
|
|
|
255
|
+
/**
|
|
256
|
+
* The numbered dot: 24px, filled once the store is ON or PAST the step.
|
|
257
|
+
*
|
|
258
|
+
* MUI's own icon is a 24px circle with the number inside and the same fill for
|
|
259
|
+
* active and completed, which is nearly the prototype — the differences are the
|
|
260
|
+
* exact greys and the ✓ on a finished step, and on a screen whose whole job is
|
|
261
|
+
* "where am I" those are the two things that carry the answer.
|
|
262
|
+
*/
|
|
263
|
+
function StageIcon({ active, completed, icon }: StepIconProps) {
|
|
264
|
+
const filled = active || completed;
|
|
265
|
+
return (
|
|
266
|
+
<Box
|
|
267
|
+
sx={{
|
|
268
|
+
width: 24,
|
|
269
|
+
height: 24,
|
|
270
|
+
borderRadius: '50%',
|
|
271
|
+
background: filled ? T.brand : '#d9dbe1',
|
|
272
|
+
color: '#fff',
|
|
273
|
+
fontSize: '12px',
|
|
274
|
+
fontWeight: 700,
|
|
275
|
+
display: 'grid',
|
|
276
|
+
placeItems: 'center',
|
|
277
|
+
}}
|
|
278
|
+
>
|
|
279
|
+
{completed ? '✓' : icon}
|
|
280
|
+
</Box>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** A 2px rule that turns brand-coloured behind the steps already passed. */
|
|
285
|
+
const StageConnector = styled(StepConnector)({
|
|
286
|
+
top: 11,
|
|
287
|
+
[`& .${stepConnectorClasses.line}`]: { borderTopWidth: 2, borderColor: T.line },
|
|
288
|
+
[`&.${stepConnectorClasses.active} .${stepConnectorClasses.line}`]: { borderColor: T.brandLine },
|
|
289
|
+
[`&.${stepConnectorClasses.completed} .${stepConnectorClasses.line}`]: {
|
|
290
|
+
borderColor: T.brandLine,
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const STAGE_LABEL_SX = {
|
|
295
|
+
'& .MuiStepLabel-label': {
|
|
296
|
+
fontSize: '12px',
|
|
297
|
+
color: T.ink3,
|
|
298
|
+
lineHeight: 1.25,
|
|
299
|
+
mt: '7px !important',
|
|
300
|
+
'&.Mui-active': { color: T.ink, fontWeight: 650 },
|
|
301
|
+
'&.Mui-completed': { color: T.ink3, fontWeight: 400 },
|
|
302
|
+
},
|
|
303
|
+
} as const;
|
|
304
|
+
|
|
205
305
|
export function ProviderSetupGuide({
|
|
206
306
|
guide,
|
|
207
307
|
activeStage = 0,
|
|
@@ -210,11 +310,18 @@ export function ProviderSetupGuide({
|
|
|
210
310
|
sectionFooter,
|
|
211
311
|
}: ProviderSetupGuideProps) {
|
|
212
312
|
return (
|
|
213
|
-
<Stack spacing={
|
|
214
|
-
<Stepper
|
|
215
|
-
{
|
|
216
|
-
|
|
217
|
-
|
|
313
|
+
<Stack spacing={0} data-testid="payments-setup-guide">
|
|
314
|
+
<Stepper
|
|
315
|
+
activeStep={activeStage}
|
|
316
|
+
alternativeLabel
|
|
317
|
+
connector={<StageConnector />}
|
|
318
|
+
sx={{ px: '20px', pt: '6px', pb: '18px' }}
|
|
319
|
+
>
|
|
320
|
+
{guide.stages.map((stage, index) => (
|
|
321
|
+
<Step key={stage.id} completed={index < activeStage}>
|
|
322
|
+
<StepLabel slots={{ stepIcon: StageIcon }} sx={STAGE_LABEL_SX}>
|
|
323
|
+
{stage.label}
|
|
324
|
+
</StepLabel>
|
|
218
325
|
</Step>
|
|
219
326
|
))}
|
|
220
327
|
</Stepper>
|
|
@@ -235,29 +342,42 @@ function SectionCard({
|
|
|
235
342
|
actions: StepActions;
|
|
236
343
|
footer?: ReactNode;
|
|
237
344
|
}) {
|
|
345
|
+
// The step whose completion only the OWNER can report is not a row among the
|
|
346
|
+
// instructions — it is what this panel is FOR. It moves to the action bar, so
|
|
347
|
+
// the control the owner is working toward is the last thing in the block and
|
|
348
|
+
// stays on screen while they read the steps above it.
|
|
349
|
+
const asks = section.steps.filter((step) => step.action !== undefined);
|
|
350
|
+
const reads = section.steps.filter((step) => step.action === undefined);
|
|
351
|
+
|
|
238
352
|
return (
|
|
239
|
-
<
|
|
240
|
-
|
|
241
|
-
sx={{ p: 2 }}
|
|
353
|
+
<Box
|
|
354
|
+
sx={PANEL_SX}
|
|
242
355
|
// Which section is showing is now a FACT about the store's progress, not
|
|
243
356
|
// a constant, so it needs to be assertable by id rather than by matching
|
|
244
357
|
// the prose inside it.
|
|
245
358
|
data-testid={`payments-setup-section-${section.id}`}
|
|
246
359
|
>
|
|
247
|
-
<
|
|
248
|
-
<Typography
|
|
360
|
+
<Box sx={{ px: '18px', pt: '15px' }}>
|
|
361
|
+
<Typography sx={{ fontSize: '14.5px', fontWeight: 700, color: T.ink }}>
|
|
249
362
|
{section.title}
|
|
250
363
|
</Typography>
|
|
251
364
|
{section.intro ? (
|
|
252
|
-
<Typography
|
|
365
|
+
<Typography sx={{ fontSize: '12.5px', color: T.ink3, mt: '5px', lineHeight: 1.5 }}>
|
|
253
366
|
{richText(section.intro)}
|
|
254
367
|
</Typography>
|
|
255
368
|
) : null}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
369
|
+
</Box>
|
|
370
|
+
<Box sx={{ px: '18px', pt: '14px', pb: '18px' }}>
|
|
371
|
+
<Stack spacing={1.5}>
|
|
372
|
+
{reads.map((step, index) => (
|
|
373
|
+
<StepRow key={index} step={step} actions={actions} />
|
|
374
|
+
))}
|
|
375
|
+
{footer}
|
|
376
|
+
</Stack>
|
|
377
|
+
</Box>
|
|
378
|
+
{asks.map((step, index) => (
|
|
379
|
+
<StepRow key={`ask-${index}`} step={step} actions={actions} />
|
|
380
|
+
))}
|
|
381
|
+
</Box>
|
|
262
382
|
);
|
|
263
383
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { Box,
|
|
3
|
+
import { Box, Stack, Switch, Tooltip, Typography } from '@mui/material';
|
|
4
4
|
|
|
5
5
|
import type { MaskedProviderConfig, ProviderDescriptor } from '@12-apps/payments-backend';
|
|
6
6
|
|
|
7
7
|
import { isConnected } from './connection-state';
|
|
8
|
+
import { T } from './panel-tokens';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* The provider's headline: what state it is in, and the switch that decides
|
|
@@ -124,6 +125,36 @@ function toggleGate(
|
|
|
124
125
|
};
|
|
125
126
|
}
|
|
126
127
|
|
|
128
|
+
/**
|
|
129
|
+
* The three sentences the header can be saying, chosen once.
|
|
130
|
+
*
|
|
131
|
+
* Proven-but-off is its OWN state. "Não está recebendo" is true of a store that
|
|
132
|
+
* never finished setup AND of one that finished and paused, and those are
|
|
133
|
+
* opposite situations: the first is a step outstanding, the second a decision
|
|
134
|
+
* the owner made and can undo in one click.
|
|
135
|
+
*/
|
|
136
|
+
function headline(io: { enabled: boolean; paused: boolean; lockedOff: boolean; hint: string }) {
|
|
137
|
+
if (io.enabled) {
|
|
138
|
+
return {
|
|
139
|
+
state: 'Recebendo vendas',
|
|
140
|
+
sub: 'Sua loja está recebendo por este provedor.',
|
|
141
|
+
tone: T.ok,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
if (io.paused) {
|
|
145
|
+
return {
|
|
146
|
+
state: 'Pausado',
|
|
147
|
+
sub: 'Conexão pronta e pausada por você — nenhum pedido novo é cobrado aqui.',
|
|
148
|
+
tone: T.warn,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
state: 'Ainda não está recebendo',
|
|
153
|
+
sub: io.lockedOff ? io.hint : 'Tudo pronto — ligue a chave para começar a receber.',
|
|
154
|
+
tone: T.ink3,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
127
158
|
/**
|
|
128
159
|
* Status chip + the "recebendo vendas" switch, hoisted OUT of the credential
|
|
129
160
|
* form.
|
|
@@ -142,6 +173,52 @@ function toggleGate(
|
|
|
142
173
|
* you hover it. The reason is printed underneath instead of hidden in a
|
|
143
174
|
* tooltip, since the owner who most needs it is the one who cannot click.
|
|
144
175
|
*/
|
|
176
|
+
/**
|
|
177
|
+
* The provider's name and its status chip, side by side.
|
|
178
|
+
*
|
|
179
|
+
* The name is a REAL heading, not a div sized to look like one: it is what this
|
|
180
|
+
* screen is about, and it is how a screen reader — and the harness — identifies
|
|
181
|
+
* the page it landed on. The restyle set the size by hand and took the element
|
|
182
|
+
* with it, which no unit test could see and the harness caught at once.
|
|
183
|
+
*/
|
|
184
|
+
function ProviderName({
|
|
185
|
+
displayName,
|
|
186
|
+
label,
|
|
187
|
+
proven,
|
|
188
|
+
}: {
|
|
189
|
+
displayName: string;
|
|
190
|
+
label: string;
|
|
191
|
+
proven: boolean;
|
|
192
|
+
}) {
|
|
193
|
+
return (
|
|
194
|
+
<Stack direction="row" alignItems="center" gap="9px">
|
|
195
|
+
<Typography
|
|
196
|
+
component="h2"
|
|
197
|
+
sx={{ m: 0, fontSize: '19px', fontWeight: 700, letterSpacing: '-.01em', color: T.ink }}
|
|
198
|
+
>
|
|
199
|
+
{displayName}
|
|
200
|
+
</Typography>
|
|
201
|
+
<Box
|
|
202
|
+
component="span"
|
|
203
|
+
data-testid="payments-status"
|
|
204
|
+
sx={{
|
|
205
|
+
fontSize: '10px',
|
|
206
|
+
fontWeight: 800,
|
|
207
|
+
letterSpacing: '.06em',
|
|
208
|
+
textTransform: 'uppercase',
|
|
209
|
+
borderRadius: '5px',
|
|
210
|
+
px: '7px',
|
|
211
|
+
py: '3px',
|
|
212
|
+
background: proven ? T.okSoft : '#f0f1f4',
|
|
213
|
+
color: proven ? T.ok : T.ink3,
|
|
214
|
+
}}
|
|
215
|
+
>
|
|
216
|
+
{label}
|
|
217
|
+
</Box>
|
|
218
|
+
</Stack>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
145
222
|
export function ProviderStatusBar({
|
|
146
223
|
descriptor,
|
|
147
224
|
config,
|
|
@@ -157,37 +234,40 @@ export function ProviderStatusBar({
|
|
|
157
234
|
const { lockedOff, hint } = toggleGate(descriptor, config, enabled);
|
|
158
235
|
const badge = statusBadge(config, descriptor);
|
|
159
236
|
|
|
237
|
+
const proven = Boolean(config?.chargeVerifiedAt);
|
|
238
|
+
const paused = proven && !enabled;
|
|
239
|
+
const { state, sub, tone } = headline({ enabled, paused, lockedOff, hint });
|
|
240
|
+
|
|
160
241
|
return (
|
|
161
|
-
<Stack
|
|
162
|
-
<
|
|
163
|
-
<
|
|
164
|
-
<
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
242
|
+
<Stack direction="row" alignItems="flex-start" gap="14px" flexWrap="wrap">
|
|
243
|
+
<Box>
|
|
244
|
+
<ProviderName displayName={descriptor.displayName} label={badge.label} proven={proven} />
|
|
245
|
+
<Typography
|
|
246
|
+
sx={{ fontSize: '12.5px', color: T.ink3, mt: '5px', maxWidth: '52ch', lineHeight: 1.5 }}
|
|
247
|
+
data-testid="payments-enable-hint"
|
|
248
|
+
>
|
|
249
|
+
{sub}
|
|
250
|
+
</Typography>
|
|
251
|
+
</Box>
|
|
252
|
+
{/* The switch belongs at the far edge: it is the one control here that
|
|
253
|
+
changes what buyers experience, and crowding it against the status
|
|
254
|
+
chip made the two read as one compound widget. */}
|
|
255
|
+
<Stack direction="row" alignItems="center" gap="10px" sx={{ ml: 'auto' }}>
|
|
169
256
|
<Tooltip title={hint}>
|
|
170
257
|
{/* A disabled control fires no events, so the tooltip needs a live wrapper. */}
|
|
171
258
|
<span>
|
|
172
|
-
<
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
disabled={busy || lockedOff}
|
|
178
|
-
onChange={(_, next) => onToggle(next)}
|
|
179
|
-
/>
|
|
180
|
-
}
|
|
181
|
-
label={enabled ? 'Recebendo vendas' : 'Não está recebendo'}
|
|
259
|
+
<Switch
|
|
260
|
+
data-testid="payments-enabled-toggle"
|
|
261
|
+
checked={enabled}
|
|
262
|
+
disabled={busy || lockedOff}
|
|
263
|
+
onChange={(_, next) => onToggle(next)}
|
|
182
264
|
/>
|
|
183
265
|
</span>
|
|
184
266
|
</Tooltip>
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
<Typography variant="caption" color="text.secondary" data-testid="payments-enable-hint">
|
|
188
|
-
{hint}
|
|
267
|
+
<Typography sx={{ fontSize: '12.5px', fontWeight: tone === T.ink3 ? 400 : 600, color: tone }}>
|
|
268
|
+
{state}
|
|
189
269
|
</Typography>
|
|
190
|
-
|
|
270
|
+
</Stack>
|
|
191
271
|
</Stack>
|
|
192
272
|
);
|
|
193
273
|
}
|
|
@@ -31,19 +31,6 @@ import type { CheckoutOrder } from "./types";
|
|
|
31
31
|
*/
|
|
32
32
|
export const HOSTED_ORDER_STORAGE_KEY = "payments.checkout.hostedOrder";
|
|
33
33
|
|
|
34
|
-
/**
|
|
35
|
-
* The key before the rename, READ ONLY.
|
|
36
|
-
*
|
|
37
|
-
* A buyer who left for the provider's page on the old bundle comes back to the
|
|
38
|
-
* new one with their order parked under the old name. Without this they land on
|
|
39
|
-
* the plain return screen — the order still settles, because the webhook does
|
|
40
|
-
* that and never depended on any of this, but the confirmation they were
|
|
41
|
-
* promised is missing for a reason they could not possibly understand.
|
|
42
|
-
*
|
|
43
|
-
* Delete once no session can still be mid-redirect across that deploy.
|
|
44
|
-
*/
|
|
45
|
-
const LEGACY_KEY = "futurepay.checkout.hostedOrder";
|
|
46
|
-
|
|
47
34
|
/**
|
|
48
35
|
* What a hosted provider appends to the return URL. InfinitePay sends the
|
|
49
36
|
* first three; Stripe's redirect-based 3-D Secure appends `payment_intent`
|
|
@@ -87,25 +74,21 @@ export function rememberHostedOrder(order: CheckoutOrder): void {
|
|
|
87
74
|
* resumed view belongs to exactly one return.
|
|
88
75
|
*/
|
|
89
76
|
/**
|
|
90
|
-
* The raw parked payload
|
|
77
|
+
* The raw parked payload, cleared as it is read.
|
|
91
78
|
*
|
|
92
79
|
* Split out from {@link takeHostedOrder} so the storage handling and the
|
|
93
|
-
* parsing stay separately readable —
|
|
94
|
-
*
|
|
95
|
-
* unrelated reasons anyway (storage disabled vs. a value that is not an order).
|
|
80
|
+
* parsing stay separately readable — the two halves fail for unrelated reasons
|
|
81
|
+
* anyway (storage disabled vs. a value that is not an order).
|
|
96
82
|
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
83
|
+
* (This once also read a pre-rename legacy key, kept only for sessions that
|
|
84
|
+
* were mid-redirect across the deploy that renamed the storage key. A hosted
|
|
85
|
+
* round trip lasts minutes, so that window is long closed and the shim is
|
|
86
|
+
* gone.)
|
|
100
87
|
*/
|
|
101
88
|
function takeParkedPayload(): string | null {
|
|
102
89
|
try {
|
|
103
|
-
const raw =
|
|
104
|
-
window.sessionStorage?.getItem(HOSTED_ORDER_STORAGE_KEY) ??
|
|
105
|
-
window.sessionStorage?.getItem(LEGACY_KEY) ??
|
|
106
|
-
null;
|
|
90
|
+
const raw = window.sessionStorage?.getItem(HOSTED_ORDER_STORAGE_KEY) ?? null;
|
|
107
91
|
window.sessionStorage?.removeItem(HOSTED_ORDER_STORAGE_KEY);
|
|
108
|
-
window.sessionStorage?.removeItem(LEGACY_KEY);
|
|
109
92
|
return raw;
|
|
110
93
|
} catch {
|
|
111
94
|
// Storage disabled or unavailable — the same "no parked order" as an empty
|
|
@@ -18,21 +18,36 @@ import type { PendingSave } from './ConfirmCredentialSave';
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* Is there
|
|
21
|
+
* Is there enough on record to make the probe WORTH RUNNING?
|
|
22
22
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* A required-field test cannot answer this, and Stripe is why: every field in its
|
|
24
|
+
* schema is `required: false`, because under authorization the access token
|
|
25
|
+
* fulfils the secret key and nothing needs typing at all. On the credentials
|
|
26
|
+
* path that makes the required-field test vacuously true, so a save with one
|
|
27
|
+
* key pasted and three boxes empty went straight to the probe and came back
|
|
28
|
+
* "Credenciais recusadas pela Stripe." — a rejection reported for a request
|
|
29
|
+
* that was never worth making, blaming the owner mid-way through typing.
|
|
30
|
+
*
|
|
31
|
+
* So completeness is measured over the schema MINUS its `advanced` fields.
|
|
32
|
+
* Not the whole schema: that asked for Stripe's `connectedAccountId`, which
|
|
33
|
+
* ordinary stores must leave empty, and owners filled it with their own account
|
|
34
|
+
* id to satisfy the button — producing exactly the refusal this was meant to
|
|
35
|
+
* prevent. A partial save is still a perfectly good save (blank fields preserve
|
|
36
|
+
* what is stored); it simply is not yet a connection worth testing.
|
|
26
37
|
*/
|
|
27
|
-
export function
|
|
38
|
+
export function credentialsComplete(
|
|
28
39
|
descriptor: ProviderDescriptor,
|
|
29
|
-
config: MaskedProviderConfig,
|
|
40
|
+
config: MaskedProviderConfig | null,
|
|
30
41
|
environment: PaymentEnvironment,
|
|
42
|
+
values: Record<string, string>,
|
|
31
43
|
): boolean {
|
|
32
|
-
const stored = config
|
|
33
|
-
return descriptor.credentialSchema
|
|
34
|
-
|
|
35
|
-
|
|
44
|
+
const stored = config?.environments[environment] ?? {};
|
|
45
|
+
return descriptor.credentialSchema.every((spec) => {
|
|
46
|
+
if (spec.advanced) return true;
|
|
47
|
+
const typed = values[spec.key];
|
|
48
|
+
if (typed !== undefined && typed.trim() !== '') return true;
|
|
49
|
+
return stored[spec.key]?.configured === true;
|
|
50
|
+
});
|
|
36
51
|
}
|
|
37
52
|
|
|
38
53
|
/**
|
|
@@ -55,10 +70,31 @@ export function fieldsWellFormed(
|
|
|
55
70
|
});
|
|
56
71
|
}
|
|
57
72
|
|
|
58
|
-
|
|
73
|
+
/**
|
|
74
|
+
* What the one button says it will do — and it does BOTH halves.
|
|
75
|
+
*
|
|
76
|
+
* Saving IS testing (see `FormActions`): the write is followed straight away by
|
|
77
|
+
* the probe, which is what reaches the provider with the pasted keys and, on a
|
|
78
|
+
* pass, is the only thing that makes the activation charge appear. A button
|
|
79
|
+
* reading "Salvar" claims half of that. On the credentials path the owner has
|
|
80
|
+
* no other control, so they were left looking for the one that sends the keys
|
|
81
|
+
* to the provider — there isn't one, because this is it.
|
|
82
|
+
*
|
|
83
|
+
* …but only once there is a connection to test. Half a credential set cannot
|
|
84
|
+
* be, so a partly-filled form says plain "Salvar" and the save skips the probe:
|
|
85
|
+
* promising a test that is then reported as "Credenciais recusadas" is worse
|
|
86
|
+
* than promising nothing, because it reads as a verdict on what was typed.
|
|
87
|
+
*
|
|
88
|
+
* A provider whose whole connection is ONE field keeps naming that field
|
|
89
|
+
* instead. The label then says what is about to be committed rather than merely
|
|
90
|
+
* that something is, and on a step whose entire content is that single box that
|
|
91
|
+
* is the more useful of the two truths. Naming one of Stripe's four would be a
|
|
92
|
+
* lie about what the button writes, which is why only the fallback changes.
|
|
93
|
+
*/
|
|
94
|
+
export function saveLabel(descriptor: ProviderDescriptor, complete: boolean): string {
|
|
59
95
|
const required = descriptor.credentialSchema.filter((field) => field.required);
|
|
60
96
|
const only = required.length === 1 ? required[0] : undefined;
|
|
61
|
-
if (!only) return 'Salvar';
|
|
97
|
+
if (!only) return complete ? 'Salvar e testar conexão' : 'Salvar';
|
|
62
98
|
return `Salvar ${only.label.replace(/\s*\([^)]*\)\s*$/, '')}`;
|
|
63
99
|
}
|
|
64
100
|
|