@12-apps/payments-frontend 1.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/eslint.config.js +34 -0
- package/package.json +63 -0
- package/src/__tests__/checkout-confirmation.test.tsx +177 -0
- package/src/__tests__/connection-state.test.tsx +84 -0
- package/src/__tests__/context.test.tsx +88 -0
- package/src/__tests__/controlled-provider.test.tsx +116 -0
- package/src/__tests__/credential-confirm.test.tsx +193 -0
- package/src/__tests__/initial-provider.test.tsx +81 -0
- package/src/__tests__/provider-priority-list.test.tsx +159 -0
- package/src/__tests__/provider-status-bar.test.tsx +152 -0
- package/src/__tests__/verification-slot.test.tsx +125 -0
- package/src/client.ts +200 -0
- package/src/components/CheckoutFlow.tsx +169 -0
- package/src/components/CheckoutPayment.tsx +379 -0
- package/src/components/ConfirmCredentialSave.tsx +106 -0
- package/src/components/CredentialFields.tsx +158 -0
- package/src/components/CredentialFormAlerts.tsx +144 -0
- package/src/components/EnvironmentTabs.tsx +109 -0
- package/src/components/PaymentProviderSettings.tsx +267 -0
- package/src/components/ProviderConnection.tsx +251 -0
- package/src/components/ProviderCredentialForm.tsx +387 -0
- package/src/components/ProviderList.tsx +126 -0
- package/src/components/ProviderPanel.tsx +300 -0
- package/src/components/ProviderPriorityList.tsx +293 -0
- package/src/components/ProviderSetupGuide.tsx +263 -0
- package/src/components/ProviderStatusBar.tsx +192 -0
- package/src/components/SetupGuideSection.tsx +190 -0
- package/src/components/checkout-ack.ts +106 -0
- package/src/components/connection-state.ts +66 -0
- package/src/components/credential-rules.ts +120 -0
- package/src/components/rich-text.tsx +27 -0
- package/src/components/settings-state.ts +211 -0
- package/src/context.tsx +144 -0
- package/src/index.ts +69 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Alert,
|
|
5
|
+
Box,
|
|
6
|
+
FormControlLabel,
|
|
7
|
+
IconButton,
|
|
8
|
+
Paper,
|
|
9
|
+
Stack,
|
|
10
|
+
Switch,
|
|
11
|
+
Typography,
|
|
12
|
+
} from '@mui/material';
|
|
13
|
+
import { useCallback, useState } from 'react';
|
|
14
|
+
|
|
15
|
+
import type { MerchantSettingsView, ProviderDescriptor } from '@12-apps/payments-backend';
|
|
16
|
+
|
|
17
|
+
import type { PaymentsSettingsClient } from '../client';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The store's failover chain, ordered.
|
|
21
|
+
*
|
|
22
|
+
* Checkout walks this list top-down, moving on only when it can PROVE the
|
|
23
|
+
* provider above created no charge — so the order is real routing
|
|
24
|
+
* configuration, not a display preference. That is why the copy names the
|
|
25
|
+
* first entry explicitly rather than leaving the merchant to infer it from
|
|
26
|
+
* position.
|
|
27
|
+
*
|
|
28
|
+
* Reordering is offered two ways on purpose. Dragging is what the design asks
|
|
29
|
+
* for; the arrow buttons are what makes it usable with a keyboard or a screen
|
|
30
|
+
* reader, and what an automated test can drive. Neither is a fallback for the
|
|
31
|
+
* other — they write the same whole-chain update.
|
|
32
|
+
*/
|
|
33
|
+
export interface ProviderPriorityListProps {
|
|
34
|
+
view: MerchantSettingsView;
|
|
35
|
+
client: PaymentsSettingsClient;
|
|
36
|
+
/** Called with the refreshed view after a successful reorder. */
|
|
37
|
+
onReordered?: (view: MerchantSettingsView) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Move `from` to `to`, returning a new array. */
|
|
41
|
+
function reorder(chain: readonly string[], from: number, to: number): string[] {
|
|
42
|
+
if (to < 0 || to >= chain.length || from === to) return [...chain];
|
|
43
|
+
const next = [...chain];
|
|
44
|
+
const [moved] = next.splice(from, 1);
|
|
45
|
+
if (moved === undefined) return [...chain];
|
|
46
|
+
next.splice(to, 0, moved);
|
|
47
|
+
return next;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function labelOf(providers: readonly ProviderDescriptor[], name: string): string {
|
|
51
|
+
return providers.find((p) => p.name === name)?.displayName ?? name;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The chain, derived from `configs` rather than read from `view.providerChain`.
|
|
56
|
+
*
|
|
57
|
+
* Both carry the same fact, and deriving keeps ONE source of truth in the UI:
|
|
58
|
+
* a payload where the two disagree — a client bundle talking to an older
|
|
59
|
+
* server mid-deploy, say — renders the enable flags the rest of this page
|
|
60
|
+
* shows, instead of white-screening on a field that is not there.
|
|
61
|
+
*/
|
|
62
|
+
export function chainOf(view: MerchantSettingsView): string[] {
|
|
63
|
+
return view.configs
|
|
64
|
+
.filter((c) => c.enabled)
|
|
65
|
+
.slice()
|
|
66
|
+
.sort((a, b) => a.priority - b.priority || a.provider.localeCompare(b.provider))
|
|
67
|
+
.map((c) => c.provider);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface PriorityRowProps {
|
|
71
|
+
label: string;
|
|
72
|
+
provider: string;
|
|
73
|
+
index: number;
|
|
74
|
+
total: number;
|
|
75
|
+
saving: boolean;
|
|
76
|
+
dragging: boolean;
|
|
77
|
+
onDragStart: () => void;
|
|
78
|
+
onDropOn: () => void;
|
|
79
|
+
onDragEnd: () => void;
|
|
80
|
+
onMove: (from: number, to: number) => void;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function PriorityRow({
|
|
84
|
+
label,
|
|
85
|
+
provider,
|
|
86
|
+
index,
|
|
87
|
+
total,
|
|
88
|
+
saving,
|
|
89
|
+
dragging,
|
|
90
|
+
onDragStart,
|
|
91
|
+
onDropOn,
|
|
92
|
+
onDragEnd,
|
|
93
|
+
onMove,
|
|
94
|
+
}: PriorityRowProps) {
|
|
95
|
+
return (
|
|
96
|
+
<Paper
|
|
97
|
+
component="li"
|
|
98
|
+
variant="outlined"
|
|
99
|
+
data-testid={`payments-priority-item-${provider}`}
|
|
100
|
+
draggable={!saving}
|
|
101
|
+
onDragStart={onDragStart}
|
|
102
|
+
onDragOver={(e: React.DragEvent) => e.preventDefault()}
|
|
103
|
+
onDrop={(e: React.DragEvent) => {
|
|
104
|
+
e.preventDefault();
|
|
105
|
+
onDropOn();
|
|
106
|
+
}}
|
|
107
|
+
onDragEnd={onDragEnd}
|
|
108
|
+
sx={{
|
|
109
|
+
display: 'flex',
|
|
110
|
+
alignItems: 'center',
|
|
111
|
+
gap: 1,
|
|
112
|
+
p: 1,
|
|
113
|
+
cursor: saving ? 'progress' : 'grab',
|
|
114
|
+
opacity: dragging ? 0.5 : 1,
|
|
115
|
+
}}
|
|
116
|
+
>
|
|
117
|
+
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 24, textAlign: 'center' }}>
|
|
118
|
+
{index + 1}
|
|
119
|
+
</Typography>
|
|
120
|
+
<Typography sx={{ flexGrow: 1 }}>{label}</Typography>
|
|
121
|
+
{index === 0 ? (
|
|
122
|
+
<Typography variant="caption" color="primary" data-testid="payments-priority-first">
|
|
123
|
+
primeiro
|
|
124
|
+
</Typography>
|
|
125
|
+
) : null}
|
|
126
|
+
<IconButton
|
|
127
|
+
size="small"
|
|
128
|
+
aria-label={`Mover ${label} para cima`}
|
|
129
|
+
disabled={index === 0 || saving}
|
|
130
|
+
onClick={() => onMove(index, index - 1)}
|
|
131
|
+
>
|
|
132
|
+
↑
|
|
133
|
+
</IconButton>
|
|
134
|
+
<IconButton
|
|
135
|
+
size="small"
|
|
136
|
+
aria-label={`Mover ${label} para baixo`}
|
|
137
|
+
disabled={index === total - 1 || saving}
|
|
138
|
+
onClick={() => onMove(index, index + 1)}
|
|
139
|
+
>
|
|
140
|
+
↓
|
|
141
|
+
</IconButton>
|
|
142
|
+
</Paper>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The optimistic-reorder state machine, kept out of the component so the
|
|
148
|
+
* render stays readable. The SERVER's response — not the local guess — sets
|
|
149
|
+
* the final order, so a concurrent change elsewhere is not papered over; a
|
|
150
|
+
* failure rolls the list back to where it was.
|
|
151
|
+
*/
|
|
152
|
+
function useChainReorder(
|
|
153
|
+
view: MerchantSettingsView,
|
|
154
|
+
client: PaymentsSettingsClient,
|
|
155
|
+
onReordered?: (next: MerchantSettingsView) => void,
|
|
156
|
+
) {
|
|
157
|
+
const [chain, setChain] = useState<string[]>(() => chainOf(view));
|
|
158
|
+
const [error, setError] = useState<string | null>(null);
|
|
159
|
+
const [saving, setSaving] = useState(false);
|
|
160
|
+
|
|
161
|
+
const move = useCallback(
|
|
162
|
+
(from: number, to: number) => {
|
|
163
|
+
const next = reorder(chain, from, to);
|
|
164
|
+
if (next.join(' ') === chain.join(' ')) return;
|
|
165
|
+
const previous = chain;
|
|
166
|
+
setChain(next);
|
|
167
|
+
setSaving(true);
|
|
168
|
+
setError(null);
|
|
169
|
+
void client
|
|
170
|
+
.setPriorities(next)
|
|
171
|
+
.then((updated) => {
|
|
172
|
+
setChain(chainOf(updated));
|
|
173
|
+
onReordered?.(updated);
|
|
174
|
+
})
|
|
175
|
+
.catch((err: unknown) => {
|
|
176
|
+
setChain(previous);
|
|
177
|
+
setError(err instanceof Error ? err.message : 'Não foi possível salvar a ordem.');
|
|
178
|
+
})
|
|
179
|
+
.finally(() => setSaving(false));
|
|
180
|
+
},
|
|
181
|
+
[chain, client, onReordered],
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
return { chain, error, saving, move };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Decline cascading — retrying a REFUSED card on the next acquirer.
|
|
189
|
+
*
|
|
190
|
+
* Deliberately a separate, explicit control rather than something the reorder
|
|
191
|
+
* list implies. Ordering is a technical preference; this is a business
|
|
192
|
+
* decision with fraud and interchange-fee consequences, and the copy says so
|
|
193
|
+
* instead of leaving the merchant to discover it from their statement.
|
|
194
|
+
*/
|
|
195
|
+
function DeclinePolicyControl({
|
|
196
|
+
view,
|
|
197
|
+
client,
|
|
198
|
+
onChanged,
|
|
199
|
+
}: {
|
|
200
|
+
view: MerchantSettingsView;
|
|
201
|
+
client: PaymentsSettingsClient;
|
|
202
|
+
onChanged?: (next: MerchantSettingsView) => void;
|
|
203
|
+
}) {
|
|
204
|
+
const [saving, setSaving] = useState(false);
|
|
205
|
+
const cascades = view.failoverPolicy === 'TECHNICAL_AND_DECLINE';
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<Box sx={{ mb: 2 }} data-testid="payments-decline-policy">
|
|
209
|
+
<FormControlLabel
|
|
210
|
+
control={
|
|
211
|
+
<Switch
|
|
212
|
+
size="small"
|
|
213
|
+
checked={cascades}
|
|
214
|
+
disabled={saving}
|
|
215
|
+
inputProps={{ 'aria-label': 'Tentar cartão recusado no próximo provedor' }}
|
|
216
|
+
onChange={(e) => {
|
|
217
|
+
setSaving(true);
|
|
218
|
+
void client
|
|
219
|
+
.setFailoverPolicy(e.target.checked ? 'TECHNICAL_AND_DECLINE' : 'TECHNICAL')
|
|
220
|
+
.then((updated) => onChanged?.(updated))
|
|
221
|
+
.finally(() => setSaving(false));
|
|
222
|
+
}}
|
|
223
|
+
/>
|
|
224
|
+
}
|
|
225
|
+
label={
|
|
226
|
+
<Typography variant="body2">
|
|
227
|
+
Tentar cartão <strong>recusado</strong> no próximo provedor
|
|
228
|
+
</Typography>
|
|
229
|
+
}
|
|
230
|
+
/>
|
|
231
|
+
<Typography variant="caption" color="text.secondary" display="block">
|
|
232
|
+
{cascades
|
|
233
|
+
? 'Uma recusa passa para o próximo da lista. Isso pode aumentar custos de transação e sinais de fraude.'
|
|
234
|
+
: 'Padrão: uma recusa encerra a cobrança. Só falhas técnicas passam para o próximo.'}
|
|
235
|
+
</Typography>
|
|
236
|
+
</Box>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function ProviderPriorityList({ view, client, onReordered }: ProviderPriorityListProps) {
|
|
241
|
+
const { chain, error, saving, move } = useChainReorder(view, client, onReordered);
|
|
242
|
+
const [dragging, setDragging] = useState<number | null>(null);
|
|
243
|
+
|
|
244
|
+
if (chain.length === 0) {
|
|
245
|
+
return (
|
|
246
|
+
<Alert severity="warning" data-testid="payments-priority-empty">
|
|
247
|
+
Nenhum provedor está ativo. O checkout não conseguirá cobrar até que você ative ao menos um.
|
|
248
|
+
</Alert>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return (
|
|
253
|
+
<Box data-testid="payments-priority-list">
|
|
254
|
+
<Typography variant="subtitle2" gutterBottom>
|
|
255
|
+
Ordem de tentativa
|
|
256
|
+
</Typography>
|
|
257
|
+
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
|
258
|
+
O checkout tenta <strong>{labelOf(view.providers, chain[0] ?? '')}</strong> primeiro. Se uma
|
|
259
|
+
cobrança falhar por motivo técnico, ele tenta o próximo da lista — mas só quando é possível
|
|
260
|
+
comprovar que a tentativa anterior não gerou cobrança.
|
|
261
|
+
</Typography>
|
|
262
|
+
|
|
263
|
+
{error ? (
|
|
264
|
+
<Alert severity="error" sx={{ mb: 1.5 }} data-testid="payments-priority-error">
|
|
265
|
+
{error}
|
|
266
|
+
</Alert>
|
|
267
|
+
) : null}
|
|
268
|
+
|
|
269
|
+
<DeclinePolicyControl view={view} client={client} onChanged={onReordered} />
|
|
270
|
+
|
|
271
|
+
<Stack spacing={1} component="ol" sx={{ listStyle: 'none', p: 0, m: 0 }}>
|
|
272
|
+
{chain.map((provider, index) => (
|
|
273
|
+
<PriorityRow
|
|
274
|
+
key={provider}
|
|
275
|
+
label={labelOf(view.providers, provider)}
|
|
276
|
+
provider={provider}
|
|
277
|
+
index={index}
|
|
278
|
+
total={chain.length}
|
|
279
|
+
saving={saving}
|
|
280
|
+
dragging={dragging === index}
|
|
281
|
+
onDragStart={() => setDragging(index)}
|
|
282
|
+
onDropOn={() => {
|
|
283
|
+
if (dragging !== null) move(dragging, index);
|
|
284
|
+
setDragging(null);
|
|
285
|
+
}}
|
|
286
|
+
onDragEnd={() => setDragging(null)}
|
|
287
|
+
onMove={move}
|
|
288
|
+
/>
|
|
289
|
+
))}
|
|
290
|
+
</Stack>
|
|
291
|
+
</Box>
|
|
292
|
+
);
|
|
293
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Alert,
|
|
5
|
+
Box,
|
|
6
|
+
Button,
|
|
7
|
+
IconButton,
|
|
8
|
+
Link,
|
|
9
|
+
Paper,
|
|
10
|
+
Stack,
|
|
11
|
+
Step,
|
|
12
|
+
StepLabel,
|
|
13
|
+
Stepper,
|
|
14
|
+
TextField,
|
|
15
|
+
Typography,
|
|
16
|
+
} from '@mui/material';
|
|
17
|
+
import { useState, type ReactNode } from 'react';
|
|
18
|
+
|
|
19
|
+
import type { ProviderSetupGuide as Guide, SetupSection, SetupStep } from '@12-apps/payments-backend';
|
|
20
|
+
|
|
21
|
+
import { richText } from './rich-text';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Renders a provider's step-by-step onboarding walkthrough — the reusable
|
|
25
|
+
* equivalent of the PagBank "Como gerar o token e cadastrar as URLs"
|
|
26
|
+
* screen: an onboarding stepper (Conectar conta → Homologar → Ativar
|
|
27
|
+
* vendas) plus numbered instruction sections with dashboard links and
|
|
28
|
+
* copy-paste URL fields. Content comes from the backend adapter's
|
|
29
|
+
* `setupGuide`; this component owns only presentation.
|
|
30
|
+
*/
|
|
31
|
+
export interface ProviderSetupGuideProps {
|
|
32
|
+
guide: Guide;
|
|
33
|
+
/** Index into `guide.stages` of the merchant's current stage. */
|
|
34
|
+
activeStage?: number;
|
|
35
|
+
/**
|
|
36
|
+
* Handlers for the in-app actions an adapter's steps can request, keyed by
|
|
37
|
+
* the adapter's opaque action id (PagBank asks for `homologacao-anexo`).
|
|
38
|
+
* An action with no handler renders NO button — the step's text still reads
|
|
39
|
+
* correctly on its own, so a host can adopt a provider before implementing
|
|
40
|
+
* its optional conveniences.
|
|
41
|
+
*/
|
|
42
|
+
actions?: Record<string, { label: string; run: () => void | Promise<void> }>;
|
|
43
|
+
/**
|
|
44
|
+
* Rendered between the stepper and the current section — where the steps
|
|
45
|
+
* ALREADY finished go, as one-line rows.
|
|
46
|
+
*
|
|
47
|
+
* Above the open card rather than below it, because that is the reading
|
|
48
|
+
* order the stepper promises: done, doing, still to do. Below, a completed
|
|
49
|
+
* step looked like a consequence of the one in progress.
|
|
50
|
+
*/
|
|
51
|
+
beforeSections?: ReactNode;
|
|
52
|
+
/**
|
|
53
|
+
* Rendered INSIDE the current section's card, after its steps.
|
|
54
|
+
*
|
|
55
|
+
* This is where the walkthrough stops being prose and becomes the thing
|
|
56
|
+
* itself: the instruction "informe sua InfiniteTag" and the field you type it
|
|
57
|
+
* into are one step, and separating them put a card of advice above a
|
|
58
|
+
* detached input, leaving the owner to work out that the two were related.
|
|
59
|
+
*/
|
|
60
|
+
sectionFooter?: ReactNode;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function CopyField({
|
|
64
|
+
label,
|
|
65
|
+
text,
|
|
66
|
+
collapsible,
|
|
67
|
+
}: {
|
|
68
|
+
label: string;
|
|
69
|
+
text: string;
|
|
70
|
+
collapsible?: boolean;
|
|
71
|
+
}) {
|
|
72
|
+
const [open, setOpen] = useState(!collapsible);
|
|
73
|
+
if (!collapsible) return <CopyRow label={label} text={text} />;
|
|
74
|
+
return (
|
|
75
|
+
// Stretch, not flex-start: the revealed field is a full-width address and
|
|
76
|
+
// shrinking it to its own content clipped the URL mid-domain. The toggle
|
|
77
|
+
// keeps its intrinsic width by sitting in a Box of its own.
|
|
78
|
+
<Stack spacing={1}>
|
|
79
|
+
<Box>
|
|
80
|
+
<Button
|
|
81
|
+
size="small"
|
|
82
|
+
sx={{ ...BUTTON_SX, px: 0 }}
|
|
83
|
+
onClick={() => setOpen((shown) => !shown)}
|
|
84
|
+
data-testid="payments-setup-copy-reveal"
|
|
85
|
+
>
|
|
86
|
+
{label} {open ? '▴' : '▾'}
|
|
87
|
+
</Button>
|
|
88
|
+
</Box>
|
|
89
|
+
{open ? <CopyRow label={label} text={text} /> : null}
|
|
90
|
+
</Stack>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function CopyRow({ label, text }: { label: string; text: string }) {
|
|
95
|
+
const [copied, setCopied] = useState(false);
|
|
96
|
+
return (
|
|
97
|
+
<Stack direction="row" spacing={1} alignItems="center" sx={{ width: '100%' }}>
|
|
98
|
+
{/*
|
|
99
|
+
Disabled, not merely read-only: nothing here is to be edited, and a
|
|
100
|
+
live-looking field invited owners to type into the one value on the step
|
|
101
|
+
that is pure reference.
|
|
102
|
+
*/}
|
|
103
|
+
<TextField fullWidth size="small" disabled value={text} />
|
|
104
|
+
<IconButton
|
|
105
|
+
aria-label={copied ? 'Copiado' : `Copiar ${label}`}
|
|
106
|
+
onClick={() => {
|
|
107
|
+
void navigator.clipboard.writeText(text).then(() => setCopied(true));
|
|
108
|
+
}}
|
|
109
|
+
>
|
|
110
|
+
{copied ? '✓' : '⧉'}
|
|
111
|
+
</IconButton>
|
|
112
|
+
</Stack>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Sentence case. MUI upper-cases button labels by default, which turns copy
|
|
118
|
+
* written to be read into copy that is shouted — "VER A MINHA INFINITETAG"
|
|
119
|
+
* also loses the capitalisation that made "InfiniteTag" one word.
|
|
120
|
+
*/
|
|
121
|
+
const BUTTON_SX = { textTransform: 'none' } as const;
|
|
122
|
+
|
|
123
|
+
type StepActions = ProviderSetupGuideProps['actions'];
|
|
124
|
+
|
|
125
|
+
interface StepRowProps {
|
|
126
|
+
step: SetupStep;
|
|
127
|
+
actions: StepActions;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The numbers an owner reads, which are not the array indices.
|
|
132
|
+
*
|
|
133
|
+
* Warnings sit between instructions and carry no number of their own, so
|
|
134
|
+
* numbering by position would print "1, 3" and skip a step that was never
|
|
135
|
+
* there. Counted rather than indexed.
|
|
136
|
+
*/
|
|
137
|
+
/**
|
|
138
|
+
* A step that warns rather than instructs.
|
|
139
|
+
*
|
|
140
|
+
* Un-numbered on purpose: it is not a thing to DO, and numbering it made the
|
|
141
|
+
* two most important sentences in InfinitePay's walkthrough — the tag decides
|
|
142
|
+
* who gets paid, and the page we link to can also change it — read as further
|
|
143
|
+
* instructions BELOW the button that had already taken the owner there.
|
|
144
|
+
*/
|
|
145
|
+
function WarningRow({ text }: { text: string }) {
|
|
146
|
+
return (
|
|
147
|
+
<Alert severity="warning" data-testid="payments-setup-warning">
|
|
148
|
+
<Typography variant="body2">{richText(text)}</Typography>
|
|
149
|
+
</Alert>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function StepRow({ step, actions }: StepRowProps) {
|
|
154
|
+
const action = step.action ? actions?.[step.action] : undefined;
|
|
155
|
+
if (step.tone === 'warning') return <WarningRow text={step.text ?? ''} />;
|
|
156
|
+
return (
|
|
157
|
+
<Stack spacing={1}>
|
|
158
|
+
{step.text ? (
|
|
159
|
+
<Typography variant="body2">
|
|
160
|
+
{richText(step.text)}{' '}
|
|
161
|
+
{step.link ? (
|
|
162
|
+
<Link href={step.link.url} target="_blank" rel="noreferrer">
|
|
163
|
+
{step.link.label}
|
|
164
|
+
</Link>
|
|
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}
|
|
187
|
+
{action ? (
|
|
188
|
+
<Box>
|
|
189
|
+
<Button variant="contained" size="small" sx={BUTTON_SX} onClick={() => void action.run()}>
|
|
190
|
+
{action.label}
|
|
191
|
+
</Button>
|
|
192
|
+
</Box>
|
|
193
|
+
) : null}
|
|
194
|
+
{step.copy ? (
|
|
195
|
+
<CopyField
|
|
196
|
+
label={step.copy.label}
|
|
197
|
+
text={step.copy.text}
|
|
198
|
+
collapsible={step.copy.collapsible}
|
|
199
|
+
/>
|
|
200
|
+
) : null}
|
|
201
|
+
</Stack>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function ProviderSetupGuide({
|
|
206
|
+
guide,
|
|
207
|
+
activeStage = 0,
|
|
208
|
+
actions,
|
|
209
|
+
beforeSections,
|
|
210
|
+
sectionFooter,
|
|
211
|
+
}: ProviderSetupGuideProps) {
|
|
212
|
+
return (
|
|
213
|
+
<Stack spacing={3} data-testid="payments-setup-guide">
|
|
214
|
+
<Stepper activeStep={activeStage} alternativeLabel>
|
|
215
|
+
{guide.stages.map((stage) => (
|
|
216
|
+
<Step key={stage.id}>
|
|
217
|
+
<StepLabel>{stage.label}</StepLabel>
|
|
218
|
+
</Step>
|
|
219
|
+
))}
|
|
220
|
+
</Stepper>
|
|
221
|
+
{beforeSections}
|
|
222
|
+
{guide.sections.map((section) => (
|
|
223
|
+
<SectionCard key={section.id} section={section} actions={actions} footer={sectionFooter} />
|
|
224
|
+
))}
|
|
225
|
+
</Stack>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function SectionCard({
|
|
230
|
+
section,
|
|
231
|
+
actions,
|
|
232
|
+
footer,
|
|
233
|
+
}: {
|
|
234
|
+
section: SetupSection;
|
|
235
|
+
actions: StepActions;
|
|
236
|
+
footer?: ReactNode;
|
|
237
|
+
}) {
|
|
238
|
+
return (
|
|
239
|
+
<Paper
|
|
240
|
+
variant="outlined"
|
|
241
|
+
sx={{ p: 2 }}
|
|
242
|
+
// Which section is showing is now a FACT about the store's progress, not
|
|
243
|
+
// a constant, so it needs to be assertable by id rather than by matching
|
|
244
|
+
// the prose inside it.
|
|
245
|
+
data-testid={`payments-setup-section-${section.id}`}
|
|
246
|
+
>
|
|
247
|
+
<Stack spacing={2}>
|
|
248
|
+
<Typography variant="subtitle1" fontWeight="bold">
|
|
249
|
+
{section.title}
|
|
250
|
+
</Typography>
|
|
251
|
+
{section.intro ? (
|
|
252
|
+
<Typography variant="body2" color="text.secondary">
|
|
253
|
+
{richText(section.intro)}
|
|
254
|
+
</Typography>
|
|
255
|
+
) : null}
|
|
256
|
+
{section.steps.map((step, index) => (
|
|
257
|
+
<StepRow key={index} step={step} actions={actions} />
|
|
258
|
+
))}
|
|
259
|
+
{footer}
|
|
260
|
+
</Stack>
|
|
261
|
+
</Paper>
|
|
262
|
+
);
|
|
263
|
+
}
|