@verbb/formie-browser 1.0.10 → 1.0.12

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.
Files changed (33) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/chunks/{api-BYe9YPko.js → api-DqpfkZWL.js} +15 -2
  3. package/dist/chunks/{bpoint-C0VOftX9.js → bpoint-Dlq34XH1.js} +1 -1
  4. package/dist/chunks/{calculations-HAZ8wUrT.js → calculations-DERi2xaC.js} +8 -8
  5. package/dist/chunks/{conditions-BiA_uANV.js → conditions-65kBcFWW.js} +1 -1
  6. package/dist/chunks/{dist-BaHXfvBO.js → dist-BdUy8m_p.js} +2 -2
  7. package/dist/chunks/{eway-CS6Y1Yvo.js → eway-D9_m8ooj.js} +1 -1
  8. package/dist/chunks/{go-cardless-BTPD668l.js → go-cardless-DAT3ZaQf.js} +1 -1
  9. package/dist/chunks/{mollie-CXgBwvo6.js → mollie-Bibhl67p.js} +1 -1
  10. package/dist/chunks/{moneris-B6oLu7nj.js → moneris-H2wSqk9h.js} +1 -1
  11. package/dist/chunks/opayo-CR0DtsLa.js +179 -0
  12. package/dist/chunks/{paddle-GRlsm5mm.js → paddle-DkvIxQdG.js} +1 -1
  13. package/dist/chunks/{password-validation-D5AirwUM.js → password-validation-T0vDQPCr.js} +1 -1
  14. package/dist/chunks/{paypal-CdcZnf6r.js → paypal-CQpAF7Ua.js} +32 -31
  15. package/dist/chunks/{payway-CfmYOS__.js → payway-CwDkC7YZ.js} +1 -1
  16. package/dist/chunks/{square-BY--5M-X.js → square-DwdcOI1G.js} +1 -1
  17. package/dist/chunks/{stripe-uusYjWty.js → stripe-DaoG2FT1.js} +1 -1
  18. package/dist/chunks/{text-limit-nSATKyhp.js → text-limit-Bn18WrPU.js} +1 -1
  19. package/dist/css/theme/fields/_check-radio.css +3 -1
  20. package/dist/css/theme/integrations/_opayo.css +9 -0
  21. package/dist/index.js +16 -16
  22. package/dist/js/modules/payments/factories.d.ts +5 -1
  23. package/dist/js/modules/payments/factories.d.ts.map +1 -1
  24. package/dist/js/modules/payments/opayo.d.ts +17 -2
  25. package/dist/js/modules/payments/opayo.d.ts.map +1 -1
  26. package/dist/js/modules/payments/paypal.d.ts.map +1 -1
  27. package/package.json +2 -2
  28. package/src/css/theme/fields/_check-radio.css +3 -1
  29. package/src/css/theme/integrations/_opayo.css +9 -0
  30. package/src/js/modules/payments/factories.ts +27 -2
  31. package/src/js/modules/payments/opayo.ts +256 -69
  32. package/src/js/modules/payments/paypal.ts +23 -3
  33. package/dist/chunks/opayo-DPUA5CKr.js +0 -113
@@ -5,7 +5,7 @@ type SagePayTokeniseResult = {
5
5
  message: string;
6
6
  }>;
7
7
  };
8
- type SagePayGlobal = {
8
+ type SagePayOwnFormGlobal = {
9
9
  (opts: {
10
10
  merchantSessionKey: string;
11
11
  }): {
@@ -15,9 +15,24 @@ type SagePayGlobal = {
15
15
  }) => void;
16
16
  };
17
17
  };
18
+ type SagePayCheckoutInstance = {
19
+ tokenise: (opts?: {
20
+ newMerchantSessionKey?: string;
21
+ }) => void;
22
+ destroy: () => void;
23
+ };
24
+ type SagePayCheckoutGlobal = {
25
+ (opts: {
26
+ merchantSessionKey: string;
27
+ containerSelector: string;
28
+ onTokenise?: (result: SagePayTokeniseResult) => void;
29
+ reusableCardIdentifier?: string;
30
+ }): SagePayCheckoutInstance;
31
+ };
18
32
  declare global {
19
33
  interface Window {
20
- sagepayOwnForm?: SagePayGlobal;
34
+ sagepayOwnForm?: SagePayOwnFormGlobal;
35
+ sagepayCheckout?: SagePayCheckoutGlobal;
21
36
  }
22
37
  }
