Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Tweak experience while checkout is processing #4684

Merged
merged 2 commits into from
Dec 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions clients/.changeset/weak-maps-occur.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@polar-sh/checkout': patch
---

Prevent embed to be closed while checkout is processing payment
71 changes: 52 additions & 19 deletions clients/apps/web/src/components/Checkout/CheckoutForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ interface BaseCheckoutFormProps {
checkout: CheckoutPublic
disabled?: boolean
loading?: boolean
loadingLabel?: string
}

const BaseCheckoutForm = ({
Expand All @@ -85,6 +86,7 @@ const BaseCheckoutForm = ({
checkout,
disabled,
loading,
loadingLabel,
children,
}: React.PropsWithChildren<BaseCheckoutFormProps>) => {
const interval =
Expand All @@ -101,7 +103,6 @@ const BaseCheckoutForm = ({
setValue,
formState: { errors },
} = form

const country = watch('customer_billing_address.country')
const watcher: WatchObserver<CheckoutUpdatePublic> = useCallback(
async (value, { name, type }) => {
Expand Down Expand Up @@ -567,24 +568,32 @@ const BaseCheckoutForm = ({
)}
</div>
)}
<Button
type="submit"
size="lg"
wrapperClassNames="text-base"
disabled={disabled}
loading={loading}
>
{!checkout.is_payment_form_required
? 'Submit'
: interval
? 'Subscribe'
: 'Pay'}
</Button>
{errors.root && (
<p className="text-destructive-foreground text-sm">
{errors.root.message}
</p>
)}
<div className="flex w-full flex-col items-center justify-center gap-y-2">
<Button
type="submit"
size="lg"
wrapperClassNames="text-base"
className="w-full"
disabled={disabled}
loading={loading}
>
{!checkout.is_payment_form_required
? 'Submit'
: interval
? 'Subscribe'
: 'Pay'}
</Button>
{loading && loadingLabel && (
<p className="dark:text-polar-500 text-sm text-gray-500">
{loadingLabel}
</p>
)}
{errors.root && (
<p className="text-destructive-foreground text-sm">
{errors.root.message}
</p>
)}
</div>
</form>
</Form>
<p className="dark:text-polar-500 text-center text-xs text-gray-500">
Expand Down Expand Up @@ -617,6 +626,9 @@ const StripeCheckoutForm = (props: CheckoutFormProps) => {
const { setError } = useFormContext<CheckoutUpdatePublic>()
const { checkout, onCheckoutUpdate, onCheckoutConfirm, theme, embed } = props
const [loading, setLoading] = useState(false)
const [loadingLabel, setLoadingLabel] = useState<string | undefined>(
undefined,
)

const elementsOptions = useMemo<StripeElementsOptions>(() => {
if (
Expand Down Expand Up @@ -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 &&
Expand All @@ -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()
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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<string, string>

Expand Down Expand Up @@ -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 && (
<PaymentElement
Expand Down
4 changes: 2 additions & 2 deletions clients/examples/checkout-embed/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@
<body>
<div class="container">
<h1>My Product</h1>
<a href="http://127.0.0.1:8000/v1/checkout-links/polar_cl_3zLL3v_5SbV0XTIMtqGa62S2duWqV3DbY8T9vbAPL9A/redirect"
<a href="http://127.0.0.1:8000/v1/checkout-links/polar_cl_F08rlnewQlZ9mzASkZ2Zh7nNzrkR3LOckbqcuAoSbOw/redirect"
data-polar-checkout data-polar-checkout-theme="light" role="button" class="primary">
Purchase (light mode)
</a>
<a href="http://127.0.0.1:8000/v1/checkout-links/polar_cl_3zLL3v_5SbV0XTIMtqGa62S2duWqV3DbY8T9vbAPL9A/redirect"
<a href="http://127.0.0.1:8000/v1/checkout-links/polar_cl_F08rlnewQlZ9mzASkZ2Zh7nNzrkR3LOckbqcuAoSbOw/redirect"
data-polar-checkout data-polar-checkout-theme="dark" role="button" class="secondary">
Purchase (dark mode)
</a>
Expand Down
41 changes: 40 additions & 1 deletion clients/packages/checkout/src/embed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -31,6 +40,7 @@ interface EmbedCheckoutMessageSuccess {
type EmbedCheckoutMessage =
| EmbedCheckoutMessageLoaded
| EmbedCheckoutMessageClose
| EmbedCheckoutMessageConfirmed
| EmbedCheckoutMessageSuccess

const isEmbedCheckoutMessage = (
Expand All @@ -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))
}

Expand Down Expand Up @@ -206,6 +219,11 @@ class EmbedCheckout {
listener: (event: CustomEvent<EmbedCheckoutMessageClose>) => void,
options?: AddEventListenerOptions | boolean,
): void
public addEventListener(
type: 'confirmed',
listener: (event: CustomEvent<EmbedCheckoutMessageConfirmed>) => void,
options?: AddEventListenerOptions | boolean,
): void
public addEventListener(
type: 'success',
listener: (event: CustomEvent<EmbedCheckoutMessageSuccess>) => void,
Expand Down Expand Up @@ -233,6 +251,10 @@ class EmbedCheckout {
type: 'close',
listener: (event: CustomEvent<EmbedCheckoutMessageClose>) => void,
): void
public removeEventListener(
type: 'confirmed',
listener: (event: CustomEvent<EmbedCheckoutMessageConfirmed>) => void,
): void
public removeEventListener(
type: 'success',
listener: (event: CustomEvent<EmbedCheckoutMessageSuccess>) => void,
Expand Down Expand Up @@ -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<EmbedCheckoutMessageConfirmed>,
): void {
if (event.defaultPrevented) {
return
}
this.closable = false
}

/**
Expand All @@ -290,6 +328,7 @@ class EmbedCheckout {
if (event.defaultPrevented) {
return
}
this.closable = true
if (event.detail.redirect) {
window.location.href = event.detail.successURL
}
Expand Down
Loading