import React, { FC, ReactType, RefObject, useEffect, useRef, useState, } from 'react'; import Layout from '../../components/Layout'; import css from './index.module.css'; import { Button, Checkbox, LinkProps } from '../../index'; import ReactModal from 'react-modal'; import 'font-awesome/css/font-awesome.min.css'; import ArrowSVG from '../../assets/icons/icon_angle.svg'; import { checkIfAddressIsValid } from '../../helpers/addressHelper'; import { CoordsProps } from '../../components/AddressMap'; import { FormCustomerDetailsValues } from '../../components/FormCustomerDetails'; import { FormCustomerBillingDetailsValues } from '../../components/FormCustomerBillingDetails'; import { PaymentFormValues, PaymentMethodData, } from '../../components/FormPayment'; import { FormServiceDetailsValues } from '../../components/FormServiceDetails'; import classnames from 'classnames'; import { AddressModal, BookingHeader, LoginModal, PasswordResetModal, SummaryContent, } from '../../helpers/bookingFunnelHelper'; import Modal from '../../blocks/Modal'; import * as R from 'ramda'; export interface DraftServiceCall { price: number; priceDiscounted: number; priceHappinessGuarantee: number; priceHappinessGuaranteeDiscounted: number; currency: string; servicePackages: { price: number; priceDiscounted: number; name: string; }[]; vouchers: { calculatedDiscountAmount: number; code: string; }[]; } export interface BookingSubmitValues { serviceLocationData: FormCustomerDetailsValues; serviceDetailsData: FormServiceDetailsValues; serviceBillingData: FormCustomerBillingDetailsValues; paymentData: PaymentFormValues; additionalData: { position: CoordsProps; receivesNewsletter: boolean; }; } export interface BookingFunnelProps { BookingForm?: ReactType; PaymentForm?: ReactType; ServiceDetailsForm?: ReactType; servicePackage: { name: string; price: string; currencyIsoCode: string; campaign?: { event: string }; }; BillingForm?: ReactType; translations: { steps?: { step1: string; step2: string; step3: string; step4: string; }; serviceContactAndLocation: string; alreadyCustomer: string; loginHere: string; differingBillingAddress: string; invoiceAddress: string; payment: string; summary: string; dateTime: string; serviceLocation: string; voucher: string; total: string; summaryInfo: string; newsUpdates: string; accept: string; continueButton: string; bookFor: string; login: string; mapTitle: string; mapText: string; mapMarkerName: string; editAddressButton: string; useAddressButton: string; priceHappiness: string; bookError: string; close: string; resetPassword: string; }; LoginForm?: ReactType; PasswordResetForm?: ReactType; onSubmit: (data: BookingSubmitValues) => void; terms: LinkProps; LinkProps?: LinkProps; locale: string; isMilaFriend: boolean; googleApiKey?: string; addVoucher?: (code: string | string[]) => Promise; removeVoucher?: (code: string) => void; draftServiceCall?: DraftServiceCall; verifyCreditCard?: ( paymentData: PaymentFormValues, billingData: FormCustomerBillingDetailsValues, ) => Promise; deviceType?: string; isAuthenticated?: boolean; } //add viual viewport to window so that typescript doesn't throw an errror declare global { interface Window { visualViewport: EventTarget; } } export const checkIfDateIsSame = (date1, date2): boolean => { return ( date1.getFullYear() === date2.getFullYear() && date1.getMonth() === date2.getMonth() && date1.getDate() === date2.getDate() ); }; const BookingFunnelTemplate: FC = ({ BookingForm, PaymentForm, ServiceDetailsForm, servicePackage, BillingForm, translations, LoginForm, PasswordResetForm, onSubmit, terms, LinkProps, locale, isMilaFriend, googleApiKey, addVoucher, removeVoucher, draftServiceCall, verifyCreditCard, deviceType, isAuthenticated, }) => { // Reverse order for scrolling const isUk = locale.slice(3, 5).toLowerCase() === 'gb'; const steps = { step4: false, step3: false, step2: false }; const forms = { serviceDetails: true, customerDetails: true, billingDetails: true, paymentDetails: true, terms: true, }; const [getSteps, setSteps] = useState(steps); const [isDisabled, setDisabled] = useState(false); const [isLoading, setLoading] = useState(false); const [isBillingAddress, setBillingAddress] = useState(false); const [validForms, setValidForms] = useState(forms); const [position, setPosition] = useState(null); const [receivesNewsletter, setReceivesNewsletter] = useState(false); const [hideStickyFooter, setStickyFooter] = useState(false); const [isKeyboardOpenFallback, setKeyboardOpenFallback] = useState(false); const [submitError, setSubmitError] = useState(null); const isMobileOrTablet = deviceType === 'mobile' || deviceType === 'tablet'; const campaign = servicePackage.campaign && servicePackage.campaign.event; const [voucherCodes, setVoucherCodes] = useState([]); const [serviceBillingData, setServiceBillingData] = useState< FormCustomerBillingDetailsValues >(Object({})); const [serviceLocationData, setServiceLocationData] = useState< FormCustomerDetailsValues >(Object({})); const [paymentData, setPaymentData] = useState(); const [serviceDetailsData, setServiceDetailsData] = useState< FormServiceDetailsValues >({ date: null, description: null, isFlexibleCheckbox: false, voucher: '', }); const [initialValues, setInitialValues] = useState({ serviceBillingData: null, serviceLocationData: null, serviceDetailsData: null, voucherCodes: [], paymentMethods: null, paymentData: { paymentMethod: 'new', } as unknown, }); const [paymentVerified, setPaymentVerified] = useState(false); const [errors, setErrors] = useState({ paymentForm: {}, }); const [stepTitle, setStepTitle] = useState(translations.steps.step1); const stepRefs: { [key: string]: RefObject } = {}; stepRefs['refStep2'] = useRef(null); stepRefs['refStep3'] = useRef(null); stepRefs['refStep4'] = useRef(null); useEffect(() => { for (const key of Object.keys(getSteps)) { if (getSteps[key] == true) { const item = stepRefs[`${'ref' + key.charAt(0).toUpperCase() + key.slice(1)}`] .current; // Substract header height in offfset window.scrollTo({ top: item.offsetTop - 60, behavior: 'smooth', }); break; } } }, [getSteps]); const isValid = (valid): void => { setDisabled(!valid); }; useEffect(() => { isValid(Object.values(validForms).every(Boolean)); }, [validForms]); const setLocationData = (values: FormCustomerDetailsValues): void => { setServiceLocationData(values); if (!isBillingAddress) setServiceBillingData(values); }; const setBillingData = (values: FormCustomerBillingDetailsValues): void => { setServiceBillingData(values); }; const setPaymentFormData = (values: PaymentFormValues): void => { setPaymentData(values); }; const setServiceDetailsFormData = ( values: FormServiceDetailsValues, ): void => { setServiceDetailsData(values); }; const saveBookingData = (): void => { const data = { serviceLocationData, serviceBillingData, serviceDetailsData, position, steps: getSteps, isBillingAddress, voucherCodes, }; localStorage.setItem('bookingData', JSON.stringify(data)); }; const loadBookingData = async (): Promise => { const data = localStorage.getItem('bookingData'); const urlParams = new URLSearchParams(window.location.search); const urlErrorKey = urlParams.get('errorKey') === 'Failed3DSecure'; const urlPaymentMethods = urlParams.get('paymentMethods'); if (data && (urlPaymentMethods || urlErrorKey)) { const x = JSON.parse(data); x.serviceLocationData && setServiceLocationData(x.serviceLocationData); x.serviceBillingData && setServiceBillingData(x.serviceBillingData); x.serviceDetailsData = x.serviceDetailsData && { ...x.serviceDetailsData, date: x.serviceDetailsData.date && new Date(x.serviceDetailsData.date), }; setServiceDetailsData(x.serviceDetailsData); x.position && setPosition(x.position); x.steps && setSteps(x.steps); setBillingAddress(!!x.isBillingAddress); let paymentMethods = []; if (urlPaymentMethods) { paymentMethods = JSON.parse(atob(urlPaymentMethods)); paymentMethods = Array.isArray(paymentMethods) ? paymentMethods : [paymentMethods]; } if (urlErrorKey) { setErrors({ ...errors, paymentForm: { creditCardValidation: true, }, }); } setInitialValues({ serviceLocationData: x.serviceLocationData, serviceBillingData: x.serviceBillingData, serviceDetailsData: x.serviceDetailsData, voucherCodes: x.voucherCodes || [], paymentMethods: paymentMethods, paymentData: { paymentMethod: !urlErrorKey && paymentMethods.length > 0 ? paymentMethods[0].id : 'new', }, }); if (x.voucherCodes) { setVoucherCodes(x.voucherCodes); await addVoucher(x.voucherCodes); } const regex = new RegExp(`([?|&](paymentMethods|errorKey)=[^\&]+)`, 'i'); window.history.replaceState(null, null, location.href.replace(regex, '')); localStorage.removeItem('bookingData'); } }; const [addressModalProps, setAddressModalProps] = useState<{ isOpen: boolean; address: FormCustomerDetailsValues | FormCustomerBillingDetailsValues; isValidAddressCallback: Function; }>({ isOpen: false, address: null, isValidAddressCallback: Function(), }); const isValidGoogleAddress = useRef(true); const isValidBillingAddress = useRef(true); const openAddressValidationModal = (address, validRef): void => { setAddressModalProps({ isOpen: true, address: address, isValidAddressCallback: () => { validRef.current = true; }, }); }; const closeAddressValidationModal = (): void => { setAddressModalProps({ isOpen: false, address: null, isValidAddressCallback: Function(), }); }; const continueEvent = async ( options: { paymentVerified?: boolean } = {}, ): Promise => { const updatedSteps = Object.assign({}, getSteps); const updateForms = Object.assign({}, validForms); if (isDisabled) return; if (!updatedSteps.step2) { updatedSteps.step2 = true; setDisabled(true); setSteps(updatedSteps); setStepTitle(translations.steps.step2); updateForms.customerDetails = false; setValidForms(updateForms); return; } if (updatedSteps.step2 && !isValidGoogleAddress.current) { await checkIfAddressIsValid( continueEvent, serviceLocationData, isValidGoogleAddress, openAddressValidationModal, setPosition, ); return; } if ( updatedSteps.step2 && !isValidBillingAddress.current && isBillingAddress ) { await checkIfAddressIsValid( continueEvent, serviceBillingData, isValidBillingAddress, openAddressValidationModal, setPosition, ); return; } if ( updatedSteps.step2 && !updatedSteps.step3 && isValidGoogleAddress.current ) { updatedSteps.step3 = true; setSteps(updatedSteps); setStepTitle(translations.steps.step3); return; } if (updatedSteps.step3 && !paymentVerified && !options.paymentVerified) { // eslint-disable-next-line @typescript-eslint/no-use-before-define verifyPayment(); } if ( updatedSteps.step3 && (paymentVerified || options.paymentVerified) && !updatedSteps.step4 ) { updatedSteps.step4 = true; updateForms.terms = false; setValidForms(updateForms); setSteps(updatedSteps); setStepTitle(translations.steps.step4); return; } }; const verifyPayment = async (): Promise => { setLoading(true); setDisabled(true); if (paymentData.paymentOption === 'Invoice') { setPaymentVerified(true); setLoading(false); setDisabled(false); continueEvent({ paymentVerified: true }); } else if (paymentData.paymentOption === 'Card') { setErrors({ ...errors, paymentForm: {}, }); try { if (paymentData.paymentMethod === 'new') { saveBookingData(); const result = await verifyCreditCard( paymentData, serviceBillingData, ); setLoading(false); setDisabled(false); if (!result) { // it's a redirect, don't do anything } else { setPaymentVerified(true); const newPaymentMethods = R.uniqBy(d => d.id, [ result, ...(initialValues.paymentMethods || []), ]); setInitialValues({ ...initialValues, paymentMethods: newPaymentMethods, paymentData: { paymentMethod: result.id, creditCardNumber: '', creditCardHolder: '', expirationDate: '', cvc: '', }, }); continueEvent({ paymentVerified: true }); } } else { setLoading(false); setDisabled(false); setPaymentVerified(true); continueEvent({ paymentVerified: true }); } } catch (e) { setLoading(false); setDisabled(false); setErrors({ ...errors, paymentForm: { creditCardValidation: true, }, }); } } }; const [loginModalIsOpen, setLoginModalIsOpen] = useState(false); const [passwordResetModalIsOpen, setPasswordResetModalIsOpen] = useState( false, ); const openLoginModal = (): void => { setLoginModalIsOpen(true); }; const closeLoginModal = (): void => { setLoginModalIsOpen(false); }; const openPasswordResetModal = (): void => { setPasswordResetModalIsOpen(true); }; const closePasswordResetModal = (): void => { setPasswordResetModalIsOpen(false); }; const submitServiceCall = async (): Promise => { if (!isValidGoogleAddress.current) { await checkIfAddressIsValid( submitServiceCall, serviceLocationData, isValidGoogleAddress, openAddressValidationModal, setPosition, ); return; } if (!isValidBillingAddress.current && isBillingAddress) { await checkIfAddressIsValid( submitServiceCall, serviceBillingData, isValidBillingAddress, openAddressValidationModal, setPosition, ); return; } setSubmitError(null); setDisabled(true); setLoading(true); try { await onSubmit({ serviceLocationData, serviceDetailsData, serviceBillingData, paymentData, additionalData: { position, receivesNewsletter, }, }); } catch (e) { console.error(e); setSubmitError(e.message); setLoading(false); setDisabled(false); } }; // Check if virtual keyboard is open const [resizeHeight, setResizeHeight] = useState(null); const keyboardResizeEvent = (event): void => { const ua = navigator.userAgent.toLowerCase(); const isAndroid = ua.indexOf('android') > -1; if (isAndroid) { if (window.screen.height - event.target.height > 250) { setStickyFooter(true); } else { setStickyFooter(false); } } else { setResizeHeight(event.target.height); } }; useEffect(() => { if (resizeHeight != null && resizeHeight !== window.innerHeight) { setStickyFooter(true); } else { setStickyFooter(false); } }, [resizeHeight]); useEffect(() => { if (window.visualViewport && isMobileOrTablet) { window.visualViewport.addEventListener('resize', keyboardResizeEvent); return (): void => window.visualViewport.removeEventListener( 'resize', keyboardResizeEvent, ); } else { setKeyboardOpenFallback(true); } loadBookingData(); }, []); return (
{ const updatedForms = Object.assign({}, validForms); updatedForms.serviceDetails = bool; setValidForms(updatedForms); }} getFormData={setServiceDetailsFormData} addVoucher={async (code: string): Promise => { try { const result = await addVoucher(code); setVoucherCodes([...voucherCodes, code]); return result; } catch (e) { throw e; } }} removeVoucher={async (code: string): Promise => { const newVouchers = voucherCodes.filter(c => c !== code); setVoucherCodes(newVouchers); return removeVoucher(code); }} checkIfKeyBoardOpen={(bool): void => { if (isMobileOrTablet && isKeyboardOpenFallback) setStickyFooter(bool); }} initialValues={initialValues.serviceDetailsData} initialVouchers={initialValues.voucherCodes} campaign={campaign} /> {getSteps.step2 && (
{translations.serviceContactAndLocation} {!isAuthenticated && (
{translations.alreadyCustomer}{' '} {' '} {translations.loginHere}{' '}
)} { closeLoginModal(); openPasswordResetModal(); }} /> { closePasswordResetModal(); openLoginModal(); }} closePasswordResetModal={closePasswordResetModal} />
{BookingForm && ( { isValidGoogleAddress.current = false; const updatedForms = Object.assign({}, validForms); updatedForms.customerDetails = bool; setValidForms(updatedForms); }} getFormData={setLocationData} initialValues={initialValues.serviceLocationData} checkIfKeyBoardOpen={(bool): void => { if (isMobileOrTablet && isKeyboardOpenFallback) setStickyFooter(bool); }} /> )}
{ setBillingAddress(e.target.checked); const updatedForms = Object.assign({}, validForms); updatedForms.billingDetails = !e.target.checked; setValidForms(updatedForms); if (!e.target.checked) setBillingData(Object.assign({}, serviceLocationData)); }} value={isBillingAddress} name="isBillingAddress" label={translations.differingBillingAddress} />
)} {isBillingAddress && (
{translations.invoiceAddress}
{BillingForm && ( { isValidBillingAddress.current = false; const updatedForms = Object.assign({}, validForms); updatedForms.billingDetails = bool; setValidForms(updatedForms); }} getFormData={setBillingData} initialValues={initialValues.serviceBillingData} checkIfKeyBoardOpen={(bool): void => { if (isMobileOrTablet && isKeyboardOpenFallback) setStickyFooter(bool); }} /> )}
)} {getSteps.step3 && (
{translations.payment}
{PaymentForm && ( { const updatedForms = Object.assign({}, validForms); updatedForms.paymentDetails = bool; updatedForms.terms = true; setValidForms(updatedForms); const updatedSteps = Object.assign({}, getSteps); if (updatedSteps.step4) { updatedSteps.step4 = false; setSteps(updatedSteps); setStepTitle(translations.steps.step3); } setPaymentVerified(false); }} getFormData={setPaymentFormData} invoiceAddress={serviceBillingData} paymentMethods={initialValues.paymentMethods} initialValues={initialValues.paymentData} checkIfKeyBoardOpen={(bool): void => { if (isMobileOrTablet && isKeyboardOpenFallback) setStickyFooter(bool); }} inputErrors={errors.paymentForm} /> )}
)} {getSteps.step4 && draftServiceCall && ( <>
{ setReceivesNewsletter(e.target.checked); }} /> { const updateForms = Object.assign({}, validForms); updateForms.terms = e.target.checked; setValidForms(updateForms); }} link={terms} />
)}
{!hideStickyFooter && (
{!getSteps.step4 ? ( <>
{servicePackage.name}
{draftServiceCall ? ( {draftServiceCall?.priceDiscounted.toFixed(2)}  {draftServiceCall?.currency} {isMilaFriend ? '*' : ''} ) : ( <> )}
) : (
{ setSubmitError(null); }} actions={ } > {submitError}
)}
)}
); }; export default BookingFunnelTemplate;