From 871a50b52ba9bcd9ad2d0320d8c289886241b586 Mon Sep 17 00:00:00 2001 From: Swashata Ghosh Date: Tue, 3 Sep 2024 17:02:19 +0530 Subject: [PATCH] [cart] [new] [refactor] Add feature to auto restore from cart + Minor refactor to make the event system of the Iframe better. --- src/checkout-adaptor.ts | 5 ++ src/lib/checkout.cart.spec.ts | 49 +++++++++++ src/lib/checkout.spec.ts | 2 - src/lib/checkout.ts | 37 ++++++-- src/lib/services/cart/index.ts | 86 +++++++++++++++++++ .../services/checkout-popup/CheckoutIFrame.ts | 86 ++++++++++++------- src/lib/services/checkout-popup/index.ts | 10 +-- src/lib/utils/ops.ts | 9 ++ 8 files changed, 240 insertions(+), 44 deletions(-) create mode 100644 src/checkout-adaptor.ts create mode 100644 src/lib/checkout.cart.spec.ts create mode 100644 src/lib/services/cart/index.ts diff --git a/src/checkout-adaptor.ts b/src/checkout-adaptor.ts new file mode 100644 index 0000000..bb7ba8a --- /dev/null +++ b/src/checkout-adaptor.ts @@ -0,0 +1,5 @@ +/** + * Backward compatible adaptor for the checkout service. + * + * @todo - Make sure it handles cart recovery with a singleton. + */ diff --git a/src/lib/checkout.cart.spec.ts b/src/lib/checkout.cart.spec.ts new file mode 100644 index 0000000..3749889 --- /dev/null +++ b/src/lib/checkout.cart.spec.ts @@ -0,0 +1,49 @@ +/** + * @jest-environment jsdom + * @jest-environment-options {"url": "http://localhost/pricing/?__fs_auth_date=Tue%2C+03+Sep+2024+10%3A57%3A45+%2B0000&__fs_authorization=FSE+9885%3AFq28cI8tXbGkuMc7Up-A632PqvplKYYTbz9JEjdgXR3Rlc2PrxbUs_bmJsnCtzYZ7obXDcGz2gOe_3GvFl_dj141aTyF07LLGsZbpHwTnxxjug20VW1j0QAV49n9UN_1ZfHJ-mQvzh866Lvtq1BbYQ&__fs_expires_in=31536000&__fs_plugin_id=1&__fs_plugin_public_key=pk_ccca7be7fa43aec791448b43c6266&plugin_id=1&plan_id=16635"} + */ + +import { screen } from '@testing-library/dom'; +import { FSCheckout } from './checkout'; + +// Make sure that setting the jest environment options in the file works, just to fail earlier. +test('test runs with the correct URL', () => { + expect(window.location.href).toContain('__fs_auth_'); +}); + +describe('Checkout with Cart', () => { + test("should open automatically if the cart is present in the URL and cart's plugin ID matches", () => { + const checkout = new FSCheckout({ + plugin_id: 1, + public_key: 'pk_123456', + }); + + const guid = checkout.getGuid(); + + expect( + screen.queryByTestId(`fs-checkout-page-${guid}`) + ).toBeInTheDocument(); + + const src = screen + .getByTestId(`fs-checkout-page-${guid}`) + .getAttribute('src'); + + expect(src).toContain('plugin_id=1'); + expect(src).toContain('public_key='); + expect(src).toContain('__fs_auth_date'); + expect(src).toContain('__fs_authorization'); + }); + + test("should open automatically if the cart is present in the URL and cart's plugin ID does not match", () => { + const checkout = new FSCheckout({ + plugin_id: 2, + public_key: 'pk_123456', + }); + + const guid = checkout.getGuid(); + + expect( + screen.queryByTestId(`fs-checkout-page-${guid}`) + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/lib/checkout.spec.ts b/src/lib/checkout.spec.ts index 66c87a0..414a550 100644 --- a/src/lib/checkout.spec.ts +++ b/src/lib/checkout.spec.ts @@ -186,6 +186,4 @@ describe('CheckoutPopup', () => { expect(iFrame.src).toContain(`${key}=`); }); }); - - test.todo('should open automatically if the cart is present in the URL'); }); diff --git a/src/lib/checkout.ts b/src/lib/checkout.ts index 57cbe08..6a3bfea 100644 --- a/src/lib/checkout.ts +++ b/src/lib/checkout.ts @@ -18,24 +18,31 @@ import { ExitIntent } from './services/exit-intent'; import { ILoader } from './contracts/ILoader'; import { IExitIntent } from './contracts/IExitIntent'; import { IStyle } from './contracts/IStyle'; +import { Cart } from './services/cart'; export type { PostmanEvents, CheckoutOptions }; export class FSCheckout { - private options: CheckoutOptions = { plugin_id: 0, public_key: '' }; + private readonly options: CheckoutOptions = { + plugin_id: 0, + public_key: '', + }; - private guid: string; + private readonly guid: string; - public style?: IStyle; + public readonly style?: IStyle; - private loader?: ILoader; + private readonly loader?: ILoader; - private checkoutPopup?: CheckoutPopup; + private readonly checkoutPopup?: CheckoutPopup; - private exitIntent?: IExitIntent; + private readonly exitIntent?: IExitIntent; + + private readonly cart?: Cart; constructor( options: CheckoutOptions, - private readonly baseUrl: string = 'https://checkout.freemius.com' + private readonly baseUrl: string = 'https://checkout.freemius.com', + recoverCart: boolean = true ) { if (!options.plugin_id) { throw new Error('Must provide a plugin_id to options.'); @@ -71,6 +78,12 @@ export class FSCheckout { ); this.style.attach(); + + this.cart = new Cart(new URL(window.location.href)); + + if (recoverCart) { + this.recoverCart(); + } } /** @@ -123,6 +136,16 @@ export class FSCheckout { public getGuid() { return this.guid; } + + private recoverCart() { + if ( + this.cart?.hasCart() && + this.cart?.matchesPluginID(this.options.plugin_id) + ) { + const checkoutOptions = this.cart.getCheckoutOptions(); + this.open(checkoutOptions); + } + } } export { diff --git a/src/lib/services/cart/index.ts b/src/lib/services/cart/index.ts new file mode 100644 index 0000000..72102f6 --- /dev/null +++ b/src/lib/services/cart/index.ts @@ -0,0 +1,86 @@ +import { CheckoutPopupOptions } from '../../contracts/CheckoutPopupOptions'; +import { assertNotNull } from '../../utils/ops'; + +/** + * @note - URL has the following search params when cart is present: ?__fs_auth_date=Tue%2C+03+Sep+2024+10%3A57%3A45+%2B0000&__fs_authorization=FSE+9885%3AFq28cI8tXbGkuMc7Up-A632PqvplKYYTbz9JEjdgXR3Rlc2PrxbUs_bmJsnCtzYZ7obXDcGz2gOe_3GvFl_dj141aTyF07LLGsZbpHwTnxxjug20VW1j0QAV49n9UN_1ZfHJ-mQvzh866Lvtq1BbYQ&__fs_expires_in=31536000&__fs_plugin_id=9885&__fs_plugin_public_key=pk_ccca7be7fa43aec791448b43c6266&plugin_id=9885&plan_id=16635 + */ +export class Cart { + private readonly queryParams: Record | null = null; + + static readonly NO_CART_FOUND_MESSAGE = 'No cart was found'; + + constructor(private readonly url: URL) { + this.queryParams = this.parseQueryStringForCart(); + } + + hasCart(): boolean { + return this.queryParams !== null; + } + + getPluginID(): string { + assertNotNull(this.queryParams, Cart.NO_CART_FOUND_MESSAGE); + + return this.queryParams.__fs_plugin_id; + } + + getPluginPublicKey(): string { + assertNotNull(this.queryParams, Cart.NO_CART_FOUND_MESSAGE); + + return this.queryParams.__fs_plugin_public_key; + } + + matchesPluginID(pluginID: number | string): boolean { + const cartPluginID = Number.parseInt(this.getPluginID(), 10); + const requestedPluginID = Number.parseInt(pluginID.toString(), 10); + + return ( + Number.isFinite(cartPluginID) && + Number.isFinite(requestedPluginID) && + cartPluginID === requestedPluginID + ); + } + + getCheckoutOptions(): CheckoutPopupOptions { + assertNotNull(this.queryParams, Cart.NO_CART_FOUND_MESSAGE); + + const params: CheckoutPopupOptions = { + plugin_id: '', + public_key: '', + }; + + Object.entries(this.queryParams).forEach(([key, value]) => { + if ('__fs_plugin_id' === key) { + params.plugin_id = value; + } else if ('__fs_plugin_public_key' === key) { + params.public_key = value; + } else { + params[key] = value; + } + }); + + return params; + } + + private parseQueryStringForCart(): Record | null { + const AUTH_PARAM = '__fs_authorization'; + const searchParams = new URLSearchParams(this.url.search); + + if (!searchParams.has(AUTH_PARAM)) { + return null; + } + + // Return all query params that starts with __fs_ + const fsParams: Record = {}; + const plusRegex = new RegExp('\\+', 'g'); + + searchParams.forEach((value, key) => { + if (key.startsWith('__fs_')) { + fsParams[key] = decodeURIComponent( + value.replace(plusRegex, ' ') + ); + } + }); + + return fsParams; + } +} diff --git a/src/lib/services/checkout-popup/CheckoutIFrame.ts b/src/lib/services/checkout-popup/CheckoutIFrame.ts index eeaf2ec..2d8ed93 100644 --- a/src/lib/services/checkout-popup/CheckoutIFrame.ts +++ b/src/lib/services/checkout-popup/CheckoutIFrame.ts @@ -4,32 +4,26 @@ import { buildFreemiusQueryFromOptions } from '../../utils/ops'; import { Logger } from '../logger'; import { IExitIntent } from '../../contracts/IExitIntent'; +type EventListener = () => void; + export class CheckoutIFrame { private postman: PostmanEvents | null = null; + private readonly iFrame: HTMLIFrameElement; + private readonly loadedEventListeners: Set = new Set(); + + private readonly closedEventListeners: Set = new Set(); + constructor( private readonly baseUrl: string, - queryParams: Record, + queryParams: Record, iFrameID: string, private readonly visibleClass: string, private readonly checkoutEvents: CheckoutPopupEvents ) { - // Create the iFrame - const src = `${baseUrl}/?${buildFreemiusQueryFromOptions( - queryParams - )}#${encodeURIComponent(document.location.href)}`; - - const iFrame = document.createElement('iframe'); - iFrame.id = iFrameID; - iFrame.src = src; - - this.iFrame = iFrame; - } - - attach(onLoad?: () => void, onClose?: () => void) { - this.attachIFrame(); - this.addEventListeners(onLoad, onClose); + this.iFrame = this.attachIFrame(baseUrl, queryParams, iFrameID); + this.addEventListeners(); } close() { @@ -37,6 +31,14 @@ export class CheckoutIFrame { this.postman?.post('close', null); } + onClosed(callback: EventListener) { + this.closedEventListeners.add(callback); + } + + onLoaded(callback: EventListener) { + this.loadedEventListeners.add(callback); + } + addToExitIntent(exitIntent: IExitIntent) { exitIntent.addListener(() => { this.postman?.post('exit_intent', null); @@ -44,25 +46,39 @@ export class CheckoutIFrame { }); } - private attachIFrame() { - this.iFrame.setAttribute('allowTransparency', 'true'); - this.iFrame.setAttribute('width', '100%'); - this.iFrame.setAttribute('height', '100%'); - this.iFrame.setAttribute( + private attachIFrame( + baseUrl: string, + queryParams: Record, + iFrameID: string + ): HTMLIFrameElement { + const src = `${baseUrl}/?${buildFreemiusQueryFromOptions( + queryParams + )}#${encodeURIComponent(document.location.href)}`; + + const iFrame = document.createElement('iframe'); + iFrame.id = iFrameID; + iFrame.src = src; + + iFrame.setAttribute('allowTransparency', 'true'); + iFrame.setAttribute('width', '100%'); + iFrame.setAttribute('height', '100%'); + iFrame.setAttribute( 'style', 'background: rgba(0,0,0,0.003); border: 0 none transparent;' ); - this.iFrame.setAttribute('frameborder', '0'); + iFrame.setAttribute('frameborder', '0'); - // @todo - Remove this and update the tests. + // @todo - Remove this and update the tests to query by ID instead. if (process.env.NODE_ENV === 'test') { - this.iFrame.setAttribute('data-testid', this.iFrame.id); + iFrame.setAttribute('data-testid', iFrame.id); } - document.body.appendChild(this.iFrame); + document.body.appendChild(iFrame); + + return iFrame; } - private addEventListeners(onLoad?: () => void, onClose?: () => void) { + private addEventListeners() { const { success, purchaseCompleted, @@ -83,7 +99,7 @@ export class CheckoutIFrame { Logger.Error(e); } - onClose?.(); + this.dispatchOnClosed(); this.removeIFrameAndPostman(); afterClose?.(); }, @@ -111,7 +127,7 @@ export class CheckoutIFrame { Logger.Error(e); } - onClose?.(); + this.dispatchOnClosed(); this.removeIFrameAndPostman(); afterClose?.(); }, @@ -129,7 +145,7 @@ export class CheckoutIFrame { this.postman?.one( 'loaded', () => { - onLoad?.(); + this.dispatchOnLoaded(); this.iFrame?.classList.add(this.visibleClass); @@ -149,5 +165,17 @@ export class CheckoutIFrame { this.postman = null; this.iFrame.remove(); + + // Reset the event listeners + this.closedEventListeners.clear(); + this.loadedEventListeners.clear(); + } + + private dispatchOnLoaded() { + this.loadedEventListeners.forEach((listener) => listener()); + } + + private dispatchOnClosed() { + this.closedEventListeners.forEach((listener) => listener()); } } diff --git a/src/lib/services/checkout-popup/index.ts b/src/lib/services/checkout-popup/index.ts index 0d5d80c..97ccdc7 100644 --- a/src/lib/services/checkout-popup/index.ts +++ b/src/lib/services/checkout-popup/index.ts @@ -49,10 +49,8 @@ export class CheckoutPopup { this.checkoutIFrame = this.checkoutIFrameBuilder.create(overrideOptions); - this.checkoutIFrame.attach( - this.onLoad.bind(this), - this.onClose.bind(this) - ); + this.checkoutIFrame.onLoaded(this.onLoaded.bind(this)); + this.checkoutIFrame.onClosed(this.onClosed.bind(this)); this.checkoutIFrame.addToExitIntent(this.exitIntent); @@ -65,11 +63,11 @@ export class CheckoutPopup { return this; } - private onLoad() { + private onLoaded() { this.loader.hide(); } - private onClose() { + private onClosed() { this.checkoutIFrame = null; this.loader.hide(); diff --git a/src/lib/utils/ops.ts b/src/lib/utils/ops.ts index edeb882..96dda96 100644 --- a/src/lib/utils/ops.ts +++ b/src/lib/utils/ops.ts @@ -107,3 +107,12 @@ export function isExitAttempt(event: MouseEvent) { export function isSsr(): boolean { return typeof window === 'undefined'; } + +export function assertNotNull( + value: T, + message: string +): asserts value is NonNullable { + if (value === null || value === undefined) { + throw new Error(message); + } +}