Skip to content

Commit

Permalink
feat(customer): CHECKOUT-4641 Add Privacy Policy feature
Browse files Browse the repository at this point in the history
  • Loading branch information
Luis Sanchez authored Feb 7, 2020
1 parent 676028e commit 525fc4b
Show file tree
Hide file tree
Showing 18 changed files with 301 additions and 64 deletions.
1 change: 1 addition & 0 deletions src/app/common/utility/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export type RetryOptions = RetryOptions;
export * from './emptyData';
export { default as joinPaths } from './joinPaths';
export { default as retry } from './retry';
export { default as parseAnchor } from './parseAnchor';
29 changes: 29 additions & 0 deletions src/app/common/utility/parseAnchor.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import parseAnchor from './parseAnchor';

describe('parseAnchor()', () => {
it('returns empty prefix and suffix if there is just an anchor element', () => {
expect(parseAnchor('<a>text</a>'))
.toEqual(['', 'text', '']);

expect(parseAnchor('<a href="something">text</a>'))
.toEqual(['', 'text', '']);
});

it('returns prefix and suffix if anchor is surrounded by text', () => {
expect(parseAnchor('foo <a>text</a> bar'))
.toEqual(['foo ', 'text', ' bar']);

expect(parseAnchor('foo <a href="something">text</a> bar'))
.toEqual(['foo ', 'text', ' bar']);
});

it('returns first anchor if theres more than one', () => {
expect(parseAnchor('foo <a>text</a> s <a>x</a> bar'))
.toEqual(['foo ', 'text', ' s <a>x</a> bar']);
});

it('returns empty array if no anchor', () => {
expect(parseAnchor('foo text bar'))
.toEqual([]);
});
});
14 changes: 14 additions & 0 deletions src/app/common/utility/parseAnchor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export default function parseAnchor(text: string): string[] {
const div = document.createElement('div');
div.innerHTML = text;

const anchor = div.querySelector('a');

if (!anchor) {
return [];
}

const anchorSiblings = div.innerHTML.split(anchor.outerHTML);

return [ anchorSiblings[0], anchor.text, anchorSiblings[1] ];
}
12 changes: 11 additions & 1 deletion src/app/customer/Customer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CheckoutSelectors, CustomerCredentials, CustomerInitializeOptions, CustomerRequestOptions, GuestCredentials } from '@bigcommerce/checkout-sdk';
import { CheckoutSelectors, CustomerCredentials, CustomerInitializeOptions, CustomerRequestOptions, GuestCredentials, StoreConfig } from '@bigcommerce/checkout-sdk';
import { noop } from 'lodash';
import React, { Component, Fragment, ReactNode } from 'react';

Expand Down Expand Up @@ -34,6 +34,7 @@ interface WithCheckoutCustomerProps {
isGuestEnabled: boolean;
isSigningIn: boolean;
signInError?: Error;
privacyPolicyUrl?: string;
clearError(error: Error): Promise<CheckoutSelectors>;
continueAsGuest(credentials: GuestCredentials): Promise<CheckoutSelectors>;
deinitializeCustomer(options: CustomerRequestOptions): Promise<CheckoutSelectors>;
Expand Down Expand Up @@ -71,6 +72,7 @@ class Customer extends Component<CustomerProps & WithCheckoutCustomerProps> {
email,
initializeCustomer,
isContinuingAsGuest = false,
privacyPolicyUrl,
onUnhandledError = noop,
} = this.props;

Expand All @@ -92,6 +94,7 @@ class Customer extends Component<CustomerProps & WithCheckoutCustomerProps> {
onChangeEmail={ this.handleChangeEmail }
onContinueAsGuest={ this.handleContinueAsGuest }
onShowLogin={ this.handleShowLogin }
privacyPolicyUrl={ privacyPolicyUrl }
/>
);
}
Expand Down Expand Up @@ -205,6 +208,12 @@ export function mapToWithCheckoutCustomerProps(
return null;
}

const { checkoutSettings: { privacyPolicyUrl } } = config as StoreConfig & {
checkoutSettings: {
privacyPolicyUrl: string;
};
};

