/** * WordPress dependencies */ import apiFetch from '@safe-wordpress/api-fetch'; import { Button } from '@safe-wordpress/components'; import { useSelect } from '@safe-wordpress/data'; import { useCallback, useEffect, useRef, useState, } from '@safe-wordpress/element'; import { _x } from '@safe-wordpress/i18n'; import { addQueryArgs } from '@safe-wordpress/url'; /** * External dependencies */ import { trim } from 'lodash'; import { store as NAB_ACCOUNT } from '@nab/account'; import { useActivityEffect, useIsSubscribed, usePluginSetting, } from '@nab/data'; import { openNativeBrowserDialog } from '@nab/utils'; import type { MutableRefObject } from 'react'; /** * Internal dependencies */ import './style.scss'; import { InfoModal } from './info-modal'; import { SubscribeModal } from './subscribe-modal'; export type SubscribeActionProps = { readonly className?: string; readonly children?: string; readonly variant: 'primary' | 'secondary' | 'link' | 'page-title-action'; }; type SessionResponse = { readonly sessionId?: string; }; type PurchaseCart = { readonly couponCode?: string; readonly lineItems?: ReadonlyArray< { readonly productPath: string; readonly quantity?: number; } >; }; type ValidState = { readonly status: | 'closed' | 'awaiting-plan-selection-url' | 'plan-selection' | 'creating-session' | 'awaiting-checkout' | 'done'; readonly sessionId: string; readonly pageHeight: number; readonly checkoutDialog: Window | null; }; type ErrorState = { readonly status: 'error'; readonly message: string; readonly hasLinkToPricingPage: boolean; readonly sessionId: string; readonly pageHeight: number; readonly checkoutDialog: Window | null; }; type State = ValidState | ErrorState; const VALID_MESSAGE_TYPES = [ 'nab/page-resize', 'nab/plan-selection-ready', 'nab/checkout-ready', 'nab/product-selected', 'nab/purchase-completed', 'nab/close-modal', 'nab/error', ] as const; type SubscribeMessage = { readonly type: ( typeof VALID_MESSAGE_TYPES )[ number ]; readonly cart?: PurchaseCart; readonly message?: string; readonly sessionId?: string; readonly pageHeight?: number; }; const CHECKOUT_URL = 'https://neliosoftware.com/testing/subscribe/checkout/'; const CLOSED_STATE: State = { status: 'closed', sessionId: '', pageHeight: 400, checkoutDialog: null, }; export const SubscribeAction = ( { className = '', children, variant, }: SubscribeActionProps ): JSX.Element | null => { const subscriptionPromo = useSubscriptionPromo(); const pageRef = useRef( null ); const isSubscribed = useIsSubscribed(); const siteId = usePluginSetting( 'siteId' ); const hasUserObjectedToProductOffers = usePluginSetting( 'hasUserObjectedToProductOffers' ); const [ state, setState ] = useSubscribeState(); useMessageListenerEffect( pageRef, state, setState ); useActivityEffect( ! isSubscribed && ! children && subscriptionPromo?.topButtonLabel ? { type: 'promo-seen', promoId: subscriptionPromo.id } : undefined ); const onError = useCallback( ( message: string, hasLinkToPricingPage: boolean ) => setState( { status: 'error', message, hasLinkToPricingPage } ), [ setState ] ); if ( isSubscribed ) { return null; } const open = () => { try { setState( { status: 'awaiting-plan-selection-url' } ); } catch ( caughtError ) { setState( { status: 'error', message: getErrorMessage( caughtError ), hasLinkToPricingPage: true, } ); } }; const close = () => setState( CLOSED_STATE ); if ( 'page-title-action' === variant ) { className = variant + ' ' + className; } const hasSelectedProductOrError = state.status === 'awaiting-checkout' || state.status === 'done' || state.status === 'error'; const isDismissible = 'awaiting-plan-selection-url' === state.status || 'plan-selection' === state.status || 'done' === state.status || 'error' === state.status; return ( { hasSelectedProductOrError ? ( ) : ( ) } ); }; // ===== // HOOKS // ===== const useSubscriptionPromo = () => useSelect( ( select ) => select( NAB_ACCOUNT ).getSubscriptionPromo(), [] ); const useSubscribeState = () => { const [ state, setFullState ] = useState< State >( CLOSED_STATE ); const setState = useCallback( ( newState: | Pick< ErrorState, 'status' | 'message' | 'hasLinkToPricingPage' > | ( Partial< ValidState > & Pick< ValidState, 'status' > ) | { readonly pageHeight: number } ) => setFullState( ( prevState ) => { if ( ! ( 'status' in newState ) ) { return { ...prevState, pageHeight: newState.pageHeight, }; } return 'error' === newState.status ? { status: newState.status, message: newState.message, hasLinkToPricingPage: newState.hasLinkToPricingPage, sessionId: prevState.sessionId, pageHeight: prevState.pageHeight, checkoutDialog: prevState.checkoutDialog, } : { status: newState.status, sessionId: newState.sessionId ?? prevState.sessionId, pageHeight: newState.pageHeight ?? prevState.pageHeight, checkoutDialog: newState.checkoutDialog ?? prevState.checkoutDialog, }; } ), [ setFullState ] ); return [ state, setState ] as const; }; const useMessageListenerEffect = ( pageRef: MutableRefObject< HTMLIFrameElement | null >, state: State, setState: ReturnType< typeof useSubscribeState >[ 1 ] ) => { const updateSession = useCallback( async ( cart: PurchaseCart ) => { try { setState( { status: 'creating-session' } ); const { sessionId } = await apiFetch< SessionResponse >( { path: '/nab/v1/subscription/purchase-session', method: 'POST', data: { cart }, } ); setState( { status: 'awaiting-checkout', sessionId } ); const dialog = openNativeBrowserDialog( addQueryArgs( CHECKOUT_URL, { session: sessionId } ) ); if ( dialog ) { setState( { status: 'awaiting-checkout', checkoutDialog: dialog, } ); } else { setState( { status: 'error', message: _x( 'Something went wrong when selecting your plan. Please try again using the following link.', 'user', 'nelio-ab-testing' ), hasLinkToPricingPage: true, } ); } } catch ( caughtError ) { setState( { status: 'error', message: getErrorMessage( caughtError ), hasLinkToPricingPage: true, } ); } }, [ setState ] ); useEffect( () => { // Listen to popup messages. const onMessage = ( event: MessageEvent ): true => { if ( event.source !== pageRef.current?.contentWindow && event.source !== state.checkoutDialog ) { return true; } if ( event.origin !== 'https://neliosoftware.com' ) { return true; } const message = getSubscribeMessage( event.data ); if ( ! message ) { return true; } switch ( message.type ) { case 'nab/plan-selection-ready': setState( { status: 'plan-selection' } ); return true; case 'nab/product-selected': if ( message.cart ) { void updateSession( message.cart ); } else { setState( { status: 'error', message: _x( 'Something went wrong when selecting your plan. Please try again using the following link.', 'user', 'nelio-ab-testing' ), hasLinkToPricingPage: true, } ); } return true; case 'nab/checkout-ready': return true; case 'nab/purchase-completed': state.checkoutDialog?.close(); setState( { status: 'done', checkoutDialog: null } ); return true; case 'nab/close-modal': setState( CLOSED_STATE ); return true; case 'nab/error': { const errorMessage = message.message || _x( 'Something went wrong. Please try again later or use the following link to get your subscription from our website.', 'user', 'nelio-ab-testing' ); setState( { status: 'error', message: errorMessage, hasLinkToPricingPage: true, } ); return true; } case 'nab/page-resize': if ( message.pageHeight && message.pageHeight !== state.pageHeight ) { setState( { pageHeight: message.pageHeight } ); } return true; } }; window.addEventListener( 'message', onMessage ); // Listen to native dialog messages. const onNativeBrowserDialogClosed = () => { if ( 'closed' === state.status ) { return; } if ( 'done' !== state.status ) { setState( { status: 'closed' } ); } }; window.addEventListener( 'nab/native-browser-dialog-closed', onNativeBrowserDialogClosed ); return () => { window.removeEventListener( 'message', onMessage ); window.removeEventListener( 'nab/native-browser-dialog-closed', onNativeBrowserDialogClosed ); }; }, [ pageRef, state, setState, updateSession ] ); }; // ======= // HELPERS // ======= const getSubscribeMessage = ( data: unknown ): SubscribeMessage | false => { if ( ! data || 'object' !== typeof data || ! ( 'type' in data ) || 'string' !== typeof data.type ) { return false; } const message = data as { readonly type: string; readonly cart?: unknown; readonly message?: unknown; readonly sessionId?: unknown; readonly pageHeight?: unknown; }; if ( ! VALID_MESSAGE_TYPES.includes( message.type as SubscribeMessage[ 'type' ] ) ) { return false; } return { type: message.type as SubscribeMessage[ 'type' ], ...( isPurchaseCart( message.cart ) && { cart: message.cart, } ), ...( 'string' === typeof message.message && { message: message.message, } ), ...( 'string' === typeof message.sessionId && { sessionId: message.sessionId, } ), ...( 'number' === typeof message.pageHeight && { pageHeight: message.pageHeight, } ), }; }; const isPurchaseCart = ( cart: unknown ): cart is PurchaseCart => { if ( ! cart || 'object' !== typeof cart ) { return false; } const candidate = cart as PurchaseCart; const { couponCode, lineItems } = candidate; if ( couponCode && 'string' !== typeof couponCode ) { return false; } return ( ! lineItems || ( Array.isArray( lineItems ) && lineItems.every( ( item: unknown ) => !! item && 'object' === typeof item && 'productPath' in item && 'string' === typeof item.productPath && !! item.productPath.trim() && 'quantity' in item && 'number' === typeof item.quantity ) ) ); }; const getErrorMessage = ( error: unknown ): string => { if ( error instanceof Error && error.message ) { return error.message; } if ( error && 'object' === typeof error && 'message' in error ) { const { message } = error as { readonly message?: unknown }; if ( 'string' === typeof message ) { return message; } } return _x( 'Unable to create a checkout session.', 'text', 'nelio-ab-testing' ); };