23
38
  export declare const opayoModule: import("../../..").FormieModuleDefinition;
@@ -1 +1 @@
1
- {"version":3,"file":"opayo.d.ts","sourceRoot":"","sources":["../../../../src/js/modules/payments/opayo.ts"],"names":[],"mappings":"AAKA,KAAK,qBAAqB,GAAG;IACzB,OAAO,EAAE,OAAO,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,MAAM,CAAC,EAAE,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACvC,CAAC;AAEF,KAAK,aAAa,GAAG;IACjB,CAAC,IAAI,EAAE;QAAE,kBAAkB,EAAE,MAAM,CAAA;KAAE,GAAG;QACpC,mBAAmB,EAAE,CAAC,IAAI,EAAE;YACxB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;YACpC,WAAW,EAAE,CAAC,MAAM,EAAE,qBAAqB,KAAK,IAAI,CAAC;SACxD,KAAK,IAAI,CAAC;KACd,CAAC;CACL,CAAC;AAEF,OAAO,CAAC,MAAM,CAAC;IACX,UAAU,MAAM;QACZ,cAAc,CAAC,EAAE,aAAa,CAAC;KAClC;CACJ;AAeD,eAAO,MAAM,WAAW,2CAwOtB,CAAC"}
1
+ {"version":3,"file":"opayo.d.ts","sourceRoot":"","sources":["../../../../src/js/modules/payments/opayo.ts"],"names":[],"mappings":"AAUA,KAAK,qBAAqB,GAAG;IACzB,OAAO,EAAE,OAAO,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,MAAM,CAAC,EAAE,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACvC,CAAC;AAEF,KAAK,oBAAoB,GAAG;IACxB,CAAC,IAAI,EAAE;QAAE,kBAAkB,EAAE,MAAM,CAAA;KAAE,GAAG;QACpC,mBAAmB,EAAE,CAAC,IAAI,EAAE;YACxB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;YACpC,WAAW,EAAE,CAAC,MAAM,EAAE,qBAAqB,KAAK,IAAI,CAAC;SACxD,KAAK,IAAI,CAAC;KACd,CAAC;CACL,CAAC;AAEF,KAAK,uBAAuB,GAAG;IAC3B,QAAQ,EAAE,CAAC,IAAI,CAAC,EAAE;QAAE,qBAAqB,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IAC9D,OAAO,EAAE,MAAM,IAAI,CAAC;CACvB,CAAC;AAEF,KAAK,qBAAqB,GAAG;IACzB,CAAC,IAAI,EAAE;QACH,kBAAkB,EAAE,MAAM,CAAC;QAC3B,iBAAiB,EAAE,MAAM,CAAC;QAC1B,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,qBAAqB,KAAK,IAAI,CAAC;QACrD,sBAAsB,CAAC,EAAE,MAAM,CAAC;KACnC,GAAG,uBAAuB,CAAC;CAC/B,CAAC;AAEF,OAAO,CAAC,MAAM,CAAC;IACX,UAAU,MAAM;QACZ,cAAc,CAAC,EAAE,oBAAoB,CAAC;QACtC,eAAe,CAAC,EAAE,qBAAqB,CAAC;KAC3C;CACJ;AAwFD,eAAO,MAAM,WAAW,2CAsUtB,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"paypal.d.ts","sourceRoot":"","sources":["../../../../src/js/modules/payments/paypal.ts"],"names":[],"mappings":"AA4EA,eAAO,MAAM,YAAY,2CAyIvB,CAAC"}
1
+ {"version":3,"file":"paypal.d.ts","sourceRoot":"","sources":["../../../../src/js/modules/payments/paypal.ts"],"names":[],"mappings":"AAqFA,eAAO,MAAM,YAAY,2CAoJvB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@verbb/formie-browser",
3
- "version": "1.0.10",
3
+ "version": "1.0.12",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -70,7 +70,7 @@
70
70
  "@placekit/autocomplete-js": "^2.2.1",
71
71
  "@uppy/core": "^5.2.0",
72
72
  "@uppy/xhr-upload": "^5.2.0",
73
- "@verbb/formie-core": "1.0.10",
73
+ "@verbb/formie-core": "1.0.12",
74
74
  "expression-language": "^2.5.4",
75
75
  "flatpickr": "^4.6.13",
76
76
  "friendly-challenge": "^0.9.19",
@@ -116,8 +116,10 @@
116
116
 
117
117
  .formie-field-option:has(> input[data-formie-other-option]),
118
118
  .formie-other-option {
119
+ display: flex;
119
120
  flex-wrap: wrap;
120
- align-items: flex-start;
121
+ align-items: center;
122
+ width: 100%;
121
123
  }
122
124
 
123
125
  .formie-field-option:has(> input[data-formie-other-option]:checked) > .formie-other-option-text,
@@ -0,0 +1,9 @@
1
+ @layer formie-theme {
2
+
3
+ /* Opayo drop-in checkout iframe mount point. */
4
+ .formie-opayo-drop-in {
5
+ width: 100%;
6
+ min-height: 10rem;
7
+ box-sizing: border-box;
8
+ }
9
+ }
@@ -33,6 +33,11 @@ type PaymentSetupResult = {
33
33
  onBeforeStage?: (stageCtx: import('#contracts/modules').SubmitHookContext) => void | Promise<void>;
34
34
  };
35
35
 
36
+ export type PaymentAfterSubmitResult = {
37
+ /** Tear down the current widget and mount again (for example after a failed payment). */
38
+ remount?: boolean;
39
+ };
40
+
36
41
  export type ManagedPaymentModuleAdapter<
37
42
  TProvider extends Record<string, unknown>,
38
43
  TApi,
@@ -74,7 +79,7 @@ export type ManagedPaymentModuleAdapter<
74
79
  options: NormalizedPaymentModuleOptions<TProvider>;
75
80
  provider: TProvider;
76
81
  result?: import('#contracts/schema').FormSubmitResult;
77
- }) => void | Promise<void>;
82
+ }) => void | Promise<void> | PaymentAfterSubmitResult | Promise<PaymentAfterSubmitResult>;
78
83
  };