return {
canSubscribe: config.shopperConfig.showNewsletterSignup,
checkoutButtonIds: config.checkoutSettings.remoteCheckoutProviders,
Expand All @@ -220,6 +229,7 @@ export function mapToWithCheckoutCustomerProps(
isContinuingAsGuest: isContinuingAsGuest(),
isGuestEnabled: config.checkoutSettings.guestCheckoutEnabled,
isSigningIn: isSigningIn(),
privacyPolicyUrl,
signIn: checkoutService.signInCustomer,
signInError: getSignInError(),
};
Expand Down
43 changes: 43 additions & 0 deletions src/app/customer/GuestForm.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React from 'react';

import { getStoreConfig } from '../config/config.mock';
import { createLocaleContext, LocaleContext, LocaleContextType } from '../locale';
import { PrivacyPolicyField } from '../privacyPolicy';

import GuestForm, { GuestFormProps } from './GuestForm';

Expand Down Expand Up @@ -219,4 +220,46 @@ describe('GuestForm', () => {
expect(componentB.find('input[name="shouldSubscribe"]').prop('value'))
.toEqual(false);
});

it('renders privacy policy field', () => {
const component = mount(
<LocaleContext.Provider value={ localeContext }>
<GuestForm
{ ...defaultProps }
privacyPolicyUrl={ 'foo' }
/>
</LocaleContext.Provider>
);

expect(component.find(PrivacyPolicyField)).toHaveLength(1);
});

it('displays error message if privacy policy is required and not checked', async () => {
const handleContinueAsGuest = jest.fn();
const component = mount(
<LocaleContext.Provider value={ localeContext }>
<GuestForm
{ ...defaultProps }
onContinueAsGuest={ handleContinueAsGuest }
privacyPolicyUrl={ 'foo' }
/>
</LocaleContext.Provider>
);

component.find('input[name="email"]')
.simulate('change', { target: { value: 'test@test.com', name: 'email' } });

component.find('form')
.simulate('submit');

await new Promise(resolve => process.nextTick(resolve));

component.update();

expect(handleContinueAsGuest)
.not.toHaveBeenCalled();

expect(component.find('[data-test="privacy-policy-field-error-message"]').text())
.toEqual('Please agree to the Privacy Policy.');
});
});
22 changes: 19 additions & 3 deletions src/app/customer/GuestForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React, { memo, FunctionComponent, ReactNode } from 'react';
import { object, string } from 'yup';

import { withLanguage, TranslatedHtml, TranslatedString, WithLanguageProps } from '../locale';
import { getPrivacyPolicyValidationSchema, PrivacyPolicyField } from '../privacyPolicy';
import { Button, ButtonVariant } from '../ui/button';
import { BasicFormField, Fieldset, Form, Legend } from '../ui/form';

Expand All @@ -15,6 +16,7 @@ export interface GuestFormProps {
defaultShouldSubscribe: boolean;
email?: string;
isContinuingAsGuest: boolean;
privacyPolicyUrl?: string;
onChangeEmail(email: string): void;
onContinueAsGuest(data: GuestFormValues): void;
onShowLogin(): void;
Expand All @@ -31,6 +33,7 @@ const GuestForm: FunctionComponent<GuestFormProps & WithLanguageProps & FormikPr
isContinuingAsGuest,
onChangeEmail,
onShowLogin,
privacyPolicyUrl,
}) => (
<Form
className="checkout-form"
Expand All @@ -56,6 +59,10 @@ const GuestForm: FunctionComponent<GuestFormProps & WithLanguageProps & FormikPr
component={ SubscribeField }
name="shouldSubscribe"
/> }

{ privacyPolicyUrl && <PrivacyPolicyField
url={ privacyPolicyUrl }
/> }
</div>

