@asteby/storefront-react 0.4.0 → 0.5.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.
@@ -0,0 +1,364 @@
1
+ import * as react from 'react';
2
+ import { ReactNode, CSSProperties } from 'react';
3
+ import { Money, PaymentAttemptResult, PaymentClient, PaymentOutcomeKind, PaymentOutcome, PaymentErrorView, PaymentMessages, PaymentErrorKind } from '@asteby/storefront';
4
+
5
+ /**
6
+ * Replaceable ports of the embedded payment. The shop (or another provider)
7
+ * implements these; the hook and component only talk to them.
8
+ *
9
+ * Two ports, on purpose:
10
+ * - `PaymentFormAdapter` = the provider SDK in the browser (Mercado Pago Bricks, Stripe Elements...).
11
+ * It mounts a secure form and returns only what the SDK allows to travel (a one-use token).
12
+ * - `PaymentTransport` = how the browser reaches the shop SERVER. The browser never calls OPS: the shop's
13
+ * route forwards to `client.checkouts.pay(...)` with its private token. The HTTP contract between
14
+ * the shop route and OPS lives in `@asteby/storefront` (`checkouts.pay`); the shop route is free to
15
+ * shape its own JSON as long as it answers with a `PaymentAttemptResult`.
16
+ */
17
+
18
+ type PaymentFormError = {
19
+ /** `true`: the form cannot be used (SDK blocked, iframe failed). The hook falls back to a redirect. */
20
+ critical: boolean;
21
+ /** Short, non-sensitive text for logs. Never card data. */
22
+ message?: string;
23
+ };
24
+ type PaymentFormMountOptions = {
25
+ /** Element the provider renders into. Its `id` is set by the hook. */
26
+ container: HTMLElement;
27
+ client: PaymentClient;
28
+ /** Total computed by the server. Only passed to the provider widget; never calculated here. */
29
+ amount: Money;
30
+ email?: string;
31
+ locale?: string;
32
+ /** Visual tokens read from CSS variables (see `readPaymentTheme`). */
33
+ theme?: Record<string, string>;
34
+ onReady: () => void;
35
+ onError: (error: PaymentFormError) => void;
36
+ };
37
+ /** What a mounted form hands back at submit time. */
38
+ type PaymentFormData = {
39
+ /** Sent to the server as `provider_data`. Must never contain amounts or raw card data. */
40
+ provider_data: Record<string, unknown>;
41
+ /** Anti-fraud device fingerprint, if the SDK exposes one. */
42
+ device_session_id?: string;
43
+ };
44
+ interface MountedPaymentForm {
45
+ /** Tokenises the card inside the provider iframes. Rejects when the form is invalid (the provider shows the field errors). */
46
+ collect(): Promise<PaymentFormData>;
47
+ unmount(): void | Promise<void>;
48
+ }
49
+ interface PaymentFormAdapter {
50
+ /** `PaymentClient.kind` this adapter serves. */
51
+ readonly kind: string;
52
+ /** Loads the provider script (once). Rejects on timeout, blocked script or invalid URL. */
53
+ load(client: PaymentClient, options: {
54
+ nonce?: string;
55
+ timeoutMs: number;
56
+ }): Promise<void>;
57
+ mount(options: PaymentFormMountOptions): Promise<MountedPaymentForm>;
58
+ }
59
+ /** Everything the shop server needs for one attempt. No card data: only the provider token and choices. */
60
+ type PaymentSubmitRequest = {
61
+ option: 'card';
62
+ /** Stable per attempt: the same attempt always carries the same key, so a retry never charges twice. */
63
+ attempt: string;
64
+ idempotencyKey: string;
65
+ provider_data: Record<string, unknown>;
66
+ device_session_id?: string;
67
+ /** The total the buyer saw (the server's own number). The server answers 422 `price_mismatch` if it differs. */
68
+ expected_total: Money;
69
+ };
70
+ type PaymentTransport = {
71
+ submit(request: PaymentSubmitRequest, context: {
72
+ signal: AbortSignal;
73
+ }): Promise<PaymentAttemptResult>;
74
+ /** Optional: re-reads the checkout state (3DS challenge, "in review"). Same result shape as `submit`. */
75
+ refresh?(context: {
76
+ signal: AbortSignal;
77
+ }): Promise<PaymentAttemptResult>;
78
+ };
79
+ /** Error thrown by `createFetchTransport` for non-2xx answers; compatible with `paymentErrorView`. */
80
+ declare class PaymentHttpError extends Error {
81
+ readonly status: number;
82
+ readonly code: string;
83
+ /** Raw `error.details` (e.g. the new `cost` of a `price_mismatch`); untrusted shape. */
84
+ readonly details?: unknown | undefined;
85
+ readonly name = "PaymentHttpError";
86
+ constructor(status: number, code: string, message?: string,
87
+ /** Raw `error.details` (e.g. the new `cost` of a `price_mismatch`); untrusted shape. */
88
+ details?: unknown | undefined);
89
+ }
90
+ type FetchTransportOptions = {
91
+ fetch?: typeof fetch;
92
+ headers?: Record<string, string>;
93
+ /** Default `same-origin`. */
94
+ credentials?: RequestCredentials;
95
+ /** Shapes the JSON body; the default sends the request as is. */
96
+ buildBody?: (request: PaymentSubmitRequest) => unknown;
97
+ /** Same-origin URL that re-reads the state (enables `refresh`). */
98
+ refreshUrl?: string;
99
+ };
100
+ /**
101
+ * Transport to a same-origin shop route (`POST url` with JSON). Maps `{ error: { code, message, details } }`
102
+ * answers to `PaymentHttpError`. Network failures surface as the original `TypeError`, which the
103
+ * hook treats as retryable with the same attempt.
104
+ */
105
+ declare function createFetchTransport(url: string, options?: FetchTransportOptions): PaymentTransport;
106
+
107
+ type EmbeddedPaymentStatus = 'idle' | 'loading' | 'ready' | 'processing' | OutcomeStatus | 'network_error' | 'blocked' | 'fallback';
108
+ type OutcomeStatus = PaymentOutcomeKind;
109
+ type PaymentFallbackReason = 'sdk_timeout' | 'sdk_error' | 'form_error' | 'unsupported_provider' | 'no_client';
110
+ /** Anonymous funnel events: no personal or card data. */
111
+ type PaymentEvent = {
112
+ type: 'form_ready';
113
+ ms: number;
114
+ } | {
115
+ type: 'form_failed';
116
+ reason: PaymentFallbackReason;
117
+ } | {
118
+ type: 'tokenize_error';
119
+ } | {
120
+ type: 'submit';
121
+ } | {
122
+ type: 'result';
123
+ status: PaymentOutcomeKind;
124
+ decline_code: string | null;
125
+ } | {
126
+ type: 'error';
127
+ kind: PaymentErrorKind;
128
+ } | {
129
+ type: 'retry';
130
+ };
131
+ type UseEmbeddedPaymentOptions = {
132
+ /** Provider data from `paymentMethods[].client`. `null`/absent: nothing to mount (status `idle`). */
133
+ client: PaymentClient | null | undefined;
134
+ /** The total computed by the server. The Brick is remounted when it changes. Never calculate it in the shop. */
135
+ amount: Money;
136
+ transport: PaymentTransport;
137
+ /** Mount only while the card option is selected. Default true. */
138
+ enabled?: boolean;
139
+ email?: string;
140
+ locale?: string;
141
+ /** Replaces the adapter chosen from `client.kind`. */
142
+ adapter?: PaymentFormAdapter;
143
+ /** Extra adapters by `kind`. Mercado Pago is built in. */
144
+ adapters?: Record<string, PaymentFormAdapter>;
145
+ /** CSP nonce for the inserted script. */
146
+ nonce?: string;
147
+ /** Time to wait for the form to be ready before falling back. Default 8000. */
148
+ sdkTimeoutMs?: number;
149
+ /** Automatic resubmits (same attempt) after a network failure before showing the retry button. Default 2. */
150
+ autoRetries?: number;
151
+ /** Base delay for the backoff between automatic retries. Default 600. */
152
+ retryDelayMs?: number;
153
+ /** Poll interval/timeout for a pending 3DS challenge or review when `transport.refresh` exists. */
154
+ pollIntervalMs?: number;
155
+ pollTimeoutMs?: number;
156
+ /** Where the attempt id is kept so a reload/retry reuses it. Default `sessionStorage`; `null` disables. Never holds card data. */
157
+ storage?: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> | null;
158
+ /** Scope of the stored attempt (e.g. the checkout or cart id). Default `default`. */
159
+ scope?: string;
160
+ messages?: Partial<PaymentMessages>;
161
+ /** Called with the classified outcome of every definitive answer. */
162
+ onResult?: (outcome: PaymentOutcome, result: PaymentAttemptResult) => void;
163
+ /** Called for `redirect` outcomes. Default: `location.assign(url)` (https only). */
164
+ onRedirect?: (url: string) => void;
165
+ onFallback?: (reason: PaymentFallbackReason) => void;
166
+ onEvent?: (event: PaymentEvent) => void;
167
+ /** Overrides the theme read from `--sf-pay-*` tokens. */
168
+ theme?: Record<string, string>;
169
+ /** Test hooks. */
170
+ newAttemptId?: () => string;
171
+ };
172
+ type EmbeddedPaymentState = {
173
+ status: EmbeddedPaymentStatus;
174
+ /** Text to show for the current state (decline reason, review notice, error...). */
175
+ message: string | null;
176
+ outcome: PaymentOutcome | null;
177
+ error: PaymentErrorView | null;
178
+ fallbackReason: PaymentFallbackReason | null;
179
+ attemptsLeft: number | null;
180
+ /** Id of the current attempt (stable across network retries). */
181
+ attemptId: string | null;
182
+ /** `true` while loading, mounting or processing. */
183
+ busy: boolean;
184
+ /** Attach to the element the provider renders into. */
185
+ containerRef: (el: HTMLElement | null) => void;
186
+ containerId: string;
187
+ /** Tokenises and sends. No-ops while another submit is in flight (double click safe). */
188
+ submit: () => Promise<void>;
189
+ /** Repeats the last submit with the SAME attempt after a network failure. */
190
+ retry: () => Promise<void>;
191
+ /** After a rejection: clears the message (the form is remounted by the hook). */
192
+ dismiss: () => void;
193
+ };
194
+ /**
195
+ * Embedded payment state machine for one card form.
196
+ *
197
+ * Guarantees:
198
+ * - The amount is the server's: it is passed to the provider widget and echoed as `expected_total`; never computed.
199
+ * - One idempotency key per attempt (`attempt`), kept in `storage`, reused on network retries and rotated only
200
+ * after a definitive answer (a rejected card needs a new token, hence a new attempt).
201
+ * - Card data never reaches this code or storage: only the provider token, held in memory until the answer.
202
+ * - A double click returns the in-flight promise instead of sending twice.
203
+ */
204
+ declare function useEmbeddedPayment(options: UseEmbeddedPaymentOptions): EmbeddedPaymentState;
205
+
206
+ type VoucherCardProps = {
207
+ outcome: PaymentOutcome;
208
+ locale?: string;
209
+ messages?: Partial<PaymentMessages>;
210
+ className?: string;
211
+ };
212
+ /**
213
+ * Cash/SPEI voucher: point, exact amount, reference, barcode/CLABE and deadline, all from the server.
214
+ * The link only renders for https URLs (already filtered by `paymentOutcome`).
215
+ */
216
+ declare function VoucherCard({ outcome, locale, messages, className }: VoucherCardProps): react.JSX.Element | null;
217
+ type PaymentChallengeProps = {
218
+ outcome: PaymentOutcome;
219
+ locale?: string;
220
+ messages?: Partial<PaymentMessages>;
221
+ /** Replace the default iframe (e.g. mount the provider's status screen). */
222
+ render?: (outcome: PaymentOutcome) => ReactNode;
223
+ onCancel?: () => void;
224
+ };
225
+ /** 3DS inside the page: a modal dialog with title and focus trap. Default content is the bank's https iframe. */
226
+ declare function PaymentChallenge({ outcome, locale, messages, render, onCancel }: PaymentChallengeProps): react.JSX.Element;
227
+ type PaymentBrickHandle = EmbeddedPaymentState;
228
+ type PaymentBrickProps = Omit<UseEmbeddedPaymentOptions, 'amount'> & {
229
+ /** The server's total. Shown on the button and passed to the provider widget. */
230
+ amount: Money;
231
+ /** When set, renders the shop's pay button with this label. Otherwise drive it through the ref. */
232
+ submitLabel?: ReactNode;
233
+ /** Hide the total next to the label. */
234
+ hideAmountInButton?: boolean;
235
+ /** Rendered instead of the built-in voucher card. */
236
+ renderVoucher?: (outcome: PaymentOutcome) => ReactNode;
237
+ renderChallenge?: (outcome: PaymentOutcome) => ReactNode;
238
+ /** Height reserved for the provider form so the page does not jump. Default 360. */
239
+ minHeight?: number | string;
240
+ className?: string;
241
+ style?: CSSProperties;
242
+ };
243
+ /**
244
+ * Card form for an embedded payment. Headless: no styles of its own, only `data-*` hooks and CSS variables
245
+ * (`--sf-pay-*`, read for the provider widget; `--sf-pay-min-height` for the reserved height).
246
+ *
247
+ * Accessibility: the container is labelled and `aria-busy` while loading; every message goes through one
248
+ * `role="status"` region (or `role="alert"` for errors, which also takes focus); the button explains why it is
249
+ * busy and never silently disables.
250
+ *
251
+ * Use the `ref` to get the same state the hook returns (`submit`, `retry`, `status`...) when the shop owns the button.
252
+ */
253
+ declare const PaymentBrick: react.ForwardRefExoticComponent<Omit<UseEmbeddedPaymentOptions, "amount"> & {
254
+ /** The server's total. Shown on the button and passed to the provider widget. */
255
+ amount: Money;
256
+ /** When set, renders the shop's pay button with this label. Otherwise drive it through the ref. */
257
+ submitLabel?: ReactNode;
258
+ /** Hide the total next to the label. */
259
+ hideAmountInButton?: boolean;
260
+ /** Rendered instead of the built-in voucher card. */
261
+ renderVoucher?: (outcome: PaymentOutcome) => ReactNode;
262
+ renderChallenge?: (outcome: PaymentOutcome) => ReactNode;
263
+ /** Height reserved for the provider form so the page does not jump. Default 360. */
264
+ minHeight?: number | string;
265
+ className?: string;
266
+ style?: CSSProperties;
267
+ } & react.RefAttributes<EmbeddedPaymentState>>;
268
+
269
+ /** The `PaymentClient.kind` the addon `mercadopago` declares. */
270
+ declare const MERCADOPAGO_BRICKS_KIND = "mercadopago_bricks";
271
+ type MpController = {
272
+ getFormData(): Promise<Record<string, unknown> | undefined | null>;
273
+ unmount(): void | Promise<void>;
274
+ };
275
+ type MpBricks = {
276
+ create(brick: string, containerId: string, settings: Record<string, unknown>): Promise<MpController>;
277
+ };
278
+ type MercadoPagoCtor = new (publicKey: string, options?: {
279
+ locale?: string;
280
+ }) => {
281
+ bricks(): MpBricks;
282
+ };
283
+ type MercadoPagoAdapterOptions = {
284
+ /** Exact origins allowed for `sdk_url`. Default: Mercado Pago's SDK origin only. */
285
+ allowedSdkOrigins?: readonly string[];
286
+ /** Resolves the SDK constructor. Default: `window.MercadoPago`. Inject a fake in tests. */
287
+ getSdk?: () => MercadoPagoCtor | undefined;
288
+ /** Reads the anti-fraud device id. Default: `window.MP_DEVICE_SESSION_ID`. */
289
+ getDeviceSessionId?: () => string | undefined;
290
+ };
291
+ /**
292
+ * The only fields that may leave the browser, and the only place that decides it. Anything else the SDK returns
293
+ * (notably `transaction_amount`, the amount: the server owns it) is dropped. Strings only, bounded length.
294
+ */
295
+ declare function sanitizeMercadoPagoFormData(data: Record<string, unknown> | null | undefined): Record<string, unknown>;
296
+ /**
297
+ * Card Payment Brick adapter. The payment button is hidden: the shop's own button calls `collect()` (MP's
298
+ * `getFormData()`), which tokenises inside MP's iframes. Card data never touches this code.
299
+ */
300
+ declare function mercadoPagoBricksAdapter(options?: MercadoPagoAdapterOptions): PaymentFormAdapter;
301
+
302
+ /**
303
+ * Loads a provider script once, with timeout, CSP nonce and an origin allowlist.
304
+ * The URL comes from the server (`PaymentClient.sdk_url`) but is still checked:
305
+ * https only and, when `allowedOrigins` is given, an exact origin match.
306
+ */
307
+ type LoadScriptOptions = {
308
+ nonce?: string;
309
+ timeoutMs: number;
310
+ /** Exact origins allowed for the script (`https://sdk.mercadopago.com`). Omitted: any https origin. */
311
+ allowedOrigins?: readonly string[];
312
+ /** `true` when the SDK global is already there (skips the insert). */
313
+ isLoaded?: () => boolean;
314
+ document?: Document;
315
+ };
316
+ declare function loadPaymentSdk(url: string, options: LoadScriptOptions): Promise<void>;
317
+ /** Test helper: forgets the loaded-script cache. */
318
+ declare function resetPaymentSdkCache(): void;
319
+
320
+ type PaymentCspOptions = {
321
+ /** Per-request nonce; also set it on the inserted `<script>` and on `<Scripts nonce>`. */
322
+ nonce?: string;
323
+ /** Extra origins by directive (e.g. your image CDN). */
324
+ extra?: {
325
+ script?: string[];
326
+ frame?: string[];
327
+ connect?: string[];
328
+ img?: string[];
329
+ form?: string[];
330
+ };
331
+ /** `report-uri` / `report-to` target for the report-only rollout. */
332
+ reportUri?: string;
333
+ };
334
+ /**
335
+ * Builds the Content-Security-Policy for the payment routes from what each provider declares
336
+ * (`PaymentClient.csp`), so the shop never hard-codes provider hosts. Meant for `/pagar` only,
337
+ * never for the whole site. Roll out as `Content-Security-Policy-Report-Only` first.
338
+ *
339
+ * No SRI on purpose: provider SDK URLs are unversioned and a hash would break payments on any update.
340
+ */
341
+ declare function paymentCsp(clients: readonly (PaymentClient | null | undefined)[], options?: PaymentCspOptions): string;
342
+ /** `Content-Security-Policy` or, during the rollout week, `Content-Security-Policy-Report-Only`. */
343
+ declare function paymentCspHeaderName(reportOnly: boolean): string;
344
+
345
+ /** CSS custom properties (set by the shop in its stylesheet) -> provider widget variables. */
346
+ declare const PAYMENT_THEME_TOKENS: {
347
+ readonly '--sf-pay-text': "textPrimaryColor";
348
+ readonly '--sf-pay-text-muted': "textSecondaryColor";
349
+ readonly '--sf-pay-background': "formBackgroundColor";
350
+ readonly '--sf-pay-input-background': "inputBackgroundColor";
351
+ readonly '--sf-pay-primary': "baseColor";
352
+ readonly '--sf-pay-primary-contrast': "buttonTextColor";
353
+ readonly '--sf-pay-error': "errorColor";
354
+ readonly '--sf-pay-success': "successColor";
355
+ readonly '--sf-pay-outline': "outlinePrimaryColor";
356
+ readonly '--sf-pay-radius': "borderRadiusMedium";
357
+ };
358
+ /**
359
+ * Reads the `--sf-pay-*` tokens from an element's computed style, so the provider form follows the shop's
360
+ * theme without the shop copying colors into JavaScript. Unsafe or empty values are skipped.
361
+ */
362
+ declare function readPaymentTheme(el: Element | null | undefined, tokens?: Record<string, string>): Record<string, string>;
363
+
364
+ export { type EmbeddedPaymentState, type EmbeddedPaymentStatus, type FetchTransportOptions, type LoadScriptOptions, MERCADOPAGO_BRICKS_KIND, type MercadoPagoAdapterOptions, type MercadoPagoCtor, type MountedPaymentForm, PAYMENT_THEME_TOKENS, PaymentBrick, type PaymentBrickHandle, type PaymentBrickProps, PaymentChallenge, type PaymentChallengeProps, type PaymentCspOptions, type PaymentEvent, type PaymentFallbackReason, type PaymentFormAdapter, type PaymentFormData, type PaymentFormError, type PaymentFormMountOptions, PaymentHttpError, type PaymentSubmitRequest, type PaymentTransport, type UseEmbeddedPaymentOptions, VoucherCard, type VoucherCardProps, createFetchTransport, loadPaymentSdk, mercadoPagoBricksAdapter, paymentCsp, paymentCspHeaderName, readPaymentTheme, resetPaymentSdkCache, sanitizeMercadoPagoFormData, useEmbeddedPayment };