79
84
 
80
85
  export function createManagedPaymentModule<
@@ -318,13 +323,33 @@ export function createManagedPaymentModule<
318
323
  return;
319
324
  }
320
325
 
321
- await adapter.onAfterSubmit({
326
+ const afterSubmitResult = await adapter.onAfterSubmit({
322
327
  field: ctx.target,
323
328
  services,
324
329
  options,
325
330
  provider: options.provider,
326
331
  result,
327
332
  });
333
+
334
+ if (!afterSubmitResult?.remount || !adapter.mount) {
335
+ return;
336
+ }
337
+
338
+ if (widget && adapter.unmount) {
339
+ const api = await getApi();
340
+
341
+ await adapter.unmount({
342
+ api,
343
+ widget,
344
+ field: ctx.target,
345
+ services,
346
+ options,
347
+ provider: options.provider,
348
+ });
349
+ }
350
+
351
+ widget = null;
352
+ await ensureMounted();
328
353
  },
329
354
  };
330
355
  },
@@ -1,15 +1,20 @@
1
+ import opayoCss from '#theme-css/integrations/_opayo.css?inline';
2
+
1
3
  import { definePaymentModule } from '#modules/payments/api';
4
+ import { ensureModuleStyles } from '#modules/styles';
2
5
  import { createDebug } from '#utils/debug';
3
6
  import { getPaymentProviderActionEventName } from '#utils/event-names';
4
7
  import { loadScriptAndEnsureGlobal } from '#utils/scripts';
5
8
 
9
+ ensureModuleStyles('opayo', [opayoCss]);
10
+
6
11
  type SagePayTokeniseResult = {
7
12
  success: boolean;
8
13
  cardIdentifier?: string;
9
14
  errors?: Array<{ message: string }>;
10
15
  };
11
16
 