<div className="form-actions customerEmail-action">
Expand Down Expand Up @@ -96,17 +103,26 @@ export default withLanguage(withFormik<GuestFormProps & WithLanguageProps, Guest
}) => ({
email,
shouldSubscribe: defaultShouldSubscribe,
terms: false,
privacyPolicy: false,
}),
handleSubmit: (values, { props: { onContinueAsGuest } }) => {
onContinueAsGuest(values);
},
validationSchema: ({ language }: GuestFormProps & WithLanguageProps) => {
validationSchema: ({ language, privacyPolicyUrl }: GuestFormProps & WithLanguageProps) => {
const email = string()
.email(language.translate('customer.email_invalid_error'))
.max(256)
.required(language.translate('customer.email_required_error'));

return object({ email });
const baseSchema = object({ email });

if (privacyPolicyUrl) {
return baseSchema.concat(getPrivacyPolicyValidationSchema({
isRequired: !!privacyPolicyUrl,
language,
}));
}

return baseSchema;
},
})(memo(GuestForm)));
5 changes: 5 additions & 0 deletions src/app/locale/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,11 @@
"spam_protection": {
"verify_action": "Please click here to verify yourself as human before proceeding."
},
"privacy_policy": {
"required_error": "Please agree to the Privacy Policy.",
"label": "Yes, I agree with the <a href=\"{url}\" target=\"_blank\">privacy policy</a>.",
"heading": "Privacy Policy"
},
"terms_and_conditions": {
"agreement_required_error": "Please agree to the terms and conditions",
"agreement_text": "Yes, I agree with the above terms and conditions.",
Expand Down
39 changes: 39 additions & 0 deletions src/app/privacyPolicy/PrivacyPolicyField.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { mount } from 'enzyme';
import { Formik } from 'formik';
import { noop } from 'lodash';
import React from 'react';

import { getStoreConfig } from '../config/config.mock';
import { createLocaleContext, LocaleContext, LocaleContextType, TranslatedHtml } from '../locale';
import { CheckboxFormField } from '../ui/form';

import PrivacyPolicyField from './PrivacyPolicyField';

describe('PrivacyPolicyField', () => {
let localeContext: LocaleContextType;
let initialValues: { terms: boolean };

beforeEach(() => {
initialValues = { terms: false };
localeContext = createLocaleContext(getStoreConfig());
});

it('renders checkbox with external link', () => {
const component = mount(
<LocaleContext.Provider value={ localeContext }>
<Formik
initialValues={ initialValues }
onSubmit={ noop }
>
<PrivacyPolicyField url="foo" />
</Formik>
</LocaleContext.Provider>
);

expect(component.find(CheckboxFormField)).toHaveLength(1);
expect(component.find(TranslatedHtml).props()).toMatchObject({
data: { url: 'foo' },
id: 'privacy_policy.label',
});
});
});
23 changes: 23 additions & 0 deletions src/app/privacyPolicy/PrivacyPolicyField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React, { memo, FunctionComponent } from 'react';

import { TranslatedHtml } from '../locale';
import { CheckboxFormField, Fieldset } from '../ui/form';

const PrivacyPolicyCheckboxFieldLink: FunctionComponent<{ url: string }> = ({
url,
}) => (
<CheckboxFormField
labelContent={ <TranslatedHtml data={ { url } } id="privacy_policy.label" /> }
name="privacyPolicy"
/>
);

const PrivacyPolicyFieldset: FunctionComponent<{ url: string }> = ({
url,
}) => (
<Fieldset additionalClassName="checkout-privacy-policy">
<PrivacyPolicyCheckboxFieldLink url={ url } />
</Fieldset>
);

export default memo(PrivacyPolicyFieldset);
23 changes: 23 additions & 0 deletions src/app/privacyPolicy/getPrivacyPolicyValidationSchema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { LanguageService } from '@bigcommerce/checkout-sdk';
import { boolean, object, BooleanSchema, ObjectSchema } from 'yup';

export interface PrivacyPolicyValidatonSchemaProps {
isRequired: boolean;
language: LanguageService;
}

export default function getPrivacyPolicyValidationSchema({
isRequired,
language,
}: PrivacyPolicyValidatonSchemaProps): ObjectSchema<{ privacyPolicy?: boolean }> {
const schemaFields: {
privacyPolicy?: BooleanSchema;
} = {};

if (isRequired) {
schemaFields.privacyPolicy = boolean()
.oneOf([true], language.translate('privacy_policy.required_error'));
}

return object(schemaFields);
}
2 changes: 2 additions & 0 deletions src/app/privacyPolicy/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as PrivacyPolicyField } from './PrivacyPolicyField';
export { default as getPrivacyPolicyValidationSchema } from './getPrivacyPolicyValidationSchema';
1 change: 0 additions & 1 deletion src/app/termsConditions/TermsConditions.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React, { FunctionComponent } from 'react';

import './TermsConditions.scss';
import TermsConditionsField, { TermsConditionsType } from './TermsConditionsField';

export interface TermsConditionsProps {
Expand Down
Loading

0 comments on commit 525fc4b

Please sign in to comment.