From 70cb6c6ea58da8d82f2f28b00910cb5c32a69214 Mon Sep 17 00:00:00 2001 From: David Chin Date: Fri, 9 Aug 2019 09:30:31 +1000 Subject: [PATCH] feat(customer): CHECKOUT-4223 Add customer step component --- src/app/common/request/responses.mock.ts | 13 + src/app/customer/CheckoutButton.spec.tsx | 51 +++ src/app/customer/CheckoutButton.tsx | 46 +++ src/app/customer/CheckoutButtonList.spec.tsx | 108 +++++ src/app/customer/CheckoutButtonList.tsx | 72 ++++ src/app/customer/Customer.spec.tsx | 375 ++++++++++++++++++ src/app/customer/Customer.tsx | 229 +++++++++++ src/app/customer/NewsletterService.spec.ts | 54 +++ src/app/customer/NewsletterService.ts | 21 + .../CheckoutButtonList.spec.tsx.snap | 19 + .../__snapshots__/Customer.spec.tsx.snap | 219 ++++++++++ src/app/customer/index.ts | 18 + 12 files changed, 1225 insertions(+) create mode 100644 src/app/common/request/responses.mock.ts create mode 100644 src/app/customer/CheckoutButton.spec.tsx create mode 100644 src/app/customer/CheckoutButton.tsx create mode 100644 src/app/customer/CheckoutButtonList.spec.tsx create mode 100644 src/app/customer/CheckoutButtonList.tsx create mode 100644 src/app/customer/Customer.spec.tsx create mode 100644 src/app/customer/Customer.tsx create mode 100644 src/app/customer/NewsletterService.spec.ts create mode 100644 src/app/customer/NewsletterService.ts create mode 100644 src/app/customer/__snapshots__/CheckoutButtonList.spec.tsx.snap create mode 100644 src/app/customer/__snapshots__/Customer.spec.tsx.snap create mode 100644 src/app/customer/index.ts diff --git a/src/app/common/request/responses.mock.ts b/src/app/common/request/responses.mock.ts new file mode 100644 index 0000000000..709ed7a17e --- /dev/null +++ b/src/app/common/request/responses.mock.ts @@ -0,0 +1,13 @@ +import { Response } from '@bigcommerce/request-sender'; + +export function getResponse(body: T, headers = {}, status = 200, statusText = 'OK'): Response { + return { + body, + status, + statusText, + headers: { + 'content-type': 'application/json', + ...headers, + }, + }; +} diff --git a/src/app/customer/CheckoutButton.spec.tsx b/src/app/customer/CheckoutButton.spec.tsx new file mode 100644 index 0000000000..ef78dc641a --- /dev/null +++ b/src/app/customer/CheckoutButton.spec.tsx @@ -0,0 +1,51 @@ +import { mount } from 'enzyme'; +import { noop } from 'lodash'; +import React from 'react'; + +import CheckoutButton from './CheckoutButton'; + +describe('CheckoutButton', () => { + it('initializes button when component is mounted', () => { + const initialize = jest.fn(); + const onError = jest.fn(); + + mount( + + ); + + expect(initialize) + .toHaveBeenCalledWith({ + methodId: 'foobar', + foobar: { + container: 'foobarContainer', + onError, + }, + }); + }); + + it('deinitializes button when component unmounts', () => { + const deinitialize = jest.fn(); + const onError = jest.fn(); + + const component = mount( + + ); + + component.unmount(); + + expect(deinitialize) + .toHaveBeenCalled(); + }); +}); diff --git a/src/app/customer/CheckoutButton.tsx b/src/app/customer/CheckoutButton.tsx new file mode 100644 index 0000000000..22a9657522 --- /dev/null +++ b/src/app/customer/CheckoutButton.tsx @@ -0,0 +1,46 @@ +import { CustomerInitializeOptions, CustomerRequestOptions } from '@bigcommerce/checkout-sdk'; +import React, { Component } from 'react'; + +export interface CheckoutButtonProps { + containerId: string; + methodId: string; + deinitialize(options: CustomerRequestOptions): void; + initialize(options: CustomerInitializeOptions): void; + onError?(error: Error): void; +} + +export default class CheckoutButton extends Component { + componentDidMount() { + const { + containerId, + initialize, + methodId, + onError, + } = this.props; + + initialize({ + methodId, + [methodId]: { + container: containerId, + onError, + }, + }); + } + + componentWillUnmount() { + const { + deinitialize, + methodId, + } = this.props; + + deinitialize({ methodId }); + } + + render() { + const { containerId } = this.props; + + return ( +
+ ); + } +} diff --git a/src/app/customer/CheckoutButtonList.spec.tsx b/src/app/customer/CheckoutButtonList.spec.tsx new file mode 100644 index 0000000000..ed9bd95f1e --- /dev/null +++ b/src/app/customer/CheckoutButtonList.spec.tsx @@ -0,0 +1,108 @@ +import { mount, render } from 'enzyme'; +import { noop } from 'lodash'; +import React from 'react'; + +import { getStoreConfig } from '../config/config.mock'; +import { createLocaleContext, LocaleContext, LocaleContextType } from '../locale'; + +import CheckoutButton from './CheckoutButton'; +import CheckoutButtonList from './CheckoutButtonList'; + +describe('CheckoutButtonList', () => { + let localeContext: LocaleContextType; + + beforeEach(() => { + localeContext = createLocaleContext(getStoreConfig()); + }); + + it('matches snapshot', () => { + const component = render( + + + + ); + + expect(component) + .toMatchSnapshot(); + }); + + it('filters out unsupported methods', () => { + const component = mount( + + + + ); + + expect(component.find(CheckoutButton)) + .toHaveLength(2); + }); + + it('does not render if there are no supported methods', () => { + const component = mount( + + + + ); + + expect(component.html()) + .toEqual(null); + }); + + it('passes data to every checkout button', () => { + const deinitialize = jest.fn(); + const initialize = jest.fn(); + const component = mount( + + + + ); + + expect(component.find(CheckoutButton).at(0).props()) + .toEqual({ + containerId: 'amazonCheckoutButton', + methodId: 'amazon', + deinitialize, + initialize, + }); + }); + + it('notifies parent if methods are incompatible with Embedded Checkout', () => { + const methodIds = ['amazon', 'braintreevisacheckout']; + const onError = jest.fn(); + const checkEmbeddedSupport = jest.fn(() => { throw new Error(); }); + + render( + + + + ); + + expect(checkEmbeddedSupport) + .toHaveBeenCalledWith(methodIds); + + expect(onError) + .toHaveBeenCalledWith(expect.any(Error)); + }); +}); diff --git a/src/app/customer/CheckoutButtonList.tsx b/src/app/customer/CheckoutButtonList.tsx new file mode 100644 index 0000000000..cd784395d1 --- /dev/null +++ b/src/app/customer/CheckoutButtonList.tsx @@ -0,0 +1,72 @@ +import { CustomerInitializeOptions, CustomerRequestOptions } from '@bigcommerce/checkout-sdk'; +import React, { Fragment, FunctionComponent } from 'react'; + +import { TranslatedString } from '../language'; + +import CheckoutButton from './CheckoutButton'; + +// TODO: The API should tell UI which payment method offers its own checkout button +export const SUPPORTED_METHODS: string[] = [ + 'amazon', + 'braintreevisacheckout', + 'chasepay', + 'masterpass', + 'googlepaybraintree', + 'googlepaystripe', +]; + +export interface CheckoutButtonListProps { + methodIds: string[]; + checkEmbeddedSupport?(methodIds: string[]): void; + deinitialize(options: CustomerRequestOptions): void; + initialize(options: CustomerInitializeOptions): void; + onError?(error: Error): void; +} + +const CheckoutButtonList: FunctionComponent = ({ + checkEmbeddedSupport, + onError, + methodIds, + ...rest +}) => { + const supportedMethodIds = methodIds + .filter(methodId => SUPPORTED_METHODS.indexOf(methodId) !== -1); + + if (supportedMethodIds.length === 0) { + return null; + } + + if (checkEmbeddedSupport) { + try { + checkEmbeddedSupport(supportedMethodIds); + } catch (error) { + if (onError) { + onError(error); + } else { + throw error; + } + + return null; + } + } + + return ( + +