12
- type SagePayGlobal = {
17
+ type SagePayOwnFormGlobal = {
13
18
  (opts: { merchantSessionKey: string }): {
14
19
  tokeniseCardDetails: (opts: {
15
20
  cardDetails: Record<string, string>;
@@ -18,34 +23,123 @@ type SagePayGlobal = {
18
23
  };
19
24
  };
20
25
 
26
+ type SagePayCheckoutInstance = {
27
+ tokenise: (opts?: { newMerchantSessionKey?: string }) => void;
28
+ destroy: () => void;
29
+ };
30
+
31
+ type SagePayCheckoutGlobal = {
32
+ (opts: {
33
+ merchantSessionKey: string;
34
+ containerSelector: string;
35
+ onTokenise?: (result: SagePayTokeniseResult) => void;
36
+ reusableCardIdentifier?: string;
37
+ }): SagePayCheckoutInstance;
38
+ };
39
+
21
40
  declare global {
22
41
  interface Window {
23
- sagepayOwnForm?: SagePayGlobal;
42
+ sagepayOwnForm?: SagePayOwnFormGlobal;
43
+ sagepayCheckout?: SagePayCheckoutGlobal;
24
44
  }
25
45
  }
26
46
 
47
+ type OpayoCheckoutMode = 'ownForm' | 'dropIn';
48
+
27
49
  type OpayoProviderOptions = {
28
50
  useSandbox?: boolean;
29
51
  handle?: string;
30
52
  sessionToken?: string | null;
53
+ checkoutMode?: OpayoCheckoutMode;
54
+ };
55
+
56
+ type OpayoDropInWidget = {
57
+ checkout: SagePayCheckoutInstance;
58
+ merchantSessionKey: string;
59
+ pendingAuthorize: ((success: boolean) => void) | null;
60
+ retriedTokenise: boolean;
31
61
  };
32
62
 
33
63
  const SCRIPT_ID = 'FORMIE_OPAYO_SCRIPT';
34
64
  const SCRIPT_SRC_LIVE = 'https://live.opayo.eu.elavon.com/api/v1/js/sagepay.js';
35
65
  const SCRIPT_SRC_SANDBOX = 'https://sandbox.opayo.eu.elavon.com/api/v1/js/sagepay.js';
66
+ const DROP_IN_SELECTOR = '[data-formie-opayo-drop-in]';
36
67
  const debug = createDebug('payments', 'opayo');
37
68
  const CHALLENGE_EVENT = getPaymentProviderActionEventName('opayo', 'challenge');
38
69
  const CHALLENGE_RESPONSE_MESSAGE = 'formie:payment:opayo:challenge:response';
39
70
 
40
- export const opayoModule = definePaymentModule<OpayoProviderOptions, null, null>({
71
+ function isDropInCheckoutMode(provider: OpayoProviderOptions): boolean {
72
+ return provider.checkoutMode === 'dropIn';
73
+ }
74
+
75
+ async function requestMerchantSessionKey(args: {
76
+ form: HTMLFormElement;
77
+ handle: string;
78
+ sessionToken: string;
79
+ services: {
80
+ addError: (message: string) => void;
81
+ };
82
+ }): Promise<string | null> {
83
+ const { form, handle, sessionToken, services } = args;
84
+ const formData = new FormData();
85
+ formData.append('action', 'formie/payment-webhooks/process-callback');
86
+ formData.append('merchantSessionKey', 'true');
87
+ formData.append('handle', handle);
88
+ formData.append('sessionToken', sessionToken);
89
+
90
+ try {
91
+ const res = await fetch(form.action, {
92
+ method: 'POST',
93
+ body: formData,
94
+ });
95
+
96
+ if (res.status < 200 || res.status >= 300) {
97
+ services.addError(`${res.status}: ${res.statusText}`);
98
+ debug.warn('Merchant session request failed.', {
99
+ status: res.status,
100
+ statusText: res.statusText,
101
+ });
102
+
103
+ return null;
104
+ }
105
+
106
+ const response = await res.json();
107
+ const merchantSessionKey = response.merchantSessionKey;
108
+
109
+ if (!merchantSessionKey) {
110
+ services.addError('Unable to get merchant session.');
111
+ debug.warn('merchantSessionKey missing in callback response.');
112
+
113
+ return null;
114
+ }
115
+
116
+ return merchantSessionKey as string;
117
+ } catch {
118
+ services.addError('Network error. Please try again.');
119
+ debug.warn('Network error requesting merchant session.');
120
+
121
+ return null;
122
+ }
123
+ }
124
+
125
+ function ensureDropInContainerId(container: HTMLElement): string {
126
+ if (!container.id) {
127
+ container.id = `formie-opayo-drop-in-${Math.random().toString(36).slice(2, 9)}`;
128
+ }
129
+
130
+ return container.id;
131
+ }
132
+
133
+ export const opayoModule = definePaymentModule<OpayoProviderOptions, null, OpayoDropInWidget | null>({
41
134
  id: 'opayo',
42
135
  defaultRequiredInputSuffixes: ['opayoTokenId'],
43
136
  load: async(ctx) => {
44
137
  const { provider } = ctx.options;
45
138
  const useSandbox = Boolean(provider.useSandbox);
46
139
  const src = useSandbox ? SCRIPT_SRC_SANDBOX : SCRIPT_SRC_LIVE;
140
+ const globalName = isDropInCheckoutMode(provider) ? 'sagepayCheckout' : 'sagepayOwnForm';
47
141
 
48
- await loadScriptAndEnsureGlobal<Window['sagepayOwnForm']>('sagepayOwnForm', {
142
+ await loadScriptAndEnsureGlobal<Window['sagepayOwnForm'] | Window['sagepayCheckout']>(globalName, {
49
143
  id: SCRIPT_ID,
50
144
  src,
51
145
  timeoutMs: 10000,
@@ -53,9 +147,118 @@ export const opayoModule = definePaymentModule<OpayoProviderOptions, null, null>
53
147
 
54
148
  return null;
55
149
  },
150
+ mount: async({ field, services, provider }) => {
151
+ if (!isDropInCheckoutMode(provider)) {
152
+ return null;
153
+ }
154
+
155
+ const form = services.form;
156
+ const sagepayCheckout = window.sagepayCheckout;
157
+ const container = field.querySelector<HTMLElement>(DROP_IN_SELECTOR);
158
+
159
+ if (!form?.action) {
160
+ services.addError('Form action is missing.');
161
+ debug.warn('Missing form action before drop-in mount.');
162
+
163
+ return null;
164
+ }
165
+
166
+ if (!sagepayCheckout) {
167
+ services.addError('Opayo script failed to load.');
168
+ debug.warn('sagepayCheckout global not available.');
169
+
170
+ return null;
171
+ }
172
+
173
+ if (!container) {
174
+ services.addError('Opayo drop-in container is missing.');
175
+ debug.warn('Drop-in container not found in payment field.');
176
+
177
+ return null;
178
+ }
179
+
180
+ const handle = (provider.handle || 'opayo') as string;
181
+ const merchantSessionKey = await requestMerchantSessionKey({
182
+ form,
183
+ handle,
184
+ sessionToken: provider.sessionToken || '',
185
+ services,
186
+ });
187
+
188
+ if (!merchantSessionKey) {
189
+ return null;
190
+ }
191
+
192
+ const containerId = ensureDropInContainerId(container);
193
+ const widget: OpayoDropInWidget = {
194
+ checkout: null as unknown as SagePayCheckoutInstance,
195
+ merchantSessionKey,
196
+ pendingAuthorize: null,
197
+ retriedTokenise: false,
198
+ };
199
+
200
+ widget.checkout = sagepayCheckout({
201
+ merchantSessionKey,
202
+ containerSelector: `#${containerId}`,
203
+ onTokenise: (result) => {
204
+ const resolve = widget.pendingAuthorize;
205
+ widget.pendingAuthorize = null;
206
+
207
+ if (!resolve) {
208
+ debug.warn('Drop-in tokenisation completed without a pending authorize step.');
209
+ return;
210
+ }
211
+
212
+ if (result.success && result.cardIdentifier) {
213
+ services.updateInputs('opayoTokenId', result.cardIdentifier);
214
+ services.updateInputs('opayoSessionKey', widget.merchantSessionKey);
215
+ debug.log('Drop-in tokenization succeeded.', {
216
+ hasCardIdentifier: !!result.cardIdentifier,
217
+ });
218
+ resolve(true);
219
+
220
+ return;
221
+ }
222
+
223
+ if (!widget.retriedTokenise) {
224
+ widget.retriedTokenise = true;
225
+ void requestMerchantSessionKey({
226
+ form,
227
+ handle,
228
+ sessionToken: provider.sessionToken || '',
229
+ services,
230
+ }).then((newMerchantSessionKey) => {
231
+ if (!newMerchantSessionKey) {
232
+ services.addError(result.errors?.[0]?.message || 'Tokenization failed.');
233
+ debug.warn('Drop-in tokenization failed after session refresh.', result);
234
+ resolve(false);
235
+
236
+ return;
237
+ }
238
+
239
+ widget.merchantSessionKey = newMerchantSessionKey;
240
+ widget.pendingAuthorize = resolve;
241
+ widget.checkout.tokenise({ newMerchantSessionKey });
242
+ });
243
+
244
+ return;
245
+ }
246
+
247
+ services.addError(result.errors?.[0]?.message || 'Tokenization failed.');
248
+ debug.warn('Drop-in tokenization failed.', result);
249
+ resolve(false);
250
+ },
251
+ });
252
+
253
+ debug.log('Drop-in checkout mounted.', { containerId });
254
+
255
+ return widget;
256
+ },
257
+ unmount: async({ widget }) => {
258
+ widget?.checkout?.destroy?.();
259
+ },
56
260
  onBeforeAuthorize: async(args) => {
57
- const { field, services, options } = args;
58
- const provider = options.provider as OpayoProviderOptions;
261
+ const { field, services, options, provider, widget } = args;
59
262
  const handle = (provider.handle || 'opayo') as string;
60
263
  const form = services.form;
61
264
 
@@ -66,7 +269,23 @@ export const opayoModule = definePaymentModule<OpayoProviderOptions, null, null>
66
269
  return false;
67
270
  }
68
271
 
69
- const sagepayOwnForm = (window as Window).sagepayOwnForm;
272
+ if (isDropInCheckoutMode(provider)) {
273
+ if (!widget) {
274
+ services.addError('Opayo drop-in checkout is not ready.');
275
+ debug.warn('Drop-in authorize requested before widget mount.');
276
+
277
+ return false;
278
+ }
279
+
280
+ widget.retriedTokenise = false;
281
+
282
+ return new Promise<boolean>((resolve) => {
283
+ widget.pendingAuthorize = resolve;
284
+ widget.checkout.tokenise();
285
+ });
286
+ }
287
+
288
+ const sagepayOwnForm = window.sagepayOwnForm;
70
289
 
71
290
  if (!sagepayOwnForm) {
72
291
  services.addError('Opayo script failed to load.');
@@ -83,72 +302,40 @@ export const opayoModule = definePaymentModule<OpayoProviderOptions, null, null>
83
302
  cardNumber = cardNumber.replace(/[\s/]/g, '');
84
303
  expiryDate = expiryDate.replace(/[\s/]/g, '');
85
304
 
86
- return new Promise<boolean>((resolve) => {
87
- const formData = new FormData();
88
- formData.append('action', 'formie/payment-webhooks/process-callback');
89
- formData.append('merchantSessionKey', 'true');
90
- formData.append('handle', handle);
91
- formData.append('sessionToken', provider.sessionToken || '');
92
-
93
- fetch(form.action, {
94
- method: 'POST',
95
- body: formData,
96
- }).then(async(res) => {
97
- if (res.status < 200 || res.status >= 300) {
98
- services.addError(`${res.status}: ${res.statusText}`);
99
- debug.warn('Merchant session request failed.', {
100
- status: res.status,
101
- statusText: res.statusText,
102
- });
103
- resolve(false);
104
-
105
- return;
106
- }
305
+ const merchantSessionKey = await requestMerchantSessionKey({
306
+ form,
307
+ handle,
308
+ sessionToken: provider.sessionToken || '',
309
+ services,
310
+ });
107
311
 
108
- try {
109
- const response = await res.json();
110
- const merchantSessionKey = response.merchantSessionKey;
312
+ if (!merchantSessionKey) {
313
+ return false;
314
+ }
111
315
 
112
- if (!merchantSessionKey) {
113
- services.addError('Unable to get merchant session.');
114
- debug.warn('merchantSessionKey missing in callback response.');
316
+ return new Promise<boolean>((resolve) => {
317
+ sagepayOwnForm({ merchantSessionKey }).tokeniseCardDetails({
318
+ cardDetails: {
319
+ cardholderName,
320
+ cardNumber,
321
+ expiryDate,
322
+ securityCode,
323
+ },
324
+ onTokenised: (result) => {
325
+ if (result.success && result.cardIdentifier) {
326
+ services.updateInputs('opayoTokenId', result.cardIdentifier);
327
+ services.updateInputs('opayoSessionKey', merchantSessionKey);
328
+ debug.log('Tokenization succeeded.', {
329
+ hasCardIdentifier: !!result.cardIdentifier,
330
+ });
331
+
332
+ resolve(true);
333
+ } else {
334
+ services.addError(result.errors?.[0]?.message || 'Tokenization failed.');
335
+ debug.warn('Tokenization failed.', result);
115
336
  resolve(false);
116
-
117
- return;
118
337
  }
119
-
120
- sagepayOwnForm({ merchantSessionKey }).tokeniseCardDetails({
121
- cardDetails: {
122
- cardholderName,
123
- cardNumber,
124
- expiryDate,
125
- securityCode,
126
- },
127
- onTokenised: (result) => {
128
- if (result.success && result.cardIdentifier) {
129
- services.updateInputs('opayoTokenId', result.cardIdentifier);
130
- services.updateInputs('opayoSessionKey', merchantSessionKey);
131
- debug.log('Tokenization succeeded.', {
132
- hasCardIdentifier: !!result.cardIdentifier,
133
- });
134
-
135
- resolve(true);
136
- } else {
137
- services.addError(result.errors?.[0]?.message || 'Tokenization failed.');
138
- debug.warn('Tokenization failed.', result);
139
- resolve(false);
140
- }
141
- },
142
- });
143
- } catch {
144
- services.addError('Unable to parse merchant session response.');
145
- debug.warn('Failed to parse merchant session response.');
146
- resolve(false);
147
- }
148
- }).catch(() => {
149
- services.addError('Network error. Please try again.');
150
- debug.warn('Network error requesting merchant session.');
151
- resolve(false);
338
+ },
152
339
  });
153
340
  });
154
341
  },
@@ -64,6 +64,15 @@ function extractAuthIdFromAuthorization(result: Record<string, unknown> | null):
64
64
  return authId.trim();
65
65
  }
66
66
 
67
+ function hidePayPalButtons(widget: PayPalButtonsInstance | null, placeholder: HTMLElement): void {
68
+ if (widget?.close) {
69
+ widget.close();
70
+ }
71
+
72
+ placeholder.removeAttribute('data-formie-paypal-rendered');
73
+ placeholder.innerHTML = '';
74
+ }
75
+
67
76
  function getScriptUrl(clientId: string, currency: string): string {
68
77
  const params = [
69
78
  'intent=authorize',
@@ -137,6 +146,8 @@ export const paypalModule = definePaymentModule<PayPalProviderOptions, PayPalGlo
137
146
  style.tagline = provider.buttonTagline ?? true;
138
147
  }
139
148
 
149
+ let widget: PayPalButtonsInstance | null = null;
150
+
140
151
  const paypalOptions: PayPalButtonsOptions = {
141
152
  env: useSandbox ? 'sandbox' : 'production',
142
153
  style,
@@ -183,6 +194,8 @@ export const paypalModule = definePaymentModule<PayPalProviderOptions, PayPalGlo
183
194
  // Allow submit to continue using order ID fallback on the backend.
184
195
  services.addSuccess('PayPal approval received. Finalizing payment on submit.');
185
196
  }
197
+
198
+ hidePayPalButtons(widget, placeholder);
186
199
  } catch {
187
200
  services.addError('Unable to authorize payment. Please try again.');
188
201
  }
@@ -191,9 +204,9 @@ export const paypalModule = definePaymentModule<PayPalProviderOptions, PayPalGlo
191
204
 
192
205
  const buttons = api.Buttons(paypalOptions);
193
206
  placeholder.setAttribute('data-formie-paypal-rendered', 'true');
194
- const instance = buttons.render(placeholder);
207
+ widget = buttons.render(placeholder);
195
208
 
196
- return instance;
209
+ return widget;
197
210
  },
198
211
  unmount: async (args) => {
199
212
  if (args.widget?.close) {
@@ -206,9 +219,16 @@ export const paypalModule = definePaymentModule<PayPalProviderOptions, PayPalGlo
206
219
  placeholder.innerHTML = '';
207
220
  }
208
221
  },
209
- onAfterSubmit: async ({ services }) => {
222
+ onAfterSubmit: async ({ services, result }) => {
210
223
  services.updateInputs(['paypalOrderId', 'paypalAuthId'], '');
211
224
  services.removeSuccess();
212
225
  services.removeError();
226
+
227
+ // Restore buttons when auth was cleared but payment did not fully complete.
228
+ if (!result?.ok || result?.nextPage) {
229
+ return { remount: true };
230
+ }
231
+
232
+ return {};
213
233
  },
214
234
  });