@12-apps/payments-frontend 2.0.0 → 2.1.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/credential-rules.ts +48 -12
- package/src/components/panel-tokens.ts +166 -0
- package/src/index.ts +3 -3
|
@@ -13,22 +13,16 @@ import type {
|
|
|
13
13
|
import type { PaymentsSettingsClient } from '../client';
|
|
14
14
|
|
|
15
15
|
import { ConfirmCredentialSave, type PendingSave } from './ConfirmCredentialSave';
|
|
16
|
+
import { CredentialFields } from './CredentialFieldStack';
|
|
16
17
|
import { isConnected } from './connection-state';
|
|
17
|
-
import {
|
|
18
|
+
import { DoneRow } from './CredentialFields';
|
|
18
19
|
import {
|
|
19
|
-
|
|
20
|
+
credentialsComplete,
|
|
20
21
|
fieldsWellFormed,
|
|
21
22
|
needsConfirmation,
|
|
22
|
-
saveLabel,
|
|
23
23
|
summaryOf,
|
|
24
24
|
} from './credential-rules';
|
|
25
|
-
import {
|
|
26
|
-
FormActions,
|
|
27
|
-
ProbeAlert,
|
|
28
|
-
ProbeChecklist,
|
|
29
|
-
ReverifyWarning,
|
|
30
|
-
type VerifyProbe,
|
|
31
|
-
} from './CredentialFormAlerts';
|
|
25
|
+
import { ProbeAlert, ProbeChecklist, type VerifyProbe } from './CredentialFormAlerts';
|
|
32
26
|
|
|
33
27
|
/**
|
|
34
28
|
* The `authMode: 'credentials'` half of the settings page — a provider whose
|
|
@@ -131,6 +125,8 @@ interface ProviderFormProps {
|
|
|
131
125
|
* imply work has been done.
|
|
132
126
|
*/
|
|
133
127
|
stored: boolean;
|
|
128
|
+
/** Which connection path these steps describe — see `ActivePanelProps`. */
|
|
129
|
+
path: 'oauth' | 'credentials';
|
|
134
130
|
}) => ReactNode;
|
|
135
131
|
}
|
|
136
132
|
|
|
@@ -187,6 +183,19 @@ function useRetireOnEnvironmentChange(
|
|
|
187
183
|
}, [environment, onEditingChange, setProbe, setValues]);
|
|
188
184
|
}
|
|
189
185
|
|
|
186
|
+
/**
|
|
187
|
+
* The form's memory and its two writes.
|
|
188
|
+
*
|
|
189
|
+
* `nothingEdited` guards the save because saving an untouched form is not a
|
|
190
|
+
* free no-op: `saveCredentials` resets the connection to UNVERIFIED, drops the
|
|
191
|
+
* proof and switches the provider OFF. `complete` answers whether there is
|
|
192
|
+
* enough on record for the probe to be worth running — see
|
|
193
|
+
* `credentialsComplete`, which skips the advanced fields an ordinary store must
|
|
194
|
+
* leave empty.
|
|
195
|
+
*/
|
|
196
|
+
/** Everything `CredentialFields` needs, without importing the hook itself. */
|
|
197
|
+
export type CredentialFormState = ReturnType<typeof useCredentialForm>;
|
|
198
|
+
|
|
190
199
|
function useCredentialForm(props: ProviderFormProps) {
|
|
191
200
|
const {
|
|
192
201
|
descriptor,
|
|
@@ -228,7 +237,13 @@ function useCredentialForm(props: ProviderFormProps) {
|
|
|
228
237
|
// no credential to probe, and asking anyway makes the adapter answer
|
|
229
238
|
// "Handle não configurado" — a store that has entered nothing has not
|
|
230
239
|
// failed at anything; it is simply NÃO VERIFICADO.
|
|
231
|
-
|
|
240
|
+
//
|
|
241
|
+
// The same holds one step further along: a set with fields still empty
|
|
242
|
+
// is not a connection yet, and probing it reports the provider's
|
|
243
|
+
// rejection of an incomplete request as a verdict on what the owner
|
|
244
|
+
// typed. Read from `next` — the server's answer — because blank fields
|
|
245
|
+
// PRESERVE what is stored, so the form does not know what is on record.
|
|
246
|
+
return credentialsComplete(descriptor, next, environment, {}) ? probeNow() : undefined;
|
|
232
247
|
});
|
|
233
248
|
|
|
234
249
|
useRetireOnEnvironmentChange(environment, onEditingChange, setProbe, setValues);
|
|
@@ -240,10 +255,9 @@ function useCredentialForm(props: ProviderFormProps) {
|
|
|
240
255
|
busy,
|
|
241
256
|
error,
|
|
242
257
|
masked: config?.environments[environment] ?? {},
|
|
243
|
-
// Saving an untouched form is not a free no-op: `saveCredentials` resets
|
|
244
|
-
// the connection to UNVERIFIED, drops the proof and switches it OFF.
|
|
245
258
|
nothingEdited: Object.keys(values).length === 0,
|
|
246
259
|
valid: fieldsWellFormed(descriptor, values),
|
|
260
|
+
complete: credentialsComplete(descriptor, config, environment, values),
|
|
247
261
|
summary: summaryOf(descriptor, config, environment),
|
|
248
262
|
edit: (spec: string, value: string) => {
|
|
249
263
|
setValues((v) => ({ ...v, [spec]: value }));
|
|
@@ -298,44 +312,11 @@ function collapsedSummary(
|
|
|
298
312
|
}
|
|
299
313
|
|
|
300
314
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
* Its own component so `ProviderForm` stays about WHICH of the two shapes is on
|
|
307
|
-
* screen (the collapsed row, or this) rather than about what each contains.
|
|
308
|
-
*/
|
|
309
|
-
function CredentialFields({
|
|
310
|
-
descriptor,
|
|
311
|
-
form,
|
|
312
|
-
proven,
|
|
313
|
-
}: {
|
|
314
|
-
descriptor: ProviderDescriptor;
|
|
315
|
-
form: ReturnType<typeof useCredentialForm>;
|
|
316
|
-
/** A real charge has landed through this connection — see `ReverifyWarning`. */
|
|
317
|
-
proven: boolean;
|
|
318
|
-
}) {
|
|
319
|
-
return (
|
|
320
|
-
<Stack spacing={2}>
|
|
321
|
-
{proven ? <ReverifyWarning /> : null}
|
|
322
|
-
{descriptor.credentialSchema.map((spec) => (
|
|
323
|
-
<CredentialField
|
|
324
|
-
key={spec.key}
|
|
325
|
-
spec={spec}
|
|
326
|
-
state={form.masked[spec.key]}
|
|
327
|
-
value={form.values[spec.key]}
|
|
328
|
-
onChange={(value) => form.edit(spec.key, value)}
|
|
329
|
-
/>
|
|
330
|
-
))}
|
|
331
|
-
<FormActions
|
|
332
|
-
busy={form.busy}
|
|
333
|
-
label={saveLabel(descriptor)}
|
|
334
|
-
disabled={form.nothingEdited || !form.valid}
|
|
335
|
-
onSave={form.requestSave}
|
|
336
|
-
/>
|
|
337
|
-
</Stack>
|
|
338
|
-
);
|
|
315
|
+
|
|
316
|
+
/** Verdict keys that name no field, so they have no box to be shown at. */
|
|
317
|
+
function unkeyed(descriptor: ProviderDescriptor): (key: string) => boolean {
|
|
318
|
+
const fields = new Set(descriptor.credentialSchema.map((spec) => spec.key));
|
|
319
|
+
return (key: string) => !fields.has(key);
|
|
339
320
|
}
|
|
340
321
|
|
|
341
322
|
export function ProviderForm(props: ProviderFormProps) {
|
|
@@ -369,6 +350,10 @@ export function ProviderForm(props: ProviderFormProps) {
|
|
|
369
350
|
sectionFooter: fields,
|
|
370
351
|
editing: form.editing && !summary,
|
|
371
352
|
stored: storedHere(descriptor, form.masked),
|
|
353
|
+
// This branch IS the credentials path: it renders only when the
|
|
354
|
+
// provider has no working connect button, so pasted keys are the
|
|
355
|
+
// only way in.
|
|
356
|
+
path: 'credentials',
|
|
372
357
|
})
|
|
373
358
|
: (rows ?? fields)}
|
|
374
359
|
|
|
@@ -376,11 +361,10 @@ export function ProviderForm(props: ProviderFormProps) {
|
|
|
376
361
|
{form.probe ? (
|
|
377
362
|
<ProbeAlert probe={form.probe} busy={form.busy !== null} onRetry={form.verify} />
|
|
378
363
|
) : null}
|
|
379
|
-
{/*
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
{form.probe ? <ProbeChecklist probe={form.probe} /> : null}
|
|
364
|
+
{/* Only what could NOT be tied to a box. Every verdict carrying a field
|
|
365
|
+
key is rendered at that field now; this keeps the leftovers, which is
|
|
366
|
+
where the UNCHECKED rows live — the half a green result would deny. */}
|
|
367
|
+
{form.probe ? <ProbeChecklist probe={form.probe} only={unkeyed(descriptor)} /> : null}
|
|
384
368
|
|
|
385
369
|
<ConfirmCredentialSave
|
|
386
370
|
pending={form.pending}
|
|
@@ -1,15 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
Accordion,
|
|
5
|
-
AccordionDetails,
|
|
6
|
-
AccordionSummary,
|
|
7
|
-
Alert,
|
|
8
|
-
Box,
|
|
9
|
-
Paper,
|
|
10
|
-
Stack,
|
|
11
|
-
Typography,
|
|
12
|
-
} from '@mui/material';
|
|
3
|
+
import { Box, Paper, Stack } from '@mui/material';
|
|
13
4
|
import { useCallback, useState, type ReactNode } from 'react';
|
|
14
5
|
|
|
15
6
|
import type {
|
|
@@ -19,9 +10,9 @@ import type {
|
|
|
19
10
|
} from '@12-apps/payments-backend';
|
|
20
11
|
|
|
21
12
|
import type { PaymentsSettingsClient } from '../client';
|
|
22
|
-
import {
|
|
13
|
+
import { CARD_SX } from './panel-tokens';
|
|
14
|
+
import { OAuthPanel } from './OAuthPanel';
|
|
23
15
|
import { EnvironmentNotice, EnvironmentSelector } from './EnvironmentTabs';
|
|
24
|
-
import { ProviderConnection } from './ProviderConnection';
|
|
25
16
|
import { ProviderForm } from './ProviderCredentialForm';
|
|
26
17
|
import { ProviderStatusBar } from './ProviderStatusBar';
|
|
27
18
|
|
|
@@ -72,6 +63,15 @@ export interface ActivePanelProps {
|
|
|
72
63
|
editing: boolean;
|
|
73
64
|
/** The environment on screen holds its credentials — see `renderGuide`. */
|
|
74
65
|
stored: boolean;
|
|
66
|
+
/**
|
|
67
|
+
* Which connection path the walkthrough is describing.
|
|
68
|
+
*
|
|
69
|
+
* The steps are not the same for both: step 1 under authorization says to
|
|
70
|
+
* press a button that is not rendered while the credential form is open,
|
|
71
|
+
* and it opens by saying no key needs copying — to an owner looking at the
|
|
72
|
+
* boxes for those keys. The panel owns the disclosure, so it owns this.
|
|
73
|
+
*/
|
|
74
|
+
path: 'oauth' | 'credentials';
|
|
75
75
|
}) => ReactNode;
|
|
76
76
|
/** The host's activation step (see `renderVerification`), already resolved. */
|
|
77
77
|
verification?: ReactNode;
|
|
@@ -138,7 +138,7 @@ function EnableBar({ descriptor, config, client, onChanged, reload }: ActivePane
|
|
|
138
138
|
* container, so the header and the body carry the padding and the band between
|
|
139
139
|
* them carries none.
|
|
140
140
|
*/
|
|
141
|
-
function ProviderCard({
|
|
141
|
+
export function ProviderCard({
|
|
142
142
|
header,
|
|
143
143
|
band,
|
|
144
144
|
children,
|
|
@@ -147,13 +147,16 @@ function ProviderCard({
|
|
|
147
147
|
band?: ReactNode;
|
|
148
148
|
children: ReactNode;
|
|
149
149
|
}) {
|
|
150
|
+
// The body carries NO padding of its own: each block inside it (a step panel,
|
|
151
|
+
// a done row, the disclosure) sets its own 20px inset, because the
|
|
152
|
+
// environment band between them has to reach both edges and a padded parent
|
|
153
|
+
// makes that impossible. `CARD_SX` is the prototype's hairline verbatim —
|
|
154
|
+
// `Paper variant="outlined"` would paint the theme's divider instead.
|
|
150
155
|
return (
|
|
151
|
-
<Paper
|
|
152
|
-
<Box sx={{ px:
|
|
156
|
+
<Paper elevation={0} sx={CARD_SX}>
|
|
157
|
+
<Box sx={{ px: '20px', pt: '18px', pb: '6px' }}>{header}</Box>
|
|
153
158
|
{band}
|
|
154
|
-
<Box
|
|
155
|
-
<Stack spacing={2}>{children}</Stack>
|
|
156
|
-
</Box>
|
|
159
|
+
<Box>{children}</Box>
|
|
157
160
|
</Paper>
|
|
158
161
|
);
|
|
159
162
|
}
|
|
@@ -188,7 +191,7 @@ function oauthWithConnect(props: ActivePanelProps): boolean {
|
|
|
188
191
|
* which is the one the connect card and the server-computed stage describe
|
|
189
192
|
* (the tabs inside the disclosure govern the form, not this).
|
|
190
193
|
*/
|
|
191
|
-
function oauthWalkthrough(props: ActivePanelProps): ReactNode {
|
|
194
|
+
function oauthWalkthrough(props: ActivePanelProps, path: 'oauth' | 'credentials'): ReactNode {
|
|
192
195
|
const { descriptor, config, guide } = props;
|
|
193
196
|
if (!guide || !oauthWithConnect(props)) return null;
|
|
194
197
|
return guide({
|
|
@@ -196,25 +199,41 @@ function oauthWalkthrough(props: ActivePanelProps): ReactNode {
|
|
|
196
199
|
sectionFooter: null,
|
|
197
200
|
editing: false,
|
|
198
201
|
stored: requiredStored(descriptor, config, config?.environment ?? 'SANDBOX'),
|
|
202
|
+
path,
|
|
199
203
|
});
|
|
200
204
|
}
|
|
201
205
|
|
|
206
|
+
/**
|
|
207
|
+
* One provider's screen, assembled.
|
|
208
|
+
*
|
|
209
|
+
* Three pieces of state live HERE rather than in the form, and each for a
|
|
210
|
+
* reason the form cannot see:
|
|
211
|
+
*
|
|
212
|
+
* - `environment` frames everything below it, so the panel puts the switch at
|
|
213
|
+
* the top instead of halfway down the form's own column.
|
|
214
|
+
* - `editing` decides more than the form: reopening step 1 must also take step
|
|
215
|
+
* 3 off the screen. A card offering to charge under a heading about typing a
|
|
216
|
+
* credential is two steps at once, and the one that costs money is the one
|
|
217
|
+
* nobody asked for.
|
|
218
|
+
* - `storedHere` answers for the environment ON SCREEN. The activation step
|
|
219
|
+
* charges through those credentials, and shown on a tab holding none it
|
|
220
|
+
* offers to charge an account that is not there — on the screen whose whole
|
|
221
|
+
* subject is which account receives the money. `config.environment` is the
|
|
222
|
+
* ACTIVE one and cannot answer it; the fields can.
|
|
223
|
+
*
|
|
224
|
+
* On the connect branch the walkthrough leaves the form (see `guide`) and the
|
|
225
|
+
* form stands alone inside the manual disclosure, so `walkthrough` is passed as
|
|
226
|
+
* a FUNCTION: which steps to show depends on the disclosure, whose state lives
|
|
227
|
+
* in `OAuthPanel`.
|
|
228
|
+
*/
|
|
202
229
|
export function ActivePanel(props: ActivePanelProps) {
|
|
203
230
|
const { descriptor, config, client, onChanged, reload, guide, verification } = props;
|
|
204
231
|
const statusBar = <EnableBar {...props} />;
|
|
205
|
-
// The environment frames everything below it, so the PANEL owns the choice
|
|
206
|
-
// and puts the tabs at the top — not the form, halfway down its own column.
|
|
207
232
|
const [environment, setEnvironment] = useState<PaymentEnvironment>(
|
|
208
233
|
config?.environment ?? 'SANDBOX',
|
|
209
234
|
);
|
|
210
|
-
// Lifted out of the form because it decides more than the form: reopening
|
|
211
|
-
// step 1 must also take step 3 off the screen. A card offering to charge
|
|
212
|
-
// R$ 1,01 under a heading about typing your InfiniteTag is two steps at once,
|
|
213
|
-
// and the one that costs money is the one you did not ask for.
|
|
214
235
|
const [editing, setEditing] = useState(false);
|
|
215
236
|
|
|
216
|
-
// On the connect branch the walkthrough leaves the form (see the `guide`
|
|
217
|
-
// prop): the form then stands alone inside the manual disclosure.
|
|
218
237
|
const oauthConnect = oauthWithConnect(props);
|
|
219
238
|
|
|
220
239
|
const credentials = (
|
|
@@ -233,13 +252,12 @@ export function ActivePanel(props: ActivePanelProps) {
|
|
|
233
252
|
);
|
|
234
253
|
|
|
235
254
|
const selector = <EnvironmentSelector environment={environment} onChange={setEnvironment} />;
|
|
236
|
-
|
|
255
|
+
// Full-bleed across the card, and the same notice inset for the manual
|
|
256
|
+
// disclosure, where it no longer spans anything. See `EnvironmentNotice`.
|
|
257
|
+
const active = config?.environment ?? null;
|
|
258
|
+
const band = <EnvironmentNotice environment={environment} active={active} />;
|
|
259
|
+
const notice = <EnvironmentNotice environment={environment} active={active} band={false} />;
|
|
237
260
|
|
|
238
|
-
// The activation step charges through the credentials of the environment it
|
|
239
|
-
// is under. Shown on a tab that stores none, it offers to charge an account
|
|
240
|
-
// that is not there — on the screen whose entire subject is which account
|
|
241
|
-
// receives the store's money. `config.environment` is the ACTIVE one, so it
|
|
242
|
-
// cannot answer this; the fields on screen can.
|
|
243
261
|
const storedHere = requiredStored(descriptor, config, environment);
|
|
244
262
|
|
|
245
263
|
if (descriptor.authMode !== 'oauth') {
|
|
@@ -263,11 +281,12 @@ export function ActivePanel(props: ActivePanelProps) {
|
|
|
263
281
|
<OAuthPanel
|
|
264
282
|
{...props}
|
|
265
283
|
statusBar={statusBar}
|
|
266
|
-
walkthrough={oauthWalkthrough(props)}
|
|
284
|
+
walkthrough={(path: 'oauth' | 'credentials') => oauthWalkthrough(props, path)}
|
|
267
285
|
form={
|
|
268
286
|
<Stack spacing={2}>
|
|
269
287
|
{selector}
|
|
270
|
-
{
|
|
288
|
+
{notice}
|
|
289
|
+
{oauthWalkthrough(props, 'credentials')}
|
|
271
290
|
{credentials}
|
|
272
291
|
</Stack>
|
|
273
292
|
}
|
|
@@ -275,80 +294,4 @@ export function ActivePanel(props: ActivePanelProps) {
|
|
|
275
294
|
);
|
|
276
295
|
}
|
|
277
296
|
|
|
278
|
-
/**
|
|
279
|
-
* The OAuth path: a connect button as the happy path, with the credential form
|
|
280
|
-
* kept behind a disclosure.
|
|
281
|
-
*
|
|
282
|
-
* That fallback is not decoration — stores connected before Connect existed
|
|
283
|
-
* still hold a pasted token, and a deployment with no registered provider
|
|
284
|
-
* application has no working connect button at all. Hiding the form outright
|
|
285
|
-
* would strand both.
|
|
286
|
-
*/
|
|
287
|
-
function OAuthPanel({
|
|
288
|
-
descriptor,
|
|
289
|
-
config,
|
|
290
|
-
client,
|
|
291
|
-
reload,
|
|
292
|
-
prepareConnect,
|
|
293
|
-
verification,
|
|
294
|
-
statusBar,
|
|
295
|
-
walkthrough,
|
|
296
|
-
form,
|
|
297
|
-
}: ActivePanelProps & { statusBar: ReactNode; walkthrough: ReactNode; form: ReactNode }) {
|
|
298
|
-
// The connection probe, for a store whose connection is a grant: the form's
|
|
299
|
-
// own probe runs off Salvar, which an OAuth store never presses (FUT-691).
|
|
300
|
-
const probe = (
|
|
301
|
-
<ConnectionProbe descriptor={descriptor} config={config} client={client} reload={reload} />
|
|
302
|
-
);
|
|
303
|
-
if (!prepareConnect) {
|
|
304
|
-
return (
|
|
305
|
-
<ProviderCard header={statusBar}>
|
|
306
|
-
<Alert severity="info">
|
|
307
|
-
Este provedor conecta por autorização, mas o botão de conexão não está disponível nesta
|
|
308
|
-
instalação. Você ainda pode conectar informando as credenciais manualmente.
|
|
309
|
-
</Alert>
|
|
310
|
-
{form}
|
|
311
|
-
{probe}
|
|
312
|
-
{verification}
|
|
313
|
-
</ProviderCard>
|
|
314
|
-
);
|
|
315
|
-
}
|
|
316
297
|
|
|
317
|
-
return (
|
|
318
|
-
<ProviderCard header={statusBar}>
|
|
319
|
-
<ProviderConnection
|
|
320
|
-
descriptor={descriptor}
|
|
321
|
-
config={config}
|
|
322
|
-
client={client}
|
|
323
|
-
prepareConnect={prepareConnect}
|
|
324
|
-
onChanged={reload}
|
|
325
|
-
/>
|
|
326
|
-
{/*
|
|
327
|
-
Directly under the connect card, ABOVE the manual disclosure: this is
|
|
328
|
-
the step that actually turns the store on, and an owner who just
|
|
329
|
-
authorized has no reason to open "prefiro informar as credenciais
|
|
330
|
-
manualmente" to find it.
|
|
331
|
-
*/}
|
|
332
|
-
{verification}
|
|
333
|
-
{/*
|
|
334
|
-
The provider's walkthrough, OUTSIDE the disclosure for the same reason
|
|
335
|
-
as the activation step above it: it used to live inside the credential
|
|
336
|
-
form, which on this branch is folded into the manual fallback — so the
|
|
337
|
-
guide (and the stepper answering "where am I") was buried behind a
|
|
338
|
-
label the connect card says there is no reason to open (FUT-691).
|
|
339
|
-
*/}
|
|
340
|
-
{walkthrough}
|
|
341
|
-
{probe}
|
|
342
|
-
{descriptor.credentialSchema.length > 0 ? (
|
|
343
|
-
<Accordion disableGutters data-testid="payments-manual-fallback">
|
|
344
|
-
<AccordionSummary expandIcon={<span aria-hidden>▾</span>}>
|
|
345
|
-
<Typography variant="body2" color="text.secondary">
|
|
346
|
-
Prefiro informar as credenciais manualmente
|
|
347
|
-
</Typography>
|
|
348
|
-
</AccordionSummary>
|
|
349
|
-
<AccordionDetails>{form}</AccordionDetails>
|
|
350
|
-
</Accordion>
|
|
351
|
-
) : null}
|
|
352
|
-
</ProviderCard>
|
|
353
|
-
);
|
|
354
|
-
}
|