diff --git a/clients/.changeset/weak-maps-occur.md b/clients/.changeset/weak-maps-occur.md new file mode 100644 index 0000000000..517b356709 --- /dev/null +++ b/clients/.changeset/weak-maps-occur.md @@ -0,0 +1,5 @@ +--- +'@polar-sh/checkout': patch +--- + +Prevent embed to be closed while checkout is processing payment diff --git a/clients/apps/web/src/components/Checkout/CheckoutForm.tsx b/clients/apps/web/src/components/Checkout/CheckoutForm.tsx index 9fc56028d3..e426338ded 100644 --- a/clients/apps/web/src/components/Checkout/CheckoutForm.tsx +++ b/clients/apps/web/src/components/Checkout/CheckoutForm.tsx @@ -77,6 +77,7 @@ interface BaseCheckoutFormProps { checkout: CheckoutPublic disabled?: boolean loading?: boolean + loadingLabel?: string } const BaseCheckoutForm = ({ @@ -85,6 +86,7 @@ const BaseCheckoutForm = ({ checkout, disabled, loading, + loadingLabel, children, }: React.PropsWithChildren) => { const interval = @@ -101,7 +103,6 @@ const BaseCheckoutForm = ({ setValue, formState: { errors }, } = form - const country = watch('customer_billing_address.country') const watcher: WatchObserver = useCallback( async (value, { name, type }) => { @@ -567,24 +568,32 @@ const BaseCheckoutForm = ({ )} )} - - {errors.root && ( -

- {errors.root.message} -

- )} +
+ + {loading && loadingLabel && ( +

+ {loadingLabel} +

+ )} + {errors.root && ( +

+ {errors.root.message} +

+ )} +

@@ -617,6 +626,9 @@ const StripeCheckoutForm = (props: CheckoutFormProps) => { const { setError } = useFormContext() const { checkout, onCheckoutUpdate, onCheckoutConfirm, theme, embed } = props const [loading, setLoading] = useState(false) + const [loadingLabel, setLoadingLabel] = useState( + undefined, + ) const elementsOptions = useMemo(() => { if ( @@ -677,6 +689,11 @@ const StripeCheckoutForm = (props: CheckoutFormProps) => { let webhookEventDelivered = false const checkResolution = () => { + if (checkoutSuccessful && orderCreated && subscriptionCreated) { + setLoadingLabel( + `Waiting confirmation from ${checkout.organization.name} `, + ) + } if ( checkoutSuccessful && orderCreated && @@ -692,6 +709,7 @@ const StripeCheckoutForm = (props: CheckoutFormProps) => { }) => { if (data.status === CheckoutStatus.SUCCEEDED) { checkoutSuccessful = true + setLoadingLabel('Payment successful! Processing order...') checkoutEvents.off('checkout.updated', checkoutUpdatedListener) checkResolution() } @@ -773,8 +791,18 @@ const StripeCheckoutForm = (props: CheckoutFormProps) => { setLoading(true) + if (checkout.embed_origin) { + PolarEmbedCheckout.postMessage( + { + event: 'confirmed', + }, + checkout.embed_origin, + ) + } + if (!checkout.is_payment_form_required) { let updatedCheckout: CheckoutPublicConfirmed + setLoadingLabel('Processing order') try { updatedCheckout = await onCheckoutConfirm(data) } catch (e) { @@ -793,6 +821,8 @@ const StripeCheckoutForm = (props: CheckoutFormProps) => { return } + setLoadingLabel('Processing payment') + const { error: submitError } = await elements.submit() if (submitError) { // Don't show validation errors, as they are already shown in their form @@ -855,6 +885,8 @@ const StripeCheckoutForm = (props: CheckoutFormProps) => { return } + setLoadingLabel('Payment successful! Getting your products ready') + const { intent_status, intent_client_secret } = updatedCheckout.payment_processor_metadata as Record @@ -972,6 +1004,7 @@ const StripeCheckoutForm = (props: CheckoutFormProps) => { onSubmit={(data) => onSubmit(data, stripe, elements)} onCheckoutUpdate={onCheckoutUpdate} loading={loading} + loadingLabel={loadingLabel} > {checkout.is_payment_form_required && (

My Product

- Purchase (light mode) - Purchase (dark mode) diff --git a/clients/packages/checkout/src/embed.ts b/clients/packages/checkout/src/embed.ts index 590b770b12..f894b244fc 100644 --- a/clients/packages/checkout/src/embed.ts +++ b/clients/packages/checkout/src/embed.ts @@ -14,6 +14,15 @@ interface EmbedCheckoutMessageClose { event: 'close' } +/** + * Message sent to the parent window when the checkout is confirmed. + * + * At that point, the parent window shouldn't allow to close the checkout. + */ +interface EmbedCheckoutMessageConfirmed { + event: 'confirmed' +} + /** * Message sent to the parent window when the checkout is successfully completed. * @@ -31,6 +40,7 @@ interface EmbedCheckoutMessageSuccess { type EmbedCheckoutMessage = | EmbedCheckoutMessageLoaded | EmbedCheckoutMessageClose + | EmbedCheckoutMessageConfirmed | EmbedCheckoutMessageSuccess const isEmbedCheckoutMessage = ( @@ -46,16 +56,19 @@ class EmbedCheckout { private iframe: HTMLIFrameElement private loader: HTMLDivElement private loaded: boolean + private closable: boolean private eventTarget: EventTarget public constructor(iframe: HTMLIFrameElement, loader: HTMLDivElement) { this.iframe = iframe this.loader = loader this.loaded = false + this.closable = true this.eventTarget = new EventTarget() this.initWindowListener() this.addEventListener('loaded', this.loadedListener.bind(this)) this.addEventListener('close', this.closeListener.bind(this)) + this.addEventListener('confirmed', this.confirmedListener.bind(this)) this.addEventListener('success', this.successListener.bind(this)) } @@ -206,6 +219,11 @@ class EmbedCheckout { listener: (event: CustomEvent) => void, options?: AddEventListenerOptions | boolean, ): void + public addEventListener( + type: 'confirmed', + listener: (event: CustomEvent) => void, + options?: AddEventListenerOptions | boolean, + ): void public addEventListener( type: 'success', listener: (event: CustomEvent) => void, @@ -233,6 +251,10 @@ class EmbedCheckout { type: 'close', listener: (event: CustomEvent) => void, ): void + public removeEventListener( + type: 'confirmed', + listener: (event: CustomEvent) => void, + ): void public removeEventListener( type: 'success', listener: (event: CustomEvent) => void, @@ -276,7 +298,23 @@ class EmbedCheckout { if (event.defaultPrevented) { return } - this.close() + if (this.closable) { + this.close() + } + } + + /** + * Default listener for the `confirmed` event. + * + * This listener will set a flag to prevent the parent window from closing the embedded checkout. + */ + private confirmedListener( + event: CustomEvent, + ): void { + if (event.defaultPrevented) { + return + } + this.closable = false } /** @@ -290,6 +328,7 @@ class EmbedCheckout { if (event.defaultPrevented) { return } + this.closable = true if (event.detail.redirect) { window.location.href = event.detail.successURL }