+ +
+ { supportedMethodIds.map(methodId => + + ) } +
+
+ ); +}; + +export default CheckoutButtonList; diff --git a/src/app/customer/Customer.spec.tsx b/src/app/customer/Customer.spec.tsx new file mode 100644 index 0000000000..2a5b6b2b07 --- /dev/null +++ b/src/app/customer/Customer.spec.tsx @@ -0,0 +1,375 @@ +import { createCheckoutService, BillingAddress, Checkout, CheckoutService, Customer as CustomerData, StoreConfig } from '@bigcommerce/checkout-sdk'; +import { mount, render, ReactWrapper } from 'enzyme'; +import React, { FunctionComponent } from 'react'; + +import { getBillingAddress } from '../billing/billingAddresses.mock'; +import { CheckoutProvider } from '../checkout'; +import { getCheckout } from '../checkout/checkouts.mock'; +import { getStoreConfig } from '../config/config.mock'; +import { createLocaleContext, LocaleContext, LocaleContextType } from '../locale'; + +import { getGuestCustomer } from './customers.mock'; +import Customer, { CustomerProps, CustomerViewType } from './Customer'; +import GuestForm, { GuestFormProps } from './GuestForm'; +import LoginForm, { LoginFormProps } from './LoginForm'; + +describe('Customer', () => { + let CustomerTest: FunctionComponent; + let billingAddress: BillingAddress; + let checkout: Checkout; + let checkoutService: CheckoutService; + let config: StoreConfig; + let customer: CustomerData; + let localeContext: LocaleContextType; + + beforeEach(() => { + billingAddress = getBillingAddress(); + checkout = getCheckout(); + customer = getGuestCustomer(); + config = getStoreConfig(); + + checkoutService = createCheckoutService(); + + jest.spyOn(checkoutService.getState().data, 'getBillingAddress') + .mockReturnValue(billingAddress); + + jest.spyOn(checkoutService.getState().data, 'getCheckout') + .mockReturnValue(checkout); + + jest.spyOn(checkoutService.getState().data, 'getCustomer') + .mockReturnValue(customer); + + jest.spyOn(checkoutService.getState().data, 'getConfig') + .mockReturnValue(config); + + localeContext = createLocaleContext(getStoreConfig()); + + CustomerTest = props => ( + + + + + + ); + }); + + describe('when view type is "guest"', () => { + it('matches snapshot', () => { + expect(render( + + )) + .toMatchSnapshot(); + }); + + it('renders guest form by default', () => { + const component = mount( + + ); + + expect(component.find(GuestForm).exists()) + .toEqual(true); + }); + + it('passes data to guest form', () => { + const component = mount( + + ); + + expect(component.find(GuestForm).props()) + .toMatchObject({ + canSubscribe: config.shopperConfig.showNewsletterSignup, + defaultShouldSubscribe: config.shopperConfig.defaultNewsletterSignup, + email: billingAddress.email, + }); + }); + + it('continues checkout as guest and subscribes to newsletter when "continue as guest" event is received', () => { + jest.spyOn(checkoutService, 'continueAsGuest') + .mockReturnValue(Promise.resolve(checkoutService.getState())); + + const subscribeToNewsletter = jest.fn(); + const component = mount( + + ); + + (component.find(GuestForm) as ReactWrapper) + .prop('onContinueAsGuest')({ + email: 'test@bigcommerce.com', + shouldSubscribe: true, + }); + + expect(checkoutService.continueAsGuest) + .toHaveBeenCalledWith({ email: 'test@bigcommerce.com' }); + + expect(subscribeToNewsletter) + .toHaveBeenCalledWith({ + email: 'test@bigcommerce.com', + firstName: expect.any(String), + }); + }); + + it('only subscribes to newsletter if allowed by customer', () => { + jest.spyOn(checkoutService, 'continueAsGuest') + .mockReturnValue(Promise.resolve(checkoutService.getState())); + + const subscribeToNewsletter = jest.fn(); + const component = mount( + + ); + + (component.find(GuestForm) as ReactWrapper) + .prop('onContinueAsGuest')({ + email: 'test@bigcommerce.com', + shouldSubscribe: false, + }); + + expect(checkoutService.continueAsGuest) + .toHaveBeenCalledWith({ email: 'test@bigcommerce.com' }); + + expect(subscribeToNewsletter) + .not.toHaveBeenCalled(); + }); + + it('changes to login view when "show login" event is received', () => { + const handleChangeViewType = jest.fn(); + const component = mount( + + ); + + (component.find(GuestForm) as ReactWrapper) + .prop('onShowLogin')(); + + expect(handleChangeViewType) + .toHaveBeenCalledWith(CustomerViewType.Login); + }); + + it('triggers completion callback if customer successfully continued as guest', async () => { + jest.spyOn(checkoutService, 'continueAsGuest') + .mockReturnValue(Promise.resolve(checkoutService.getState())); + + const handleContinueAsGuest = jest.fn(); + const component = mount( + + ); + + (component.find(GuestForm) as ReactWrapper) + .prop('onContinueAsGuest')({ + email: 'test@bigcommerce.com', + shouldSubscribe: false, + }); + + await new Promise(resolve => process.nextTick(resolve)); + + expect(handleContinueAsGuest) + .toHaveBeenCalled(); + }); + + it('triggers error callback if customer is unable to continue as guest', async () => { + jest.spyOn(checkoutService, 'continueAsGuest') + .mockRejectedValue({ type: 'unknown_error' }); + + const handleError = jest.fn(); + const component = mount( + + ); + + (component.find(GuestForm) as ReactWrapper) + .prop('onContinueAsGuest')({ + email: 'test@bigcommerce.com', + shouldSubscribe: false, + }); + + await new Promise(resolve => process.nextTick(resolve)); + + expect(handleError) + .toHaveBeenCalled(); + }); + + it('retains draft email address when switching to login view', () => { + const component = mount( + + ); + + (component.find(GuestForm) as ReactWrapper) + .prop('onChangeEmail')('test@bigcommerce.com'); + + component.setProps({ viewType: CustomerViewType.Login }); + component.update(); + + expect((component.find(LoginForm) as ReactWrapper).prop('email')) + .toEqual('test@bigcommerce.com'); + }); + }); + + describe('when view type is "login"', () => { + it('matches snapshot', () => { + const component = mount( + + ); + + expect(component.render()) + .toMatchSnapshot(); + }); + + it('renders login form', () => { + const component = mount( + + ); + + expect(component.find(LoginForm).exists()) + .toEqual(true); + }); + + it('passes data to login form', () => { + const component = mount( + + ); + + expect(component.find(LoginForm).props()) + .toMatchObject({ + email: billingAddress.email, + canCancel: config.checkoutSettings.guestCheckoutEnabled, + createAccountUrl: config.links.createAccountLink, + forgotPasswordUrl: config.links.forgotPasswordLink, + }); + }); + + it('handles "sign in" event', () => { + jest.spyOn(checkoutService, 'signInCustomer') + .mockReturnValue(Promise.resolve(checkoutService.getState())); + + const component = mount( + + ); + + (component.find(LoginForm) as ReactWrapper) + .prop('onSignIn')({ + email: 'test@bigcommerce.com', + password: 'password1', + }); + + expect(checkoutService.signInCustomer) + .toHaveBeenCalledWith({ + email: 'test@bigcommerce.com', + password: 'password1', + }); + }); + + it('triggers completion callback if customer is successfully signed in', async () => { + jest.spyOn(checkoutService, 'signInCustomer') + .mockReturnValue(Promise.resolve(checkoutService.getState())); + + const handleSignedIn = jest.fn(); + const component = mount( + + ); + + (component.find(LoginForm) as ReactWrapper) + .prop('onSignIn')({ + email: 'test@bigcommerce.com', + password: 'password1', + }); + + await new Promise(resolve => process.nextTick(resolve)); + + expect(handleSignedIn) + .toHaveBeenCalled(); + }); + + it('triggers error callback if customer is unable to sign in', async () => { + jest.spyOn(checkoutService, 'signInCustomer') + .mockRejectedValue({ type: 'unknown_error' }); + + const handleError = jest.fn(); + const component = mount( + + ); + + (component.find(LoginForm) as ReactWrapper) + .prop('onSignIn')({ + email: 'test@bigcommerce.com', + password: 'password1', + }); + + await new Promise(resolve => process.nextTick(resolve)); + + expect(handleError) + .toHaveBeenCalled(); + }); + + it('clears error when "cancel" event is triggered', () => { + const error = new Error(); + + jest.spyOn(checkoutService.getState().errors, 'getSignInError') + .mockReturnValue(error); + + jest.spyOn(checkoutService, 'clearError') + .mockReturnValue(Promise.resolve(checkoutService.getState())); + + const component = mount( + + ); + + (component.find(LoginForm) as ReactWrapper) + // tslint:disable-next-line:no-non-null-assertion + .prop('onCancel')!(); + + expect(checkoutService.clearError) + .toHaveBeenCalledWith(error); + }); + + it('changes to guest view when "cancel" event is triggered', () => { + const handleChangeViewType = jest.fn(); + const component = mount( + + ); + + (component.find(LoginForm) as ReactWrapper) + // tslint:disable-next-line:no-non-null-assertion + .prop('onCancel')!(); + + expect(handleChangeViewType) + .toHaveBeenCalledWith(CustomerViewType.Guest); + }); + + it('retains draft email address when switching to guest view', () => { + const component = mount( + + ); + + (component.find(LoginForm) as ReactWrapper) + // tslint:disable-next-line:no-non-null-assertion + .prop('onChangeEmail')!('test@bigcommerce.com'); + + component.setProps({ viewType: CustomerViewType.Guest }); + component.update(); + + expect((component.find(GuestForm) as ReactWrapper).prop('email')) + .toEqual('test@bigcommerce.com'); + }); + }); +}); diff --git a/src/app/customer/Customer.tsx b/src/app/customer/Customer.tsx new file mode 100644 index 0000000000..6c8e60d0a8 --- /dev/null +++ b/src/app/customer/Customer.tsx @@ -0,0 +1,229 @@ +import { CheckoutSelectors, CustomerCredentials, CustomerInitializeOptions, CustomerRequestOptions, GuestCredentials } from '@bigcommerce/checkout-sdk'; +import { noop } from 'lodash'; +import React, { Component, Fragment, ReactNode } from 'react'; + +import { withCheckout, CheckoutContextProps } from '../checkout'; + +import CheckoutButtonList from './CheckoutButtonList'; +import GuestForm, { GuestFormValues } from './GuestForm'; +import LoginForm from './LoginForm'; + +export interface CustomerProps { + viewType: CustomerViewType; + checkEmbeddedSupport?(methodIds: string[]): void; + onChangeViewType?(viewType: CustomerViewType): void; + onContinueAsGuest?(): void; + onContinueAsGuestError?(error: Error): void; + onReady?(): void; + onSignIn?(): void; + onSignInError?(error: Error): void; + onUnhandledError?(error: Error): void; + subscribeToNewsletter?(data: { email: string; firstName?: string }): void; +} + +export enum CustomerViewType { + Guest = 'guest', + Login = 'login', +} + +interface WithCheckoutCustomerProps { + canSubscribe: boolean; + checkoutButtonIds: string[]; + createAccountUrl: string; + defaultShouldSubscribe: boolean; + email?: string; + firstName?: string; + forgotPasswordUrl: string; + isContinuingAsGuest: boolean; + isGuestEnabled: boolean; + isSigningIn: boolean; + signInError?: Error; + clearError(error: Error): Promise; + continueAsGuest(credentials: GuestCredentials): Promise; + deinitializeCustomer(options: CustomerRequestOptions): Promise; + initializeCustomer(options: CustomerInitializeOptions): Promise; + signIn(credentials: CustomerCredentials): Promise; +} + +class Customer extends Component { + private draftEmail?: string; + + componentDidMount(): void { + const { onReady = noop } = this.props; + + onReady(); + } + + render(): ReactNode { + const { viewType } = this.props; + + return ( + + { viewType === CustomerViewType.Login && this.renderLoginForm() } + { viewType === CustomerViewType.Guest && this.renderGuestForm() } + + ); + } + + private renderGuestForm(): ReactNode { + const { + canSubscribe, + checkEmbeddedSupport, + checkoutButtonIds, + defaultShouldSubscribe, + deinitializeCustomer, + email, + initializeCustomer, + isContinuingAsGuest = false, + onChangeViewType = noop, + onUnhandledError = noop, + } = this.props; + + return ( + + } + email={ this.draftEmail || email } + defaultShouldSubscribe={ defaultShouldSubscribe } + isContinuingAsGuest={ isContinuingAsGuest } + onChangeEmail={ this.handleChangeEmail } + onContinueAsGuest={ this.handleContinueAsGuest } + onShowLogin={ () => { + onChangeViewType(CustomerViewType.Login); + } } + /> + ); + } + + private renderLoginForm(): ReactNode { + const { + createAccountUrl, + email, + forgotPasswordUrl, + isGuestEnabled, + isSigningIn, + signInError, + } = this.props; + + return ( + + ); + } + + private handleContinueAsGuest: (formValues: GuestFormValues) => Promise = async formValues => { + const { + canSubscribe, + continueAsGuest, + firstName, + onContinueAsGuest = noop, + onContinueAsGuestError = noop, + subscribeToNewsletter = noop, + } = this.props; + + if (canSubscribe && formValues.shouldSubscribe) { + subscribeToNewsletter({ email: formValues.email, firstName }); + } + + try { + await continueAsGuest({ email: formValues.email }); + onContinueAsGuest(); + + this.draftEmail = undefined; + } catch (error) { + onContinueAsGuestError(error); + } + }; + + private handleSignIn: (credentials: CustomerCredentials) => Promise = async credentials => { + const { + signIn, + onSignIn = noop, + onSignInError = noop, + } = this.props; + + try { + await signIn(credentials); + onSignIn(); + + this.draftEmail = undefined; + } catch (error) { + onSignInError(error); + } + }; + + private handleCancelSignIn: () => void = () => { + const { + clearError, + onChangeViewType = noop, + signInError, + } = this.props; + + if (signInError) { + clearError(signInError); + } + + onChangeViewType(CustomerViewType.Guest); + }; + + private handleChangeEmail: (email: string) => void = email => { + this.draftEmail = email; + }; +} + +export function mapToWithCheckoutCustomerProps( + { checkoutService, checkoutState }: CheckoutContextProps +): WithCheckoutCustomerProps | null { + const { + data: { getBillingAddress, getCheckout, getCustomer, getConfig }, + errors: { getSignInError }, + statuses: { isContinuingAsGuest, isSigningIn }, + } = checkoutState; + + const billingAddress = getBillingAddress(); + const checkout = getCheckout(); + const customer = getCustomer(); + const config = getConfig(); + + if (!billingAddress || !checkout || !customer || !config) { + return null; + } + + return { + canSubscribe: config.shopperConfig.showNewsletterSignup, + checkoutButtonIds: config.checkoutSettings.remoteCheckoutProviders, + clearError: checkoutService.clearError, + continueAsGuest: checkoutService.continueAsGuest, + createAccountUrl: config.links.createAccountLink, + defaultShouldSubscribe: config.shopperConfig.defaultNewsletterSignup, + deinitializeCustomer: checkoutService.deinitializeCustomer, + email: billingAddress.email || customer.email, + firstName: customer.firstName, + forgotPasswordUrl: config.links.forgotPasswordLink, + initializeCustomer: checkoutService.initializeCustomer, + isContinuingAsGuest: isContinuingAsGuest(), + isGuestEnabled: config.checkoutSettings.guestCheckoutEnabled, + isSigningIn: isSigningIn(), + signIn: checkoutService.signInCustomer, + signInError: getSignInError(), + }; +} + +export default withCheckout(mapToWithCheckoutCustomerProps)(Customer); diff --git a/src/app/customer/NewsletterService.spec.ts b/src/app/customer/NewsletterService.spec.ts new file mode 100644 index 0000000000..ae3e5a8343 --- /dev/null +++ b/src/app/customer/NewsletterService.spec.ts @@ -0,0 +1,54 @@ +import { createRequestSender, RequestSender } from '@bigcommerce/request-sender'; + +import { getResponse } from '../common/request/responses.mock'; + +import NewsletterService from './NewsletterService'; + +describe('NewsletterService', () => { + let requestSender: RequestSender; + let newsletterService: NewsletterService; + + beforeEach(() => { + requestSender = createRequestSender(); + newsletterService = new NewsletterService(requestSender); + }); + + it('returns resolved promise if request is successful', async () => { + const email = 'foo'; + const firstName = 'bar'; + const response = getResponse({}); + + jest.spyOn(requestSender, 'post') + .mockResolvedValue(response); + + expect(await newsletterService.subscribe({ email, firstName })) + .toEqual(response); + + expect(requestSender.post) + .toHaveBeenCalledWith('/subscribe.php', { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: { + action: 'subscribe', + nl_email: email, + nl_first_name: firstName, + check: '1', + }, + }); + }); + + it('returns rejected promise if request is unsuccessful', async () => { + const email = 'foo'; + const firstName = 'bar'; + const response = { status: 500 }; + + jest.spyOn(requestSender, 'post') + .mockRejectedValue(response); + + try { + await newsletterService.subscribe({ email, firstName }); + } catch (error) { + expect(error) + .toEqual(response); + } + }); +}); diff --git a/src/app/customer/NewsletterService.ts b/src/app/customer/NewsletterService.ts new file mode 100644 index 0000000000..b1fdb448a8 --- /dev/null +++ b/src/app/customer/NewsletterService.ts @@ -0,0 +1,21 @@ +import { RequestSender, Response } from '@bigcommerce/request-sender'; + +export default class NewsletterService { + constructor( + private requestSender: RequestSender + ) {} + + subscribe(data: { email: string; firstName?: string }): Promise { + return this.requestSender.post('/subscribe.php', { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: { + action: 'subscribe', + nl_email: data.email || '', + nl_first_name: data.firstName || '', + check: '1', + }, + }); + } +} diff --git a/src/app/customer/__snapshots__/CheckoutButtonList.spec.tsx.snap b/src/app/customer/__snapshots__/CheckoutButtonList.spec.tsx.snap new file mode 100644 index 0000000000..bbe9fc611a --- /dev/null +++ b/src/app/customer/__snapshots__/CheckoutButtonList.spec.tsx.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CheckoutButtonList matches snapshot 1`] = ` +Array [ +

+ Or continue with +

, +
+
+
+
, +] +`; diff --git a/src/app/customer/__snapshots__/Customer.spec.tsx.snap b/src/app/customer/__snapshots__/Customer.spec.tsx.snap new file mode 100644 index 0000000000..17714a9f11 --- /dev/null +++ b/src/app/customer/__snapshots__/Customer.spec.tsx.snap @@ -0,0 +1,219 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Customer when view type is "guest" matches snapshot 1`] = ` +
+
+
+ + Guest Customer + +
+

+ + Checking out as a + + Guest + + ? You'll be able to save your details to create an account with us later. + +

+
+
+
+ + +
+
+ + +
+
+
+ +
+
+

+ Already have an account? + + Sign in now + +

+
+
+
+
+`; + +exports[`Customer when view type is "login" matches snapshot 1`] = ` +
+
+
+ + Returning Customer + +
+

+ + Don’t have an account? + + Create an account + + to continue. + +

+
+ + +
+
+ + + + Forgot password? + +
+
+ + + Cancel + +
+
+
+
+
+`; diff --git a/src/app/customer/index.ts b/src/app/customer/index.ts new file mode 100644 index 0000000000..87377f6b15 --- /dev/null +++ b/src/app/customer/index.ts @@ -0,0 +1,18 @@ +import { CustomerProps } from './Customer'; +import { CustomerInfoProps, CustomerSignOutEvent } from './CustomerInfo'; +import { GuestFormProps, GuestFormValues } from './GuestForm'; +import { LoginFormProps, LoginFormValues } from './LoginForm'; + +export type CustomerInfoProps = CustomerInfoProps; +export type CustomerProps = CustomerProps; +export type CustomerSignOutEvent = CustomerSignOutEvent; +export type GuestFormProps = GuestFormProps; +export type GuestFormValues = GuestFormValues; +export type LoginFormProps = LoginFormProps; +export type LoginFormValues = LoginFormValues; + +export { default as Customer, CustomerViewType } from './Customer'; +export { default as CustomerInfo } from './CustomerInfo'; +export { default as GuestForm } from './GuestForm'; +export { default as LoginForm } from './LoginForm'; +export { default as NewsletterService } from './NewsletterService';