diff --git a/package.json b/package.json index b5e5cdf..809145d 100644 --- a/package.json +++ b/package.json @@ -32,9 +32,11 @@ "ghpages:build": "wireit" }, "dependencies": { + "@internetarchive/analytics-manager": "^0.1.5", "@internetarchive/ia-clearable-text-input": "^1.1.1", "@internetarchive/ia-dropdown": "^2.2.0", "@internetarchive/lazy-loader-service": "^0.2.0", + "@internetarchive/modal-manager": "^2.0.6", "@internetarchive/promised-singleton": "0.3.0-alpha.1", "@lit/localize": "^0.12.2", "@types/applepayjs": "^14.0.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb59f66..928593e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@internetarchive/analytics-manager': + specifier: ^0.1.5 + version: 0.1.5 '@internetarchive/ia-clearable-text-input': specifier: ^1.1.1 version: 1.1.1 @@ -17,6 +20,9 @@ importers: '@internetarchive/lazy-loader-service': specifier: ^0.2.0 version: 0.2.0 + '@internetarchive/modal-manager': + specifier: ^2.0.6 + version: 2.0.6 '@internetarchive/promised-singleton': specifier: 0.3.0-alpha.1 version: 0.3.0-alpha.1 @@ -385,6 +391,12 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@internetarchive/analytics-manager@0.1.5': + resolution: {integrity: sha512-6p/EB1ls6x0+xU5WyDcpLU7N4Q1VGW4cNgVV52AUw3nmXJPFZsUM7l7iFGjjJvxWK7TpxU10Z8svAzfdpOQUaA==} + + '@internetarchive/ia-activity-indicator@1.0.0': + resolution: {integrity: sha512-vBwWGDBb/vzB+jxwLFeOjzmwXWD+UjAqvQFTszwQ31hEMBBY0U+jgSgMDznt/3pb1e8ZcqtzsHRTvpKkzhoZsg==} + '@internetarchive/ia-clearable-text-input@1.1.1': resolution: {integrity: sha512-l3Z6gKfSRURvicHlAjL3V+q/yESYSev34fswfG7ESIiWuF7UuWDKjlLOyY3gFch8eAfqS5AQ6KurRw5JWGD4RA==} @@ -394,9 +406,15 @@ packages: '@internetarchive/icon-close@1.4.1': resolution: {integrity: sha512-nN1rGfScGssGFpfEew4azsrsMyUJtkDWqUDlckJi31PtyQnbZqUmZ07UO3Aw+wZIo3cU1MHxb5VxDy3Rmr8QGA==} + '@internetarchive/icon-user@1.4.1': + resolution: {integrity: sha512-xRlpX954+lLKjC++A+jpIutptHCmVMwLZI/WqOoAQoKfnCNKKvJ6Wcdq4bM/qUDt44Oj2VgP/gM6hSdWyljzEA==} + '@internetarchive/lazy-loader-service@0.2.0': resolution: {integrity: sha512-SI/ADWnIhdtuAbxkeOItb0xOqco+ueVtwEAdEb0D60sFngUCUpBr/EXeIeh+ykXveO9pmd5J0ZVpgEfKdbhE+g==} + '@internetarchive/modal-manager@2.0.6': + resolution: {integrity: sha512-PDEXz005tgjhKxrJLnd1sn8wn3DceEQqDluDhcn8+gxxhFKX714jyicKlbgvHzQOLtJYTdJjnNdmiyziF7ZIVA==} + '@internetarchive/promised-singleton@0.3.0-alpha.1': resolution: {integrity: sha512-o4cUSkTDrWXn7lP1tuMrJ8xxCoD8pCKVgdvv7gtlnCJsCpsFcBr3aTFkMF3O0G+Dm5VZ/Jmsw7pINKRXm226Lg==} @@ -2294,6 +2312,10 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + throttle-debounce@5.0.2: + resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==} + engines: {node: '>=12.22'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -2736,6 +2758,12 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@internetarchive/analytics-manager@0.1.5': {} + + '@internetarchive/ia-activity-indicator@1.0.0': + dependencies: + lit: 3.3.3 + '@internetarchive/ia-clearable-text-input@1.1.1': dependencies: '@internetarchive/icon-close': 1.4.1 @@ -2749,10 +2777,22 @@ snapshots: dependencies: lit: 3.3.3 + '@internetarchive/icon-user@1.4.1': + dependencies: + lit: 3.3.3 + '@internetarchive/lazy-loader-service@0.2.0': dependencies: nanoevents: 6.0.2 + '@internetarchive/modal-manager@2.0.6': + dependencies: + '@internetarchive/ia-activity-indicator': 1.0.0 + '@internetarchive/icon-close': 1.4.1 + '@internetarchive/icon-user': 1.4.1 + lit: 3.3.3 + throttle-debounce: 5.0.2 + '@internetarchive/promised-singleton@0.3.0-alpha.1': {} '@jridgewell/resolve-uri@3.1.2': {} @@ -4898,6 +4938,8 @@ snapshots: tapable@2.3.3: {} + throttle-debounce@5.0.2: {} + tinybench@2.9.0: {} tinyexec@1.2.4: {} diff --git a/src/elements/ia-donation-form/analytics.ts b/src/elements/ia-donation-form/analytics.ts new file mode 100644 index 0000000..4630e68 --- /dev/null +++ b/src/elements/ia-donation-form/analytics.ts @@ -0,0 +1,11 @@ +/** + * The analytics hooks the controller hands down to the flow handlers, so they + * can log events in the donation form's category without knowing about the + * analytics manager. + */ +export interface DonationEventLoggerInterface { + /** A sampled event in the form's own category */ + logEvent(action: string, label?: string): void; + /** An unsampled event in the `DonationFlow` category, for the money-moving steps */ + logDonationFlowEvent(action: string, label?: string): void; +} diff --git a/src/elements/ia-donation-form/braintree/payment-providers/paypal/paypal.ts b/src/elements/ia-donation-form/braintree/payment-providers/paypal/paypal.ts index 1495ee9..22b805a 100644 --- a/src/elements/ia-donation-form/braintree/payment-providers/paypal/paypal.ts +++ b/src/elements/ia-donation-form/braintree/payment-providers/paypal/paypal.ts @@ -64,6 +64,10 @@ export class PayPalHandler implements PayPalHandlerInterface { paypalInstance, }); + // Venmo has its own button, so keep it out of the PayPal one. The + // funding constants come from the PayPal script once it has loaded. + const venmoFunding = window.paypal?.FUNDING?.VENMO; + this.paypalButtonGenerator.render( { env, @@ -72,9 +76,7 @@ export class PayPalHandler implements PayPalHandlerInterface { onAuthorize: dataSource.onAuthorize.bind(dataSource), onCancel: dataSource.onCancel.bind(dataSource), onError: dataSource.onError.bind(dataSource), - funding: { - disallowed: [window.paypal.FUNDING.VENMO], - }, + funding: venmoFunding ? { disallowed: [venmoFunding] } : undefined, }, params.selector, ); diff --git a/src/elements/ia-donation-form/element-names.test.ts b/src/elements/ia-donation-form/element-names.test.ts index 21c6e9f..974d675 100644 --- a/src/elements/ia-donation-form/element-names.test.ts +++ b/src/elements/ia-donation-form/element-names.test.ts @@ -2,6 +2,17 @@ import { describe, expect, test } from 'vitest'; import './form-elements/ia-donation-section'; import './form-elements/ia-donation-edit-donation'; +import './form-elements/ia-donation-badged-input'; +import './form-elements/ia-donation-header'; +import './form-elements/ia-donation-payment-selector'; +import './form-elements/ia-donation-summary'; +import './form-elements/ia-donation-total-amount'; +import './form-elements/contact-form/ia-donation-contact-form'; +import './modals/ia-donation-confirm-modal'; +import './modals/ia-donation-error-modal-content'; +import './modals/ia-donation-upsell-modal-content'; +import './ia-donation-form'; +import './ia-donation-form-controller'; /** * Custom elements share one global registry, so the donation form's parts are @@ -10,11 +21,36 @@ import './form-elements/ia-donation-edit-donation'; * migration, so none of those names can be claimed here. */ const NAMESPACED_ELEMENTS = [ + 'ia-donation-form', + 'ia-donation-form-controller', 'ia-donation-section', 'ia-donation-edit-donation', + 'ia-donation-badged-input', + 'ia-donation-header', + 'ia-donation-payment-selector', + 'ia-donation-summary', + 'ia-donation-total-amount', + 'ia-donation-contact-form', + 'ia-donation-confirm-modal', + 'ia-donation-error-modal-content', + 'ia-donation-upsell-modal-content', ]; -const NAMES_TO_AVOID = ['donation-form-section', 'donation-form-edit-donation']; +const NAMES_TO_AVOID = [ + 'donation-form', + 'donation-form-controller', + 'donation-form-section', + 'donation-form-edit-donation', + 'badged-input', + 'donation-form-header', + 'payment-selector', + 'donation-summary', + 'donation-form-total-amount', + 'contact-form', + 'confirm-donation-modal', + 'donation-form-error-modal-content', + 'upsell-modal-content', +]; describe('donation form element names', () => { test.each(NAMESPACED_ELEMENTS)('registers %s', (tag) => { diff --git a/src/elements/ia-donation-form/flow-handlers/donation-flow-modal-manager.test.ts b/src/elements/ia-donation-form/flow-handlers/donation-flow-modal-manager.test.ts new file mode 100644 index 0000000..e591c62 --- /dev/null +++ b/src/elements/ia-donation-form/flow-handlers/donation-flow-modal-manager.test.ts @@ -0,0 +1,258 @@ +import { fixture } from '@open-wc/testing-helpers'; +import { describe, expect, test } from 'vitest'; +import { html } from 'lit'; + +import { MockBraintreeManager } from '../braintree/test-helpers/mock-managers.test-helpers'; +import { + fiveDollars, + mockBillingInfo, + mockCustomerInfo, + mockSuccessResponse, +} from '../braintree/test-helpers/mock-models.test-helpers'; +import { SuccessResponse } from '../models/donation-response'; +import { DonationType } from '../models/donation-type'; +import { PaymentProvider } from '../models/payment-provider'; +import { MockDonationEventLogger } from '../test-helpers/mock-analytics.test-helpers'; +import type { MockModalManager } from '../test-helpers/mock-modal-manager.test-helpers'; +import '../test-helpers/mock-modal-manager.test-helpers'; +import { DonationFlowModalManager } from './donation-flow-modal-manager'; + +async function setup(braintreeManager = new MockBraintreeManager()): Promise<{ + manager: DonationFlowModalManager; + modalManager: MockModalManager; + braintreeManager: MockBraintreeManager; + analytics: MockDonationEventLogger; +}> { + const modalManager = await fixture( + html``, + ); + const analytics = new MockDonationEventLogger(); + const manager = new DonationFlowModalManager({ + braintreeManager, + modalManager, + analytics, + }); + return { manager, modalManager, braintreeManager, analytics }; +} + +/** The text of a lit template with its expressions filled in, whitespace collapsed. */ +function templateText(template?: { + strings: readonly string[]; + values: unknown[]; +}): string { + if (!template) return ''; + return template.strings + .map( + (s, i) => + s + (i < template.values.length ? String(template.values[i]) : ''), + ) + .join('') + .replace(/\s+/g, ' ') + .trim(); +} + +describe('DonationFlowModalManager', () => { + test('can close the modal', async () => { + const { manager, modalManager } = await setup(); + manager.closeModal(); + expect(modalManager.closeCalled).to.be.true; + }); + + test('steps the default upsell amount up with the one-time gift', () => { + expect(DonationFlowModalManager.getDefaultUpsellAmount(1)).to.equal(8); + expect(DonationFlowModalManager.getDefaultUpsellAmount(10)).to.equal(8); + expect(DonationFlowModalManager.getDefaultUpsellAmount(10.01)).to.equal(10); + expect(DonationFlowModalManager.getDefaultUpsellAmount(25)).to.equal(10); + expect(DonationFlowModalManager.getDefaultUpsellAmount(25.01)).to.equal(25); + expect(DonationFlowModalManager.getDefaultUpsellAmount(100)).to.equal(25); + expect(DonationFlowModalManager.getDefaultUpsellAmount(100.01)).to.equal( + 50, + ); + }); + + test('shows the processing modal, which can not be dismissed', async () => { + const { manager, modalManager } = await setup(); + manager.showProcessingModal(); + + const config = modalManager.showModalOptions?.config; + expect(config?.headerColor).to.equal('#497fbf'); + expect(config?.showProcessingIndicator).to.be.true; + expect(config?.processingImageMode).to.equal('processing'); + expect(config?.closeOnBackdropClick).to.be.false; + expect(config?.showCloseButton).to.be.false; + expect(templateText(config?.title)).to.equal('Processing...'); + }); + + test('shows the error modal with the message and the questions link', async () => { + const { manager, modalManager } = await setup(); + manager.showErrorModal({ message: 'foo-error' }); + + const options = modalManager.showModalOptions; + expect(options?.config.headerColor).to.equal('#691916'); + expect(options?.config.showProcessingIndicator).to.be.false; + expect(options?.config.closeOnBackdropClick).to.be.true; + expect(options?.config.showCloseButton).to.be.true; + expect(templateText(options?.config.headline)).to.equal( + "There's been a problem completing your donation.", + ); + expect(templateText(options?.config.message)).to.equal('foo-error'); + expect(templateText(options?.customModalContent)).to.include( + '', + ); + }); + + test('shows the thank you modal and hands the donation to the endpoint manager', async () => { + const { manager, modalManager, braintreeManager, analytics } = + await setup(); + manager.showThankYouModal({ successResponse: mockSuccessResponse }); + + const config = modalManager.showModalOptions?.config; + expect(config?.headerColor).to.equal('#55A183'); + expect(config?.processingImageMode).to.equal('complete'); + expect(config?.showProcessingIndicator).to.be.true; + expect(templateText(config?.title)).to.equal('Thank You!'); + expect( + braintreeManager.donationSuccessfulOptions?.successResponse, + ).to.equal(mockSuccessResponse); + expect(analytics.flowEvents).to.deep.equal([ + ['Donated-CreditCard', DonationType.OneTime], + ]); + }); + + test('logs the provider and the upsell in the donated event', async () => { + const { manager, analytics } = await setup(); + const successResponse = new SuccessResponse({ + ...mockSuccessResponse, + paymentProvider: PaymentProvider.GooglePay, + }); + + manager.showThankYouModal({ + successResponse, + upsellSuccessResponse: mockSuccessResponse, + }); + + expect(analytics.flowEvents).to.deep.equal([ + ['Donated-GooglePay-upsell', DonationType.OneTime], + ]); + }); + + test('shows the confirmation step with the right title for an upsell', async () => { + const { manager, modalManager } = await setup(); + manager.showConfirmationStepModal({ + donationType: DonationType.Upsell, + amount: 8, + currencyType: 'USD', + cancelDonationCB: () => {}, + confirmDonationCB: () => {}, + }); + + const config = modalManager.showModalOptions?.config; + expect(config?.headerColor).to.equal('#55A183'); + expect(config?.closeOnBackdropClick).to.be.false; + expect(templateText(config?.title)).to.equal('Confirm monthly donation'); + expect(templateText(config?.message)).to.include( + ' { + const { manager, modalManager } = await setup(); + let cancelled = 0; + manager.showConfirmationStepModal({ + donationType: DonationType.OneTime, + amount: 8, + currencyType: 'USD', + cancelDonationCB: () => cancelled++, + confirmDonationCB: () => {}, + }); + + expect(templateText(modalManager.showModalOptions?.config.title)).to.equal( + 'Complete donation', + ); + + modalManager.showModalOptions?.userClosedModalCallback?.(); + expect(cancelled).to.equal(1); + }); + + test('runs the submission flow and returns the response', async () => { + const { manager, modalManager } = await setup(); + + const result = await manager.startDonationSubmissionFlow({ + nonce: 'foo', + paymentProvider: PaymentProvider.CreditCard, + donationInfo: fiveDollars(), + billingInfo: mockBillingInfo, + customerInfo: mockCustomerInfo, + }); + + expect(result?.success).to.be.true; + const value = result?.value as SuccessResponse; + expect(value.amount).to.equal(5); + expect(value.paymentProvider).to.equal(PaymentProvider.CreditCard); + expect(value.customer.email).to.equal('foo@bar.com'); + // A one-time donation ends on the upsell + expect(templateText(modalManager.showModalOptions?.config.title)).to.equal( + 'Donation received', + ); + }); + + test('goes straight to thanks for a monthly donation', async () => { + const { manager, modalManager } = await setup(); + + await manager.startDonationSubmissionFlow({ + nonce: 'foo', + paymentProvider: PaymentProvider.CreditCard, + donationInfo: { + ...fiveDollars(), + donationType: DonationType.Monthly, + } as never, + billingInfo: mockBillingInfo, + customerInfo: mockCustomerInfo, + }); + + expect(templateText(modalManager.showModalOptions?.config.title)).to.equal( + 'Thank You!', + ); + }); + + test('shows the error modal when the backend rejects the donation', async () => { + const { manager, modalManager } = await setup( + new MockBraintreeManager({ submitDonationResponse: 'failure' }), + ); + + const result = await manager.startDonationSubmissionFlow({ + nonce: 'foo', + paymentProvider: PaymentProvider.CreditCard, + donationInfo: fiveDollars(), + billingInfo: mockBillingInfo, + customerInfo: mockCustomerInfo, + }); + + expect(result?.success).to.be.false; + expect(templateText(modalManager.showModalOptions?.config.title)).to.equal( + 'Processing error', + ); + expect( + templateText(modalManager.showModalOptions?.config.message), + ).to.equal('error'); + }); + + test('shows the error modal when submission throws', async () => { + const { manager, modalManager } = await setup( + new MockBraintreeManager({ submitDonationError: true }), + ); + + const result = await manager.startDonationSubmissionFlow({ + nonce: 'foo', + paymentProvider: PaymentProvider.CreditCard, + donationInfo: fiveDollars(), + billingInfo: mockBillingInfo, + customerInfo: mockCustomerInfo, + }); + + expect(result).to.be.undefined; + expect( + templateText(modalManager.showModalOptions?.config.message), + ).to.equal('oh no'); + }); +}); diff --git a/src/elements/ia-donation-form/flow-handlers/donation-flow-modal-manager.ts b/src/elements/ia-donation-form/flow-handlers/donation-flow-modal-manager.ts new file mode 100644 index 0000000..cac0255 --- /dev/null +++ b/src/elements/ia-donation-form/flow-handlers/donation-flow-modal-manager.ts @@ -0,0 +1,347 @@ +import { html } from 'lit'; +import { msg } from '@lit/localize'; +import { + ModalConfig, + type ModalManagerInterface, +} from '@internetarchive/modal-manager'; + +import type { DonationEventLoggerInterface } from '../analytics'; +import type { BraintreeManagerInterface } from '../braintree/braintree-manager-interface'; +import type { DonationPaymentInfo } from '../models/donation-payment-info'; +import { + type DonationResponse, + type ErrorResponse, + type SuccessResponse, +} from '../models/donation-response'; +import { DonationType } from '../models/donation-type'; +import type { BillingInfo, CustomerInfo } from '../models/donor-contact-info'; +import type { PaymentProvider } from '../models/payment-provider'; +import { UpsellModalCTAMode } from '../modals/ia-donation-upsell-modal-content'; +import '../modals/ia-donation-confirm-modal'; +import '../modals/ia-donation-error-modal-content'; +import '../modals/ia-donation-upsell-modal-content'; + +const ModalHeaderColor = { + Blue: '#497fbf', + Green: '#55A183', + Red: '#691916', +} as const; + +/** + * The high-level modal flow. Each payment provider gets the donor to a nonce + * differently (PayPal button, Apple Pay sheet, the Venmo app), but from there + * the modals are the same: processing, upsell, thank you, error. The flow + * handlers call this to take the donor through them. + */ +export interface DonationFlowModalManagerInterface { + showConfirmationStepModal(options: { + amount: number; + donationType: DonationType; + currencyType: string; + confirmDonationCB: () => void; + cancelDonationCB: () => void; + }): Promise; + + closeModal(): void; + + showProcessingModal(): void; + + showThankYouModal(options: { + successResponse: SuccessResponse; + upsellSuccessResponse?: SuccessResponse; + }): void; + + showErrorModal(options: { + message: string; + userClosedModalCallback?: () => void; + }): void; + + showUpsellModal(options: { + oneTimeAmount: number; + ctaMode?: UpsellModalCTAMode; + yesSelected?: (amount: number) => void; + noSelected?: () => void; + amountChanged?: (amount: number) => void; + userClosedModalCallback?: () => void; + }): Promise; + + /** + * The main flow, once the provider has handed back a nonce: show + * processing, submit, then the upsell or thank you or error. + */ + startDonationSubmissionFlow(options: { + nonce: string; + paymentProvider: PaymentProvider; + donationInfo: DonationPaymentInfo; + billingInfo: BillingInfo; + customerInfo: CustomerInfo; + upsellOnetimeTransactionId?: string; + customerId?: string; + recaptchaToken?: string; + bin?: string; + binName?: string; + }): Promise; + + /** + * What happens after a successful donation depends on its type: a one-time + * donation gets the monthly upsell, a monthly one goes straight to thanks. + */ + handleSuccessfulDonationResponse( + donationInfo: DonationPaymentInfo, + response: SuccessResponse, + ): void; +} + +export class DonationFlowModalManager + implements DonationFlowModalManagerInterface +{ + private braintreeManager: BraintreeManagerInterface; + + private modalManager: ModalManagerInterface; + + private analytics: DonationEventLoggerInterface; + + constructor(options: { + braintreeManager: BraintreeManagerInterface; + modalManager: ModalManagerInterface; + analytics: DonationEventLoggerInterface; + }) { + this.modalManager = options.modalManager; + this.braintreeManager = options.braintreeManager; + this.analytics = options.analytics; + } + + closeModal(): void { + this.modalManager.closeModal(); + } + + showProcessingModal(): void { + const modalConfig = new ModalConfig({ + headerColor: ModalHeaderColor.Blue, + showProcessingIndicator: true, + closeOnBackdropClick: false, + showCloseButton: false, + processingImageMode: 'processing', + title: html`${msg('Processing...')}`, + }); + this.modalManager.showModal({ config: modalConfig }); + } + + showThankYouModal(options: { + successResponse: SuccessResponse; + upsellSuccessResponse?: SuccessResponse; + }): void { + const modalConfig = new ModalConfig({ + showProcessingIndicator: true, + processingImageMode: 'complete', + headerColor: ModalHeaderColor.Green, + title: html`${msg('Thank You!')}`, + }); + this.modalManager.showModal({ config: modalConfig }); + + const selectedPayment = options.successResponse.paymentProvider.replace( + /\s+/g, + '', + ); + let action = `Donated-${selectedPayment}`; + if (options.upsellSuccessResponse) { + action += `-upsell`; + } + const label = options.successResponse.donationType; + this.analytics.logDonationFlowEvent(action, label); + + this.braintreeManager.donationSuccessful(options); + } + + showErrorModal(options: { + message: string; + userClosedModalCallback?: () => void; + }): void { + const modalConfig = new ModalConfig({ + headerColor: ModalHeaderColor.Red, + title: html`${msg('Processing error')}`, + headline: html`${msg("There's been a problem completing your donation.")}`, + message: html`${options.message}`, + }); + + this.modalManager.showModal({ + config: modalConfig, + userClosedModalCallback: options.userClosedModalCallback, + customModalContent: html` + + `, + }); + } + + showConfirmationStepModal(options: { + amount: number; + donationType: DonationType; + currencyType: string; + confirmDonationCB: () => void; + cancelDonationCB: () => void; + }): Promise { + const modalTitle = + options.donationType === DonationType.Upsell + ? msg('Confirm monthly donation') + : msg('Complete donation'); + + const modalConfig = new ModalConfig({ + closeOnBackdropClick: false, + headerColor: ModalHeaderColor.Green, + title: html`${modalTitle}`, + message: html` + + `, + }); + return this.modalManager.showModal({ + config: modalConfig, + userClosedModalCallback: options.cancelDonationCB, + }); + } + + showUpsellModal(options: { + oneTimeAmount: number; + yesSelected?: (amount: number) => void; + noSelected?: () => void; + amountChanged?: (amount: number) => void; + userClosedModalCallback?: () => void; + ctaMode?: UpsellModalCTAMode; + }): Promise { + const modalConfig = new ModalConfig({ + headerColor: ModalHeaderColor.Green, + title: html`${msg('Donation received')}`, + processingImageMode: 'complete', + showProcessingIndicator: true, + }); + + const upsellAmount = DonationFlowModalManager.getDefaultUpsellAmount( + options.oneTimeAmount, + ); + options.amountChanged?.(upsellAmount); + + const modalContent = html` + + options.yesSelected?.(e.detail.amount)} + @noThanksSelected=${options.noSelected} + @amountChanged=${(e: CustomEvent): void => + options.amountChanged?.(e.detail.amount)} + > + + + `; + return this.modalManager.showModal({ + config: modalConfig, + customModalContent: modalContent, + userClosedModalCallback: options.userClosedModalCallback, + }); + } + + async startDonationSubmissionFlow(options: { + nonce: string; + paymentProvider: PaymentProvider; + donationInfo: DonationPaymentInfo; + billingInfo: BillingInfo; + customerInfo: CustomerInfo; + upsellOnetimeTransactionId?: string; + customerId?: string; + recaptchaToken?: string; + bin?: string; + binName?: string; + }): Promise { + this.showProcessingModal(); + + try { + const response = await this.braintreeManager.submitDonation(options); + + if (response.success) { + this.handleSuccessfulDonationResponse( + options.donationInfo, + response.value as SuccessResponse, + ); + } else { + const error = response.value as ErrorResponse; + this.showErrorModal({ message: error.message }); + } + return response; + } catch (error) { + this.showErrorModal({ message: `${error}` }); + console.error('error getting a response', error); + return undefined; + } + } + + private async upsellModalYesSelected( + oneTimeDonationResponse: SuccessResponse, + amount: number, + ): Promise { + this.showProcessingModal(); + + try { + const response = await this.braintreeManager.submitUpsellDonation({ + oneTimeDonationResponse, + amount, + }); + + if (response.success) { + this.showThankYouModal({ + successResponse: oneTimeDonationResponse, + upsellSuccessResponse: response.value as SuccessResponse, + }); + } else { + const error = response.value as ErrorResponse; + this.showErrorModal({ message: error.message }); + } + + return response; + } catch (error) { + this.showErrorModal({ message: `${error}` }); + console.error('error getting a response', error); + return undefined; + } + } + + /** The monthly amount the upsell suggests, stepped up with the one-time gift. */ + static getDefaultUpsellAmount(oneTimeAmount: number): number { + if (oneTimeAmount <= 10) return 8; + if (oneTimeAmount <= 25) return 10; + if (oneTimeAmount <= 100) return 25; + return 50; + } + + handleSuccessfulDonationResponse( + donationInfo: DonationPaymentInfo, + response: SuccessResponse, + ): void { + switch (donationInfo.donationType) { + case DonationType.OneTime: + this.showUpsellModal({ + oneTimeAmount: response.amount, + yesSelected: (amount: number) => { + this.upsellModalYesSelected(response, amount); + }, + noSelected: () => { + this.showThankYouModal({ successResponse: response }); + }, + userClosedModalCallback: () => { + this.showThankYouModal({ successResponse: response }); + }, + }); + break; + case DonationType.Monthly: + this.showThankYouModal({ successResponse: response }); + break; + // The upsell itself is completed in `upsellModalYesSelected` + case DonationType.Upsell: + break; + } + } +} diff --git a/src/elements/ia-donation-form/flow-handlers/handlers/apple-pay-flow-handler.ts b/src/elements/ia-donation-form/flow-handlers/handlers/apple-pay-flow-handler.ts new file mode 100644 index 0000000..5d58350 --- /dev/null +++ b/src/elements/ia-donation-form/flow-handlers/handlers/apple-pay-flow-handler.ts @@ -0,0 +1,117 @@ +import { msg } from '@lit/localize'; + +import type { BraintreeManagerInterface } from '../../braintree/braintree-manager-interface'; +import type { + ApplePaySessionDataSourceDelegate, + ApplePaySessionDataSourceInterface, +} from '../../braintree/payment-providers/apple-pay/apple-pay-session-datasource-interface'; +import type { DonationPaymentInfo } from '../../models/donation-payment-info'; +import type { + DonationResponse, + ErrorResponse, + SuccessResponse, +} from '../../models/donation-response'; +import { DonationType } from '../../models/donation-type'; +import type { DonationFlowModalManagerInterface } from '../donation-flow-modal-manager'; + +export interface ApplePayFlowHandlerInterface { + paymentInitiated(donationInfo: DonationPaymentInfo, e: Event): Promise; +} + +/** The Apple Pay flow: the sheet does the collecting, this handles what comes back. */ +export class ApplePayFlowHandler + implements ApplePayFlowHandlerInterface, ApplePaySessionDataSourceDelegate +{ + private donationFlowModalManager: DonationFlowModalManagerInterface; + + private braintreeManager: BraintreeManagerInterface; + + constructor(options: { + braintreeManager: BraintreeManagerInterface; + donationFlowModalManager: DonationFlowModalManagerInterface; + }) { + this.braintreeManager = options.braintreeManager; + this.donationFlowModalManager = options.donationFlowModalManager; + } + + private applePayDataSource?: ApplePaySessionDataSourceInterface; + + async paymentInitiated( + donationInfo: DonationPaymentInfo, + e: Event, + ): Promise { + this.donationFlowModalManager.showProcessingModal(); + const handler = + await this.braintreeManager.paymentProviders.applePayHandler.get(); + this.applePayDataSource = await handler.createPaymentRequest( + e, + donationInfo, + ); + + if (this.applePayDataSource) { + this.applePayDataSource.delegate = this; + } + } + + private async modalYesSelected( + oneTimeDonationResponse: SuccessResponse, + amount: number, + ): Promise { + this.donationFlowModalManager.showProcessingModal(); + + const response = await this.braintreeManager.submitUpsellDonation({ + oneTimeDonationResponse, + amount, + }); + + if (response.success) { + this.donationFlowModalManager.showThankYouModal({ + successResponse: oneTimeDonationResponse, + upsellSuccessResponse: response.value as SuccessResponse, + }); + } else { + const error = response.value as ErrorResponse; + this.donationFlowModalManager.showErrorModal({ message: error.message }); + } + } + + paymentComplete(response: DonationResponse): void { + if (response.success) { + const successResponse = response.value as SuccessResponse; + if ( + this.applePayDataSource?.donationInfo.donationType === + DonationType.OneTime + ) { + this.donationFlowModalManager.showUpsellModal({ + oneTimeAmount: successResponse.amount, + yesSelected: this.modalYesSelected.bind(this, successResponse), + noSelected: () => + this.donationFlowModalManager.showThankYouModal({ + successResponse, + }), + userClosedModalCallback: () => + this.donationFlowModalManager.showThankYouModal({ + successResponse, + }), + }); + } else { + this.donationFlowModalManager.showThankYouModal({ successResponse }); + } + } else { + const errorResponse = response.value as ErrorResponse; + this.donationFlowModalManager.showErrorModal({ + message: errorResponse.message, + }); + } + } + + paymentFailed(): void { + this.donationFlowModalManager.showErrorModal({ + message: msg('Payment failed'), + }); + } + + paymentCancelled(): void { + this.donationFlowModalManager.closeModal(); + } +} diff --git a/src/elements/ia-donation-form/flow-handlers/handlers/credit-card-flow-handler.ts b/src/elements/ia-donation-form/flow-handlers/handlers/credit-card-flow-handler.ts new file mode 100644 index 0000000..a2d5449 --- /dev/null +++ b/src/elements/ia-donation-form/flow-handlers/handlers/credit-card-flow-handler.ts @@ -0,0 +1,190 @@ +import type * as braintree from 'braintree-web'; +import { createNanoEvents, type Emitter, type Unsubscribe } from 'nanoevents'; +import { msg } from '@lit/localize'; + +import type { BraintreeManagerInterface } from '../../braintree/braintree-manager-interface'; +import { HostedFieldName } from '../../braintree/payment-providers/credit-card/hosted-field-container'; +import type { IADonationBadgedInput } from '../../form-elements/ia-donation-badged-input'; +import type { DonationPaymentInfo } from '../../models/donation-payment-info'; +import type { DonorContactInfo } from '../../models/donor-contact-info'; +import { PaymentProvider } from '../../models/payment-provider'; +import type { RecaptchaManagerInterface } from '../../recaptcha-manager'; +import type { DonationFlowModalManagerInterface } from '../donation-flow-modal-manager'; + +export interface CreditCardFlowHandlerInterface { + /** Bind the hosted field events */ + startup(): Promise; + + /** + * Tokenize the hosted fields. This also validates them, so it runs before + * the payment starts and shows any field errors. + */ + tokenizeFields(): Promise; + + /** Start the payment. Call `tokenizeFields()` first to validate the hosted fields. */ + paymentInitiated( + hostedFieldsResponse: braintree.HostedFieldsTokenizePayload, + donationInfo: DonationPaymentInfo, + donorContactInfo: DonorContactInfo, + ): Promise; + + on( + event: E, + callback: CreditCardFlowHandlerEvents[E], + ): Unsubscribe; +} + +export interface CreditCardFlowHandlerEvents { + validityChanged: (isValid: boolean) => void; +} + +export class CreditCardFlowHandler implements CreditCardFlowHandlerInterface { + private donationFlowModalManager: DonationFlowModalManagerInterface; + + private braintreeManager: BraintreeManagerInterface; + + private recaptchaManager: RecaptchaManagerInterface; + + private emitter: Emitter; + + constructor(options: { + braintreeManager: BraintreeManagerInterface; + donationFlowModalManager: DonationFlowModalManagerInterface; + recaptchaManager: RecaptchaManagerInterface; + }) { + this.braintreeManager = options.braintreeManager; + this.donationFlowModalManager = options.donationFlowModalManager; + this.recaptchaManager = options.recaptchaManager; + this.emitter = createNanoEvents(); + } + + on( + event: E, + callback: CreditCardFlowHandlerEvents[E], + ): Unsubscribe { + return this.emitter.on(event, callback); + } + + private started = false; + + async startup(): Promise { + if (this.started) { + return; + } + this.started = true; + + const handler = + await this.braintreeManager.paymentProviders.creditCardHandler.get(); + const instance = await handler.instance.get(); + + // The hosted fields only report focus and blur through these callbacks, + // so the error state of the wrapping badged inputs is toggled here, apart + // from the HostedFieldContainer that handles it everywhere else. + instance?.on('focus', (event: braintree.HostedFieldsEvent): void => { + const { emittedBy, fields } = event; + const { container } = fields[emittedBy]; + (container.parentElement as IADonationBadgedInput).error = false; + handler.hideErrorMessage(); + }); + + instance?.on('blur', (event: braintree.HostedFieldsEvent): void => { + const { emittedBy, fields } = event; + const { container, isEmpty, isValid } = fields[emittedBy]; + if (isEmpty || !isValid) { + (container.parentElement as IADonationBadgedInput).error = true; + } + }); + + instance?.on( + 'validityChange', + (event: braintree.HostedFieldsEvent): void => { + const { fields } = event; + const isValid = + fields.cvv.isValid && + fields.expirationDate.isValid && + fields.number.isValid; + this.emitter.emit('validityChanged', isValid); + }, + ); + } + + async tokenizeFields(): Promise< + braintree.HostedFieldsTokenizePayload | undefined + > { + const handler = + await this.braintreeManager.paymentProviders.creditCardHandler.get(); + + try { + return await handler.tokenizeHostedFields(); + } catch (error) { + this.handleHostedFieldTokenizationError( + error as braintree.BraintreeError, + ); + return undefined; + } + } + + async paymentInitiated( + hostedFieldsResponse: braintree.HostedFieldsTokenizePayload, + donationInfo: DonationPaymentInfo, + donorContactInfo: DonorContactInfo, + ): Promise { + let recaptchaToken: string | undefined; + + try { + recaptchaToken = await this.recaptchaManager.execute(); + } catch { + this.donationFlowModalManager.showErrorModal({ + message: msg('Recaptcha failure'), + }); + return; + } + + this.donationFlowModalManager.startDonationSubmissionFlow({ + nonce: hostedFieldsResponse.nonce, + paymentProvider: PaymentProvider.CreditCard, + recaptchaToken, + bin: hostedFieldsResponse.details.bin, + donationInfo, + customerInfo: donorContactInfo.customer, + billingInfo: donorContactInfo.billing, + }); + } + + /** + * Marks the fields Braintree rejected. The codes are documented at + * https://braintree.github.io/braintree-web/current/HostedFields.html#tokenize + */ + private async handleHostedFieldTokenizationError( + error: braintree.BraintreeError, + ): Promise { + const handler = + await this.braintreeManager.paymentProviders.creditCardHandler.get(); + + handler.showErrorMessage(); + + switch (error.code) { + case 'HOSTED_FIELDS_FIELDS_EMPTY': + handler.markFieldErrors([ + HostedFieldName.Number, + HostedFieldName.CVV, + HostedFieldName.ExpirationDate, + ]); + break; + case 'HOSTED_FIELDS_FIELDS_INVALID': + Object.keys(error.details.invalidFields).forEach((key) => { + handler.markFieldErrors([key as HostedFieldName]); + }); + break; + case 'HOSTED_FIELDS_TOKENIZATION_CVV_VERIFICATION_FAILED': + handler.markFieldErrors([HostedFieldName.CVV]); + break; + // HOSTED_FIELDS_TOKENIZATION_FAIL_ON_DUPLICATE, + // HOSTED_FIELDS_FAILED_TOKENIZATION, + // HOSTED_FIELDS_TOKENIZATION_NETWORK_ERROR and anything else: the + // general error message is enough, no field to point at. + default: + break; + } + } +} diff --git a/src/elements/ia-donation-form/flow-handlers/handlers/google-pay-flow-handler.ts b/src/elements/ia-donation-form/flow-handlers/handlers/google-pay-flow-handler.ts new file mode 100644 index 0000000..6d96b86 --- /dev/null +++ b/src/elements/ia-donation-form/flow-handlers/handlers/google-pay-flow-handler.ts @@ -0,0 +1,114 @@ +import type * as braintree from 'braintree-web'; +import { createNanoEvents, type Emitter, type Unsubscribe } from 'nanoevents'; + +import type { BraintreeManagerInterface } from '../../braintree/braintree-manager-interface'; +import type { DonationPaymentInfo } from '../../models/donation-payment-info'; +import { BillingInfo, CustomerInfo } from '../../models/donor-contact-info'; +import { PaymentProvider } from '../../models/payment-provider'; +import type { DonationFlowModalManagerInterface } from '../donation-flow-modal-manager'; + +export interface GooglePayFlowHandlerInterface { + paymentInitiated(donationInfo: DonationPaymentInfo): Promise; + on( + event: E, + callback: GooglePayFlowHandlerEvents[E], + ): Unsubscribe; +} + +export interface GooglePayFlowHandlerEvents { + paymentCancelled: () => void; +} + +/** The Google Pay flow: the payment sheet collects everything, this submits what it returns. */ +export class GooglePayFlowHandler implements GooglePayFlowHandlerInterface { + private donationFlowModalManager: DonationFlowModalManagerInterface; + + private braintreeManager: BraintreeManagerInterface; + + private emitter: Emitter = + createNanoEvents(); + + constructor(options: { + braintreeManager: BraintreeManagerInterface; + donationFlowModalManager: DonationFlowModalManagerInterface; + }) { + this.braintreeManager = options.braintreeManager; + this.donationFlowModalManager = options.donationFlowModalManager; + } + + on( + event: E, + callback: GooglePayFlowHandlerEvents[E], + ): Unsubscribe { + return this.emitter.on(event, callback); + } + + async paymentInitiated(donationInfo: DonationPaymentInfo): Promise { + const handler = + await this.braintreeManager.paymentProviders.googlePayHandler.get(); + const instance = await handler.instance.get(); + + const paymentDataRequest = instance.createPaymentDataRequest({ + emailRequired: true, + transactionInfo: { + currencyCode: 'USD', + totalPriceStatus: 'FINAL', + totalPrice: `${donationInfo.total}`, + }, + }); + + const cardPaymentMethod = paymentDataRequest.allowedPaymentMethods[0]; + cardPaymentMethod.parameters.billingAddressRequired = true; + cardPaymentMethod.parameters.billingAddressParameters = { + format: 'FULL', + phoneNumberRequired: false, + }; + + try { + const paymentData = + await handler.paymentsClient.loadPaymentData(paymentDataRequest); + const result: braintree.GooglePaymentTokenizePayload = + await instance.parseResponse(paymentData); + + const billingInfo = paymentData.paymentMethodData.info?.billingAddress; + // Google Pay gives one full name; the last word is the surname + const name = billingInfo?.name?.trim(); + let firstName: string | undefined = name; + let lastName: string | undefined = ''; + const lastSpace = name?.lastIndexOf(' ') ?? -1; + if (name && lastSpace > 0) { + firstName = name.substring(0, lastSpace); + lastName = name.substring(lastSpace + 1); + } + + const customer = new CustomerInfo({ + email: paymentData.email, + firstName, + lastName, + }); + + const billing = new BillingInfo({ + streetAddress: billingInfo?.address1, + extendedAddress: billingInfo?.address2, + locality: billingInfo?.locality, + region: billingInfo?.administrativeArea, + postalCode: billingInfo?.postalCode, + countryCodeAlpha2: billingInfo?.countryCode, + }); + + this.donationFlowModalManager.startDonationSubmissionFlow({ + nonce: result.nonce, + paymentProvider: PaymentProvider.GooglePay, + bin: result.details.bin, + binName: result.binData.issuingBank, + donationInfo, + customerInfo: customer, + billingInfo: billing, + }); + } catch { + // The sheet was dismissed, or Google couldn't complete it + this.emitter.emit('paymentCancelled'); + this.donationFlowModalManager.closeModal(); + } + } +} diff --git a/src/elements/ia-donation-form/flow-handlers/handlers/paypal-flow-handler.ts b/src/elements/ia-donation-form/flow-handlers/handlers/paypal-flow-handler.ts new file mode 100644 index 0000000..e611793 --- /dev/null +++ b/src/elements/ia-donation-form/flow-handlers/handlers/paypal-flow-handler.ts @@ -0,0 +1,336 @@ +import type * as paypal from 'paypal-checkout-components'; +import { createNanoEvents, type Emitter, type Unsubscribe } from 'nanoevents'; +import { msg } from '@lit/localize'; + +import type { BraintreeManagerInterface } from '../../braintree/braintree-manager-interface'; +import type { + PayPalButtonDataSourceDelegate, + PayPalButtonDataSourceInterface, +} from '../../braintree/payment-providers/paypal/paypal-button-datasource'; +import { DonationPaymentInfo } from '../../models/donation-payment-info'; +import type { + DonationResponse, + ErrorResponse, + SuccessResponse, +} from '../../models/donation-response'; +import { DonationType } from '../../models/donation-type'; +import { BillingInfo, CustomerInfo } from '../../models/donor-contact-info'; +import { PaymentProvider } from '../../models/payment-provider'; +import { UpsellModalCTAMode } from '../../modals/ia-donation-upsell-modal-content'; +import { + DonationFlowModalManager, + type DonationFlowModalManagerInterface, +} from '../donation-flow-modal-manager'; + +export interface PayPalFlowHandlerInterface { + updateDonationInfo(donationInfo: DonationPaymentInfo): void; + updateUpsellDonationInfo(donationInfo: DonationPaymentInfo): void; + renderPayPalButton(donationInfo: DonationPaymentInfo): Promise; + on( + event: E, + callback: PayPalFlowHandlerEvents[E], + ): Unsubscribe; +} + +/** The one-time donation's results, kept for the upsell that follows it. */ +class UpsellDataSourceContainer { + upsellButtonDataSource: PayPalButtonDataSourceInterface; + oneTimePayload: paypal.TokenizePayload; + oneTimeSuccessResponse: SuccessResponse; + + constructor(options: { + upsellButtonDataSource: PayPalButtonDataSourceInterface; + oneTimePayload: paypal.TokenizePayload; + oneTimeSuccessResponse: SuccessResponse; + }) { + this.upsellButtonDataSource = options.upsellButtonDataSource; + this.oneTimePayload = options.oneTimePayload; + this.oneTimeSuccessResponse = options.oneTimeSuccessResponse; + } +} + +export interface PayPalFlowHandlerEvents { + payPalPaymentStarted: ( + dataSource: PayPalButtonDataSourceInterface, + options: object, + ) => void; + payPalPaymentCancelled: ( + dataSource: PayPalButtonDataSourceInterface, + data: object, + ) => void; + payPalPaymentError: ( + dataSource: PayPalButtonDataSourceInterface, + error: string, + ) => void; + payPalPaymentConfirmed: ( + dataSource: PayPalButtonDataSourceInterface, + data: object, + ) => void; +} + +/** The PayPal flow: button press, authorization, our confirmation step, submit, upsell. */ +export class PayPalFlowHandler + implements PayPalFlowHandlerInterface, PayPalButtonDataSourceDelegate +{ + private upsellButtonDataSourceContainer?: UpsellDataSourceContainer; + + private buttonDataSource?: PayPalButtonDataSourceInterface; + + private donationFlowModalManager: DonationFlowModalManagerInterface; + + private braintreeManager: BraintreeManagerInterface; + + private emitter: Emitter = + createNanoEvents(); + + updateDonationInfo(donationInfo: DonationPaymentInfo): void { + if (this.buttonDataSource) { + this.buttonDataSource.donationInfo = donationInfo; + } + } + + updateUpsellDonationInfo(donationInfo: DonationPaymentInfo): void { + if (this.upsellButtonDataSourceContainer) { + this.upsellButtonDataSourceContainer.upsellButtonDataSource.donationInfo = + donationInfo; + } + } + + constructor(options: { + braintreeManager: BraintreeManagerInterface; + donationFlowModalManager: DonationFlowModalManagerInterface; + }) { + this.braintreeManager = options.braintreeManager; + this.donationFlowModalManager = options.donationFlowModalManager; + } + + on( + event: E, + callback: PayPalFlowHandlerEvents[E], + ): Unsubscribe { + return this.emitter.on(event, callback); + } + + async payPalPaymentStarted( + dataSource: PayPalButtonDataSourceInterface, + options: object, + ): Promise { + this.emitter.emit('payPalPaymentStarted', dataSource, options); + } + + /** + * PayPal has authorized the payment. The donor confirms the amount in our + * own step before it's submitted, which leads to `payPalPaymentConfirmed`. + */ + async payPalPaymentAuthorized( + dataSource: PayPalButtonDataSourceInterface, + payload: paypal.TokenizePayload, + ): Promise { + const { donationType, total } = dataSource.donationInfo; + this.donationFlowModalManager.showConfirmationStepModal({ + donationType, + amount: total, + currencyType: 'USD', + confirmDonationCB: () => { + this.payPalPaymentConfirmed(dataSource, payload); + }, + cancelDonationCB: () => { + this.donationFlowModalManager.closeModal(); + this.payPalPaymentCancelled(dataSource, {}); + }, + }); + } + + async payPalPaymentConfirmed( + dataSource: PayPalButtonDataSourceInterface, + payload: paypal.TokenizePayload, + ): Promise { + this.emitter.emit('payPalPaymentConfirmed', dataSource, {}); + this.donationFlowModalManager.showProcessingModal(); + + const donationType = dataSource.donationInfo.donationType; + + const details = payload.details; + + const customerInfo = new CustomerInfo({ + email: details?.email, + firstName: details?.firstName, + lastName: details?.lastName, + }); + + const shippingAddress = details?.shippingAddress; + + const billingInfo = new BillingInfo({ + streetAddress: shippingAddress?.line1, + extendedAddress: shippingAddress?.line2, + locality: shippingAddress?.city, + region: shippingAddress?.state, + postalCode: shippingAddress?.postalCode, + countryCodeAlpha2: shippingAddress?.countryCode, + }); + + const oneTimeTransaction = + this.upsellButtonDataSourceContainer?.oneTimeSuccessResponse + .transaction_id; + + const response: DonationResponse = + await this.braintreeManager.submitDonation({ + nonce: payload.nonce, + paymentProvider: PaymentProvider.PayPal, + donationInfo: dataSource.donationInfo, + customerInfo, + billingInfo, + upsellOnetimeTransactionId: oneTimeTransaction, + }); + + if (!response.success) { + const error = response.value as ErrorResponse; + this.donationFlowModalManager.showErrorModal({ message: error.message }); + return; + } + + const successResponse = response.value as SuccessResponse; + + switch (donationType) { + case DonationType.OneTime: + this.showUpsellModal(payload, successResponse); + break; + case DonationType.Monthly: + this.donationFlowModalManager.showThankYouModal({ successResponse }); + break; + case DonationType.Upsell: + if (this.upsellButtonDataSourceContainer) { + this.donationFlowModalManager.showThankYouModal({ + successResponse: + this.upsellButtonDataSourceContainer.oneTimeSuccessResponse, + upsellSuccessResponse: successResponse, + }); + } else { + // An upsell with no record of the one-time donation it follows + this.donationFlowModalManager.showErrorModal({ + message: msg('Error setting up monthly donation'), + }); + } + break; + } + } + + async payPalPaymentCancelled( + dataSource: PayPalButtonDataSourceInterface, + data: object, + ): Promise { + this.emitter.emit('payPalPaymentCancelled', dataSource, data); + } + + async payPalPaymentError( + dataSource: PayPalButtonDataSourceInterface, + error: string, + ): Promise { + this.emitter.emit('payPalPaymentError', dataSource, error); + console.error( + 'PayPalFlowHandler payment error:', + dataSource, + dataSource.donationInfo, + error, + ); + } + + async renderPayPalButton(donationInfo: DonationPaymentInfo): Promise { + const handler = + await this.braintreeManager.paymentProviders.paypalHandler.get(); + + this.buttonDataSource = await handler.renderPayPalButton({ + selector: '#paypal-button', + style: { + color: 'blue' as paypal.ButtonColorOption, + label: 'paypal' as paypal.ButtonLabelOption, + shape: 'rect' as paypal.ButtonShapeOption, + size: 'medium' as paypal.ButtonSizeOption, + tagline: false, + }, + donationInfo, + }); + + if (this.buttonDataSource) { + this.buttonDataSource.delegate = this; + } + } + + private async showUpsellModal( + oneTimePayload: paypal.TokenizePayload, + oneTimeSuccessResponse: SuccessResponse, + ): Promise { + this.donationFlowModalManager.showUpsellModal({ + oneTimeAmount: oneTimeSuccessResponse.amount, + amountChanged: this.upsellAmountChanged.bind(this), + noSelected: () => { + this.donationFlowModalManager.showThankYouModal({ + successResponse: oneTimeSuccessResponse, + }); + }, + ctaMode: UpsellModalCTAMode.PayPalUpsellSlot, + userClosedModalCallback: () => { + this.donationFlowModalManager.showThankYouModal({ + successResponse: oneTimeSuccessResponse, + }); + }, + }); + + const amount = DonationFlowModalManager.getDefaultUpsellAmount( + oneTimeSuccessResponse.amount, + ); + + const upsellDonationInfo = new DonationPaymentInfo({ + amount, + donationType: DonationType.Upsell, + coverFees: false, + }); + + if (!this.upsellButtonDataSourceContainer) { + this.renderUpsellPayPalButton({ + donationInfo: upsellDonationInfo, + oneTimePayload, + oneTimeSuccessResponse, + }); + } + } + + private upsellAmountChanged(amount: number): void { + if (this.upsellButtonDataSourceContainer) { + this.upsellButtonDataSourceContainer.upsellButtonDataSource.donationInfo.amount = + amount; + } + } + + private async renderUpsellPayPalButton(options: { + donationInfo: DonationPaymentInfo; + oneTimePayload: paypal.TokenizePayload; + oneTimeSuccessResponse: SuccessResponse; + }): Promise { + const handler = + await this.braintreeManager.paymentProviders.paypalHandler.get(); + + const upsellButtonDataSource = await handler.renderPayPalButton({ + selector: '#paypal-upsell-button', + style: { + color: 'blue' as paypal.ButtonColorOption, + label: 'paypal' as paypal.ButtonLabelOption, + shape: 'rect' as paypal.ButtonShapeOption, + size: 'responsive' as paypal.ButtonSizeOption, + tagline: false, + }, + donationInfo: options.donationInfo, + }); + + if (upsellButtonDataSource) { + upsellButtonDataSource.delegate = this; + this.upsellButtonDataSourceContainer = new UpsellDataSourceContainer({ + upsellButtonDataSource, + oneTimePayload: options.oneTimePayload, + oneTimeSuccessResponse: options.oneTimeSuccessResponse, + }); + } else { + console.error('error rendering paypal upsell button'); + } + } +} diff --git a/src/elements/ia-donation-form/flow-handlers/handlers/venmo-flow-handler.ts b/src/elements/ia-donation-form/flow-handlers/handlers/venmo-flow-handler.ts new file mode 100644 index 0000000..4a6c2f7 --- /dev/null +++ b/src/elements/ia-donation-form/flow-handlers/handlers/venmo-flow-handler.ts @@ -0,0 +1,125 @@ +import type * as braintree from 'braintree-web'; +import { msg } from '@lit/localize'; + +import type { BraintreeManagerInterface } from '../../braintree/braintree-manager-interface'; +import { + VenmoRestorationStateHandler, + type VenmoRestorationStateHandlerInterface, +} from '../../braintree/payment-providers/venmo/venmo-restoration-state-handler'; +import type { DonationPaymentInfo } from '../../models/donation-payment-info'; +import type { DonorContactInfo } from '../../models/donor-contact-info'; +import { PaymentProvider } from '../../models/payment-provider'; +import type { DonationFlowModalManagerInterface } from '../donation-flow-modal-manager'; + +export interface VenmoFlowHandlerInterface { + startup(): Promise; + paymentInitiated( + contactInfo: DonorContactInfo, + donationInfo: DonationPaymentInfo, + ): Promise; +} + +/** + * The Venmo flow. Venmo sends the donor to its app and back, possibly in a + * new tab, so the donation info is stashed before leaving and restored on + * startup if a tokenization result is waiting. + */ +export class VenmoFlowHandler implements VenmoFlowHandlerInterface { + private donationFlowModalManager: DonationFlowModalManagerInterface; + + private braintreeManager: BraintreeManagerInterface; + + private restorationStateHandler: VenmoRestorationStateHandlerInterface; + + constructor(options: { + braintreeManager: BraintreeManagerInterface; + donationFlowModalManager: DonationFlowModalManagerInterface; + restorationStateHandler?: VenmoRestorationStateHandlerInterface; + }) { + this.braintreeManager = options.braintreeManager; + this.donationFlowModalManager = options.donationFlowModalManager; + this.restorationStateHandler = + options.restorationStateHandler ?? new VenmoRestorationStateHandler(); + } + + /** Resume a Venmo payment if the app redirected back to us with a result. */ + async startup(): Promise { + const handler = + await this.braintreeManager.paymentProviders.venmoHandler.get(); + const instance = await handler?.instance.get(); + if (instance?.hasTokenizationResult()) { + const restoredInfo = + await this.restorationStateHandler.getRestorationState(); + if (restoredInfo) { + this.paymentInitiated( + restoredInfo.contactInfo, + restoredInfo.donationInfo, + ); + } else { + console.error('no restoration info'); + this.donationFlowModalManager.showErrorModal({ + message: msg('Error restoring donation session'), + }); + } + } + } + + async paymentInitiated( + contactInfo: DonorContactInfo, + donationInfo: DonationPaymentInfo, + ): Promise { + // Stashed in case Venmo brings the donor back in a new tab + this.restorationStateHandler.persistState(contactInfo, donationInfo); + + try { + const handler = + await this.braintreeManager.paymentProviders.venmoHandler.get(); + const result = await handler?.startPayment(); + if (!result) { + this.restorationStateHandler.clearState(); + this.donationFlowModalManager.showErrorModal({ + message: msg('Error setting up the donation'), + }); + return; + } + this.handleTokenizationResult(result, contactInfo, donationInfo); + } catch (tokenizeError) { + this.restorationStateHandler.clearState(); + this.handleTokenizationError(tokenizeError as braintree.BraintreeError); + this.donationFlowModalManager.showErrorModal({ + message: msg( + 'There was a problem loading your donation information. Please try again.', + ), + }); + } + } + + private async handleTokenizationResult( + payload: braintree.VenmoTokenizePayload, + contactInfo: DonorContactInfo, + donationInfo: DonationPaymentInfo, + ): Promise { + this.restorationStateHandler.clearState(); + + this.donationFlowModalManager.startDonationSubmissionFlow({ + nonce: payload.nonce, + paymentProvider: PaymentProvider.Venmo, + donationInfo, + customerInfo: contactInfo.customer, + billingInfo: contactInfo.billing, + }); + } + + private handleTokenizationError( + tokenizeError: braintree.BraintreeError, + ): void { + switch (tokenizeError.code) { + // The donor backed out, nothing to report + case 'VENMO_APP_CANCELED': + case 'VENMO_CANCELED': + break; + default: + console.error('Venmo tokenization error', tokenizeError); + } + } +} diff --git a/src/elements/ia-donation-form/flow-handlers/payment-flow-handlers.ts b/src/elements/ia-donation-form/flow-handlers/payment-flow-handlers.ts new file mode 100644 index 0000000..1bbe745 --- /dev/null +++ b/src/elements/ia-donation-form/flow-handlers/payment-flow-handlers.ts @@ -0,0 +1,161 @@ +import type { ModalManagerInterface } from '@internetarchive/modal-manager'; + +import type { DonationEventLoggerInterface } from '../analytics'; +import type { BraintreeManagerInterface } from '../braintree/braintree-manager-interface'; +import type { DonationType } from '../models/donation-type'; +import type { UpsellModalCTAMode } from '../modals/ia-donation-upsell-modal-content'; +import type { RecaptchaManagerInterface } from '../recaptcha-manager'; +import { + DonationFlowModalManager, + type DonationFlowModalManagerInterface, +} from './donation-flow-modal-manager'; +import { + ApplePayFlowHandler, + type ApplePayFlowHandlerInterface, +} from './handlers/apple-pay-flow-handler'; +import { + CreditCardFlowHandler, + type CreditCardFlowHandlerInterface, +} from './handlers/credit-card-flow-handler'; +import { + GooglePayFlowHandler, + type GooglePayFlowHandlerInterface, +} from './handlers/google-pay-flow-handler'; +import { + PayPalFlowHandler, + type PayPalFlowHandlerInterface, +} from './handlers/paypal-flow-handler'; +import { + VenmoFlowHandler, + type VenmoFlowHandlerInterface, +} from './handlers/venmo-flow-handler'; + +export interface PaymentFlowHandlersInterface { + startup(): Promise; + + showUpsellModal(options: { + oneTimeAmount: number; + ctaMode?: UpsellModalCTAMode; + yesSelected?: (amount: number) => void; + noSelected?: () => void; + amountChanged?: (amount: number) => void; + userClosedModalCallback?: () => void; + }): Promise; + + showConfirmationStepModal(options: { + amount: number; + donationType: DonationType; + currencyType: string; + confirmDonationCB: () => void; + cancelDonationCB: () => void; + }): Promise; + + creditCardHandler: CreditCardFlowHandlerInterface | undefined; + paypalHandler: PayPalFlowHandlerInterface | undefined; + applePayHandler: ApplePayFlowHandlerInterface | undefined; + venmoHandler: VenmoFlowHandlerInterface | undefined; + googlePayHandler: GooglePayFlowHandlerInterface | undefined; +} + +/** + * The container for the per-provider flow handlers. + * + * A flow handler owns the steps between the UI and the data for one + * provider. For a credit card, that's: read the contact form, run recaptcha, + * tokenize the hosted fields, submit, show the upsell, finish. For PayPal the + * donor starts from PayPal's own button and the handler picks up from there. + */ +export class PaymentFlowHandlers implements PaymentFlowHandlersInterface { + async startup(): Promise { + this.venmoHandler?.startup(); + this.creditCardHandler?.startup(); + } + + async showUpsellModal(options: { + oneTimeAmount: number; + ctaMode?: UpsellModalCTAMode; + yesSelected?: (amount: number) => void; + noSelected?: () => void; + amountChanged?: (amount: number) => void; + userClosedModalCallback?: () => void; + }): Promise { + return this.donationFlowModalManager.showUpsellModal(options); + } + + showConfirmationStepModal(options: { + amount: number; + donationType: DonationType; + currencyType: string; + confirmDonationCB: () => void; + cancelDonationCB: () => void; + }): Promise { + return this.donationFlowModalManager.showConfirmationStepModal(options); + } + + get creditCardHandler(): CreditCardFlowHandlerInterface { + this.creditCardHandlerCache ??= new CreditCardFlowHandler({ + braintreeManager: this.braintreeManager, + donationFlowModalManager: this.donationFlowModalManager, + recaptchaManager: this.recaptchaManager, + }); + return this.creditCardHandlerCache; + } + + get paypalHandler(): PayPalFlowHandlerInterface { + this.paypalHandlerCache ??= new PayPalFlowHandler({ + braintreeManager: this.braintreeManager, + donationFlowModalManager: this.donationFlowModalManager, + }); + return this.paypalHandlerCache; + } + + get applePayHandler(): ApplePayFlowHandlerInterface { + this.applePayHandlerCache ??= new ApplePayFlowHandler({ + braintreeManager: this.braintreeManager, + donationFlowModalManager: this.donationFlowModalManager, + }); + return this.applePayHandlerCache; + } + + get venmoHandler(): VenmoFlowHandlerInterface { + this.venmoHandlerCache ??= new VenmoFlowHandler({ + braintreeManager: this.braintreeManager, + donationFlowModalManager: this.donationFlowModalManager, + }); + return this.venmoHandlerCache; + } + + get googlePayHandler(): GooglePayFlowHandlerInterface { + this.googlePayHandlerCache ??= new GooglePayFlowHandler({ + braintreeManager: this.braintreeManager, + donationFlowModalManager: this.donationFlowModalManager, + }); + return this.googlePayHandlerCache; + } + + private creditCardHandlerCache?: CreditCardFlowHandlerInterface; + private paypalHandlerCache?: PayPalFlowHandlerInterface; + private applePayHandlerCache?: ApplePayFlowHandlerInterface; + private venmoHandlerCache?: VenmoFlowHandlerInterface; + private googlePayHandlerCache?: GooglePayFlowHandlerInterface; + + constructor(options: { + braintreeManager: BraintreeManagerInterface; + modalManager: ModalManagerInterface; + recaptchaManager: RecaptchaManagerInterface; + analytics: DonationEventLoggerInterface; + }) { + this.braintreeManager = options.braintreeManager; + this.recaptchaManager = options.recaptchaManager; + + this.donationFlowModalManager = new DonationFlowModalManager({ + braintreeManager: this.braintreeManager, + modalManager: options.modalManager, + analytics: options.analytics, + }); + } + + private braintreeManager: BraintreeManagerInterface; + private recaptchaManager: RecaptchaManagerInterface; + private donationFlowModalManager: DonationFlowModalManagerInterface; +} diff --git a/src/elements/ia-donation-form/form-elements/contact-form/autocomplete-field-options.ts b/src/elements/ia-donation-form/form-elements/contact-form/autocomplete-field-options.ts new file mode 100644 index 0000000..79230f4 --- /dev/null +++ b/src/elements/ia-donation-form/form-elements/contact-form/autocomplete-field-options.ts @@ -0,0 +1,63 @@ +export type AutoCompleteFieldOptions = + | 'on' + | 'off' + | 'additional-name' + | 'address-level1' + | 'address-level2' + | 'address-level3' + | 'address-level4' + | 'address-line1' + | 'address-line2' + | 'address-line3' + | 'bday' + | 'bday-year' + | 'bday-day' + | 'bday-month' + | 'billing' + | 'cc-additional-name' + | 'cc-csc' + | 'cc-exp' + | 'cc-exp-month' + | 'cc-exp-year' + | 'cc-family-name' + | 'cc-given-name' + | 'cc-name' + | 'cc-number' + | 'cc-type' + | 'country' + | 'country-name' + | 'current-password' + | 'email' + | 'family-name' + | 'fax' + | 'given-name' + | 'home' + | 'honorific-prefix' + | 'honorific-suffix' + | 'impp' + | 'language' + | 'mobile' + | 'name' + | 'new-password' + | 'nickname' + | 'organization' + | 'organization-title' + | 'pager' + | 'photo' + | 'postal-code' + | 'sex' + | 'shipping' + | 'street-address' + | 'tel-area-code' + | 'tel' + | 'tel-country-code' + | 'tel-extension' + | 'tel-local' + | 'tel-local-prefix' + | 'tel-local-suffix' + | 'tel-national' + | 'transaction-amount' + | 'transaction-currency' + | 'url' + | 'username' + | 'work'; diff --git a/src/elements/ia-donation-form/form-elements/contact-form/countries.ts b/src/elements/ia-donation-form/form-elements/contact-form/countries.ts new file mode 100644 index 0000000..b82458b --- /dev/null +++ b/src/elements/ia-donation-form/form-elements/contact-form/countries.ts @@ -0,0 +1,253 @@ +/** ISO 3166-1 alpha-2 codes to display names, in display order. */ +export const countries: Record = { + AF: 'Afghanistan', + AX: 'Aland Islands', + AL: 'Albania', + DZ: 'Algeria', + AS: 'American Samoa', + AD: 'Andorra', + AO: 'Angola', + AI: 'Anguilla', + AQ: 'Antarctica', + AG: 'Antigua and Barbuda', + AR: 'Argentina', + AM: 'Armenia', + AW: 'Aruba', + AU: 'Australia', + AT: 'Austria', + AZ: 'Azerbaijan', + BS: 'Bahamas', + BH: 'Bahrain', + BD: 'Bangladesh', + BB: 'Barbados', + BY: 'Belarus', + BE: 'Belgium', + BZ: 'Belize', + BJ: 'Benin', + BM: 'Bermuda', + BT: 'Bhutan', + BO: 'Bolivia', + BQ: 'Bonaire, Saint Eustatius and Saba ', + BA: 'Bosnia and Herzegovina', + BW: 'Botswana', + BV: 'Bouvet Island', + BR: 'Brazil', + IO: 'British Indian Ocean Territory', + VG: 'British Virgin Islands', + BN: 'Brunei', + BG: 'Bulgaria', + BF: 'Burkina Faso', + BI: 'Burundi', + KH: 'Cambodia', + CM: 'Cameroon', + CA: 'Canada', + CV: 'Cape Verde', + KY: 'Cayman Islands', + CF: 'Central African Republic', + TD: 'Chad', + CL: 'Chile', + CN: 'China', + CX: 'Christmas Island', + CC: 'Cocos Islands', + CO: 'Colombia', + KM: 'Comoros', + CK: 'Cook Islands', + CR: 'Costa Rica', + HR: 'Croatia', + CU: 'Cuba', + CW: 'Curacao', + CY: 'Cyprus', + CZ: 'Czech Republic', + CD: 'Democratic Republic of the Congo', + DK: 'Denmark', + DJ: 'Djibouti', + DM: 'Dominica', + DO: 'Dominican Republic', + TL: 'East Timor', + EC: 'Ecuador', + EG: 'Egypt', + SV: 'El Salvador', + GQ: 'Equatorial Guinea', + ER: 'Eritrea', + EE: 'Estonia', + ET: 'Ethiopia', + FK: 'Falkland Islands', + FO: 'Faroe Islands', + FJ: 'Fiji', + FI: 'Finland', + FR: 'France', + GF: 'French Guiana', + PF: 'French Polynesia', + TF: 'French Southern Territories', + GA: 'Gabon', + GM: 'Gambia', + GE: 'Georgia', + DE: 'Germany', + GH: 'Ghana', + GI: 'Gibraltar', + GR: 'Greece', + GL: 'Greenland', + GD: 'Grenada', + GP: 'Guadeloupe', + GU: 'Guam', + GT: 'Guatemala', + GG: 'Guernsey', + GN: 'Guinea', + GW: 'Guinea-Bissau', + GY: 'Guyana', + HT: 'Haiti', + HM: 'Heard Island and McDonald Islands', + HN: 'Honduras', + HK: 'Hong Kong', + HU: 'Hungary', + IS: 'Iceland', + IN: 'India', + ID: 'Indonesia', + IR: 'Iran', + IQ: 'Iraq', + IE: 'Ireland', + IM: 'Isle of Man', + IL: 'Israel', + IT: 'Italy', + CI: 'Ivory Coast', + JM: 'Jamaica', + JP: 'Japan', + JE: 'Jersey', + JO: 'Jordan', + KZ: 'Kazakhstan', + KE: 'Kenya', + KI: 'Kiribati', + XK: 'Kosovo', + KW: 'Kuwait', + KG: 'Kyrgyzstan', + LA: 'Laos', + LV: 'Latvia', + LB: 'Lebanon', + LS: 'Lesotho', + LR: 'Liberia', + LY: 'Libya', + LI: 'Liechtenstein', + LT: 'Lithuania', + LU: 'Luxembourg', + MO: 'Macao', + MK: 'Macedonia', + MG: 'Madagascar', + MW: 'Malawi', + MY: 'Malaysia', + MV: 'Maldives', + ML: 'Mali', + MT: 'Malta', + MH: 'Marshall Islands', + MQ: 'Martinique', + MR: 'Mauritania', + MU: 'Mauritius', + YT: 'Mayotte', + MX: 'Mexico', + FM: 'Micronesia', + MD: 'Moldova', + MC: 'Monaco', + MN: 'Mongolia', + ME: 'Montenegro', + MS: 'Montserrat', + MA: 'Morocco', + MZ: 'Mozambique', + MM: 'Myanmar', + NA: 'Namibia', + NR: 'Nauru', + NP: 'Nepal', + NL: 'Netherlands', + NC: 'New Caledonia', + NZ: 'New Zealand', + NI: 'Nicaragua', + NE: 'Niger', + NG: 'Nigeria', + NU: 'Niue', + NF: 'Norfolk Island', + KP: 'North Korea', + MP: 'Northern Mariana Islands', + NO: 'Norway', + OM: 'Oman', + PK: 'Pakistan', + PW: 'Palau', + PS: 'Palestinian Territory', + PA: 'Panama', + PG: 'Papua New Guinea', + PY: 'Paraguay', + PE: 'Peru', + PH: 'Philippines', + PN: 'Pitcairn', + PL: 'Poland', + PT: 'Portugal', + PR: 'Puerto Rico', + QA: 'Qatar', + CG: 'Republic of the Congo', + RE: 'Reunion', + RO: 'Romania', + RU: 'Russia', + RW: 'Rwanda', + BL: 'Saint Barthelemy', + SH: 'Saint Helena', + KN: 'Saint Kitts and Nevis', + LC: 'Saint Lucia', + MF: 'Saint Martin', + PM: 'Saint Pierre and Miquelon', + VC: 'Saint Vincent and the Grenadines', + WS: 'Samoa', + SM: 'San Marino', + ST: 'Sao Tome and Principe', + SA: 'Saudi Arabia', + SN: 'Senegal', + RS: 'Serbia', + SC: 'Seychelles', + SL: 'Sierra Leone', + SG: 'Singapore', + SX: 'Sint Maarten', + SK: 'Slovakia', + SI: 'Slovenia', + SB: 'Solomon Islands', + SO: 'Somalia', + ZA: 'South Africa', + GS: 'South Georgia and the South Sandwich Islands', + KR: 'South Korea', + SS: 'South Sudan', + ES: 'Spain', + LK: 'Sri Lanka', + SD: 'Sudan', + SR: 'Suriname', + SJ: 'Svalbard and Jan Mayen', + SZ: 'Swaziland', + SE: 'Sweden', + CH: 'Switzerland', + SY: 'Syria', + TW: 'Taiwan', + TJ: 'Tajikistan', + TZ: 'Tanzania', + TH: 'Thailand', + TG: 'Togo', + TK: 'Tokelau', + TO: 'Tonga', + TT: 'Trinidad and Tobago', + TN: 'Tunisia', + TR: 'Turkey', + TM: 'Turkmenistan', + TC: 'Turks and Caicos Islands', + TV: 'Tuvalu', + VI: 'U.S. Virgin Islands', + UG: 'Uganda', + UA: 'Ukraine', + AE: 'United Arab Emirates', + GB: 'United Kingdom', + UM: 'United States Minor Outlying Islands', + US: 'United States', + UY: 'Uruguay', + UZ: 'Uzbekistan', + VU: 'Vanuatu', + VA: 'Vatican', + VE: 'Venezuela', + VN: 'Vietnam', + WF: 'Wallis and Futuna', + EH: 'Western Sahara', + YE: 'Yemen', + ZM: 'Zambia', + ZW: 'Zimbabwe', +}; diff --git a/src/elements/ia-donation-form/form-elements/contact-form/ia-donation-contact-form.test.ts b/src/elements/ia-donation-form/form-elements/contact-form/ia-donation-contact-form.test.ts new file mode 100644 index 0000000..28b5056 --- /dev/null +++ b/src/elements/ia-donation-form/form-elements/contact-form/ia-donation-contact-form.test.ts @@ -0,0 +1,188 @@ +import { elementUpdated, fixture } from '@open-wc/testing-helpers'; +import { describe, expect, test } from 'vitest'; +import { html } from 'lit'; + +import { fillInContactForm } from '../../test-helpers/dom.test-helpers'; +import type { IADonationContactForm } from './ia-donation-contact-form'; +import './ia-donation-contact-form'; + +async function setup(): Promise { + return fixture( + html``, + ); +} + +describe('IADonationContactForm', () => { + test('renders into the light DOM so autofill can reach the fields', async () => { + const el = await setup(); + expect(el.shadowRoot).to.be.null; + expect(el.querySelector('#donation-contact-form-email')).to.exist; + }); + + test('requires email, first name and last name', async () => { + const el = await setup(); + + el.firstNameField.value = ' '; + el.lastNameField.value = ' '; + el.emailField.value = ''; + expect(el.firstNameField.checkValidity()).to.be.false; + expect(el.lastNameField.checkValidity()).to.be.false; + expect(el.emailField.checkValidity()).to.be.false; + + el.firstNameField.value = 'John'; + el.lastNameField.value = 'Doe'; + el.emailField.value = 'john.doe@example.com'; + expect(el.firstNameField.checkValidity()).to.be.true; + expect(el.lastNameField.checkValidity()).to.be.true; + expect(el.emailField.checkValidity()).to.be.true; + }); + + test('requires at least two characters in the names', async () => { + const el = await setup(); + el.firstNameField.value = 'A'; + el.lastNameField.value = 'B'; + expect(el.firstNameField.checkValidity()).to.be.false; + expect(el.lastNameField.checkValidity()).to.be.false; + }); + + test('requires a street address of a few characters', async () => { + const el = await setup(); + el.streetAddressField.value = '1 b'; + expect(el.streetAddressField.checkValidity()).to.be.false; + el.streetAddressField.value = '1 st'; + expect(el.streetAddressField.checkValidity()).to.be.true; + }); + + test('pre-fills the email from donorEmail', async () => { + const el = await fixture( + html``, + ); + expect(el.emailField.value).to.equal('someone@archive.org'); + }); + + describe('region and postal code', () => { + test('are required for a US address', async () => { + const el = await setup(); + el.selectedCountry = 'US'; + await elementUpdated(el); + expect(el.regionField.required).to.be.true; + expect(el.postalCodeField.required).to.be.true; + }); + + test('are optional outside the US', async () => { + const el = await setup(); + el.selectedCountry = 'CA'; + await elementUpdated(el); + expect(el.regionField.required).to.be.false; + expect(el.postalCodeField.required).to.be.false; + }); + + test('only checks the zip code pattern for US addresses', async () => { + const el = await setup(); + + el.selectedCountry = 'US'; + await elementUpdated(el); + el.postalCodeField.value = '1234'; + expect(el.postalCodeField.checkValidity()).to.be.false; + + el.selectedCountry = 'CA'; + await elementUpdated(el); + el.postalCodeField.value = '1234'; + expect(el.postalCodeField.checkValidity()).to.be.true; + }); + + test('accepts 5 and 9 digit US zip codes and nothing else', async () => { + const el = await setup(); + el.selectedCountry = 'US'; + await elementUpdated(el); + + for (const code of ['12345', '12345-6789', '123456789']) { + el.postalCodeField.value = code; + expect(el.postalCodeField.checkValidity(), `${code} should be valid`).to + .be.true; + } + for (const code of ['1234', '123456', '1234A', 'ABCDE', '12345-678']) { + el.postalCodeField.value = code; + expect(el.postalCodeField.checkValidity(), `${code} should be invalid`) + .to.be.false; + } + }); + }); + + describe('country selector', () => { + test('defaults to the US', async () => { + const el = await setup(); + expect(el.selectedCountry).to.equal('US'); + expect(el.countryCodeAlpha2Field.value).to.equal('US'); + }); + + test('follows the dropdown', async () => { + const el = await setup(); + el.countryCodeAlpha2Field.value = 'CA'; + el.countryCodeAlpha2Field.dispatchEvent(new Event('change')); + await elementUpdated(el); + expect(el.selectedCountry).to.equal('CA'); + }); + + test('ignores a code that is not a country', async () => { + const el = await setup(); + el.countryCodeAlpha2Field.value = 'XX'; + el.countryCodeAlpha2Field.dispatchEvent(new Event('change')); + await elementUpdated(el); + expect(el.selectedCountry).to.equal('US'); + }); + }); + + describe('reportValidity()', () => { + test('is false with required fields empty, marking them and focusing the first', async () => { + const el = await setup(); + + expect(el.reportValidity()).to.be.false; + expect(el.emailField.validationMessage).to.not.equal(''); + expect(el.firstNameBadgedInput.error).to.be.true; + expect(el.lastNameBadgedInput.error).to.be.true; + expect(el.extendedAddressBadgedInput.error).to.be.false; + // The browser focuses the first invalid field, and focusing a field + // clears its error state, so the email field is already clear again + expect(document.activeElement).to.equal(el.emailField); + expect(el.emailBadgedInput.error).to.be.false; + }); + + test('is true once every required field is filled in', async () => { + const el = await setup(); + fillInContactForm(el); + + expect(el.reportValidity()).to.be.true; + expect(el.emailBadgedInput.error).to.be.false; + expect(el.errorMessage.textContent).to.equal(''); + }); + + test('clears a field error when the donor returns to it', async () => { + const el = await setup(); + el.reportValidity(); + expect(el.firstNameBadgedInput.error).to.be.true; + + el.firstNameField.focus(); + expect(el.firstNameBadgedInput.error).to.be.false; + expect(el.errorMessage.textContent).to.equal(''); + }); + }); + + test('packages the fields as donor contact info', async () => { + const el = await setup(); + fillInContactForm(el); + + const info = el.donorContactInfo; + expect(info.customer.email).to.equal('foo@bar.com'); + expect(info.customer.firstName).to.equal('Fooey'); + expect(info.customer.lastName).to.equal('McBarrison'); + expect(info.billing.streetAddress).to.equal('123 Fake St'); + expect(info.billing.extendedAddress).to.equal('Apt 123'); + expect(info.billing.locality).to.equal('San Francisco'); + expect(info.billing.region).to.equal('CA'); + expect(info.billing.postalCode).to.equal('12345'); + expect(info.billing.countryCodeAlpha2).to.equal('US'); + }); +}); diff --git a/src/elements/ia-donation-form/form-elements/contact-form/ia-donation-contact-form.ts b/src/elements/ia-donation-form/form-elements/contact-form/ia-donation-contact-form.ts new file mode 100644 index 0000000..32248fd --- /dev/null +++ b/src/elements/ia-donation-form/form-elements/contact-form/ia-donation-contact-form.ts @@ -0,0 +1,518 @@ +import { + css, + html, + LitElement, + type PropertyValues, + type TemplateResult, +} from 'lit'; +import { customElement, property, query } from 'lit/decorators.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { msg } from '@lit/localize'; + +import { emailIcon, localePinIcon, userIcon } from '../../icons'; +import { + BillingInfo, + CustomerInfo, + DonorContactInfo, +} from '../../models/donor-contact-info'; +import { + SpacerOption, + type IADonationBadgedInput, +} from '../ia-donation-badged-input'; +import '../ia-donation-badged-input'; +import type { AutoCompleteFieldOptions } from './autocomplete-field-options'; +import { countries } from './countries'; + +/** + * The donor's name, email and billing address. + * + * This renders into the light DOM, not a shadow root: browser autofill + * doesn't reach into shadow DOM, and autofill matters a lot on a donation + * form. So its styles are a ` + `; + } +} diff --git a/src/elements/ia-donation-form/form-elements/ia-donation-badged-input.test.ts b/src/elements/ia-donation-form/form-elements/ia-donation-badged-input.test.ts new file mode 100644 index 0000000..bb9dca0 --- /dev/null +++ b/src/elements/ia-donation-form/form-elements/ia-donation-badged-input.test.ts @@ -0,0 +1,69 @@ +import { fixture } from '@open-wc/testing-helpers'; +import { describe, expect, test } from 'vitest'; +import { html } from 'lit'; + +import { emailIcon } from '../icons'; +import { + SpacerOption, + type IADonationBadgedInput, +} from './ia-donation-badged-input'; +import './ia-donation-badged-input'; + +function wrapper(el: IADonationBadgedInput): HTMLDivElement { + return el.shadowRoot!.querySelector('.input-wrapper')!; +} + +describe('IADonationBadgedInput', () => { + test('slots the field and shows the icon', async () => { + const el = await fixture( + html` + + `, + ); + + const slot = el.shadowRoot!.querySelector('slot')!; + expect(slot.assignedElements()[0].id).to.equal('field'); + expect(el.shadowRoot!.querySelector('.icon-container .ia-icon')).to.exist; + }); + + test('marks a required field with an asterisk', async () => { + const el = await fixture( + html``, + ); + const indicator = el.shadowRoot!.querySelector('.required-indicator')!; + expect(indicator.textContent?.trim()).to.equal('*'); + + el.required = false; + await el.updateComplete; + expect(indicator.textContent?.trim()).to.equal(''); + }); + + test('toggles the error class', async () => { + const el = await fixture( + html``, + ); + expect(wrapper(el).classList.contains('error')).to.be.false; + + el.error = true; + await el.updateComplete; + expect(wrapper(el).classList.contains('error')).to.be.true; + }); + + test('can collapse the icon column when there is no icon', async () => { + const el = await fixture( + html``, + ); + expect(wrapper(el).classList.contains('compress-space')).to.be.true; + + const iconContainer = + el.shadowRoot!.querySelector('.icon-container')!; + // 10px base: the collapsed column is one base unit, the full one is three + expect(getComputedStyle(iconContainer).width).to.equal('10px'); + + el.iconSpaceOption = SpacerOption.LeaveSpace; + await el.updateComplete; + expect(getComputedStyle(iconContainer).width).to.equal('30px'); + }); +}); diff --git a/src/elements/ia-donation-form/form-elements/ia-donation-badged-input.ts b/src/elements/ia-donation-form/form-elements/ia-donation-badged-input.ts new file mode 100644 index 0000000..56c1da3 --- /dev/null +++ b/src/elements/ia-donation-form/form-elements/ia-donation-badged-input.ts @@ -0,0 +1,162 @@ +import { + css, + html, + LitElement, + nothing, + type CSSResultGroup, + type TemplateResult, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +import themeStyles from '@src/themes/theme-styles'; + +/** Whether a hidden icon or required marker keeps its space, so neighbouring fields still line up. */ +export const SpacerOption = { + LeaveSpace: 'leave-space', + CompressSpace: 'compress-space', +} as const; + +export type SpacerOption = (typeof SpacerOption)[keyof typeof SpacerOption]; + +/** + * A bordered field wrapper with an icon on the left and a required marker, + * which turns red on error. The field itself is slotted in, so it works for + * plain inputs, selects and Braintree's hosted field containers alike. + */ +@customElement('ia-donation-badged-input') +export class IADonationBadgedInput extends LitElement { + @property({ type: Boolean }) error = false; + + @property({ type: Object }) icon?: TemplateResult; + + @property({ type: Boolean }) required = false; + + /** When there's no icon, whether its column stays or collapses */ + @property({ type: String }) iconSpaceOption: SpacerOption = + SpacerOption.LeaveSpace; + + /** When the field isn't required, whether the marker's space stays or collapses */ + @property({ type: String }) requiredIndicatorSpaceOption: SpacerOption = + SpacerOption.LeaveSpace; + + render(): TemplateResult { + return html` +
+
${this.icon}
+
+ ${this.required ? html`*` : nothing} +
+ + +
+ `; + } + + private get errorClass(): string { + return this.error ? 'error' : ''; + } + + private get iconSpaceOptionClass(): string { + return this.iconSpaceOption === SpacerOption.CompressSpace + ? 'compress-space' + : ''; + } + + static get styles(): CSSResultGroup { + return [ + themeStyles, + css` + :host { + --donation-badged-input-base-font-size--: var( + --ia-donation-form-base-font-size, + 10px + ); + --donation-badged-input-border--: var( + --ia-donation-form-input-border, + 1px solid #d9d9d9 + ); + --donation-badged-input-error-color--: var( + --ia-donation-badged-input-error-color, + var(--color-danger) + ); + --donation-badged-input-icon-size--: var( + --ia-donation-badged-input-icon-size, + calc(var(--donation-badged-input-base-font-size--) * 1.4) + ); + --donation-badged-input-icon-color--: var( + --ia-donation-badged-input-icon-color, + currentColor + ); + --donation-badged-input-icon-spacer-width--: var( + --ia-donation-badged-input-icon-spacer-width, + calc(var(--donation-badged-input-base-font-size--) * 3) + ); + --donation-badged-input-no-icon-spacer-width--: var( + --ia-donation-badged-input-no-icon-spacer-width, + var(--donation-badged-input-base-font-size--) + ); + --donation-badged-input-height--: var( + --ia-donation-badged-input-height, + calc(var(--donation-badged-input-base-font-size--) * 3) + ); + --donation-badged-input-required-color--: var( + --ia-donation-badged-input-required-color, + var(--color-danger) + ); + --donation-badged-input-required-margin--: var( + --ia-donation-badged-input-required-margin, + 0 0.25rem 0 0 + ); + --donation-badged-input-required-font-size--: var( + --ia-donation-badged-input-required-font-size, + calc(var(--donation-badged-input-base-font-size--) * 2) + ); + } + + .input-wrapper { + border: var(--donation-badged-input-border--); + height: var(--donation-badged-input-height--); + display: flex; + align-items: center; + } + + .input-wrapper.error { + box-shadow: inset 0px 0px 0px 1px + var(--donation-badged-input-error-color--); + border-color: var(--donation-badged-input-error-color--); + } + + .input-wrapper.compress-space .icon-container { + width: var(--donation-badged-input-no-icon-spacer-width--); + } + + .icon-container { + width: var(--donation-badged-input-icon-spacer-width--); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + /* Masked glyphs take their shape from the mask and their colour from here */ + .icon-container .ia-icon { + width: var(--donation-badged-input-icon-size--); + height: var(--donation-badged-input-icon-size--); + background-color: var(--donation-badged-input-icon-color--); + } + + .required-indicator { + color: var(--donation-badged-input-required-color--); + font-size: var(--donation-badged-input-required-font-size--); + margin: var(--donation-badged-input-required-margin--); + } + + .required-indicator.leave-space { + width: 0.5em; + } + `, + ]; + } +} diff --git a/src/elements/ia-donation-form/form-elements/ia-donation-header.test.ts b/src/elements/ia-donation-form/form-elements/ia-donation-header.test.ts new file mode 100644 index 0000000..337fe8a --- /dev/null +++ b/src/elements/ia-donation-form/form-elements/ia-donation-header.test.ts @@ -0,0 +1,95 @@ +import { elementUpdated, fixture } from '@open-wc/testing-helpers'; +import { describe, expect, test } from 'vitest'; +import { html } from 'lit'; + +import { DonationPaymentInfo } from '../models/donation-payment-info'; +import { DonationType } from '../models/donation-type'; +import { oneEvent } from '../test-helpers/dom.test-helpers'; +import type { IADonationEditDonation } from './ia-donation-edit-donation'; +import { + DonationHeaderMode, + type IADonationHeader, +} from './ia-donation-header'; +import type { IADonationSummary } from './ia-donation-summary'; +import './ia-donation-header'; + +function fiveDollars(): DonationPaymentInfo { + return new DonationPaymentInfo({ + amount: 5, + donationType: DonationType.OneTime, + coverFees: false, + }); +} + +describe('IADonationHeader', () => { + test('shows the amount picker by default', async () => { + const el = await fixture( + html``, + ); + + expect(el.shadowRoot!.querySelector('ia-donation-edit-donation')).to.exist; + expect(el.shadowRoot!.querySelector('ia-donation-summary')).to.be.null; + }); + + test('shows the summary in summary mode, and edit goes back to the picker', async () => { + const el = await fixture( + html``, + ); + + const summary = el.shadowRoot!.querySelector( + 'ia-donation-summary', + )!; + expect(summary).to.exist; + expect(summary.displayTitle).to.equal('$5 Donation'); + + summary.shadowRoot!.querySelector('button')!.click(); + await elementUpdated(el); + expect(el.mode).to.equal(DonationHeaderMode.Edit); + expect(el.shadowRoot!.querySelector('ia-donation-edit-donation')).to.exist; + }); + + test('relays donationInfoChanged from the picker and keeps the new info', async () => { + const el = await fixture( + html``, + ); + const picker = el.shadowRoot!.querySelector( + 'ia-donation-edit-donation', + )!; + + const changed = oneEvent(el, 'donationInfoChanged'); + picker + .shadowRoot!.querySelector('#amount-25-option')! + .click(); + const { donationInfo } = (await changed).detail; + + expect(donationInfo.amount).to.equal(25); + expect(el.donationInfo?.amount).to.equal(25); + }); + + test('relays editDonationError from the picker', async () => { + const el = await fixture( + html``, + ); + const picker = el.shadowRoot!.querySelector( + 'ia-donation-edit-donation', + )!; + + const errored = oneEvent(el, 'editDonationError'); + const input = picker.shadowRoot!.querySelector( + '#custom-amount-input', + )!; + input.value = '0.5'; + input.dispatchEvent(new Event('input')); + + expect((await errored).detail.error).to.equal('donation_too_low'); + }); +}); diff --git a/src/elements/ia-donation-form/form-elements/ia-donation-header.ts b/src/elements/ia-donation-form/form-elements/ia-donation-header.ts new file mode 100644 index 0000000..ea6c996 --- /dev/null +++ b/src/elements/ia-donation-form/form-elements/ia-donation-header.ts @@ -0,0 +1,102 @@ +import { html, LitElement, type TemplateResult } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +import { + defaultDonationAmounts, + type DonationPaymentInfo, +} from '../models/donation-payment-info'; +import { + EditDonationAmountSelectionLayout, + EditDonationFrequencySelectionMode, +} from './ia-donation-edit-donation'; +import './ia-donation-edit-donation'; +import './ia-donation-summary'; + +/** Whether the top of the form shows the editable amount picker or the collapsed summary. */ +export const DonationHeaderMode = { + Summary: 'summary', + Edit: 'edit', +} as const; + +export type DonationHeaderMode = + (typeof DonationHeaderMode)[keyof typeof DonationHeaderMode]; + +/** + * The top of the donation form: the frequency and amount picker, or a + * one-line summary of the choice with an edit button. Relays the picker's + * events upward. + */ +@customElement('ia-donation-header') +export class IADonationHeader extends LitElement { + @property({ type: Object }) donationInfo?: DonationPaymentInfo; + + @property({ type: String }) mode: DonationHeaderMode = + DonationHeaderMode.Edit; + + @property({ type: Array }) amountOptions: number[] = defaultDonationAmounts; + + @property({ type: String }) + amountSelectionLayout: EditDonationAmountSelectionLayout = + EditDonationAmountSelectionLayout.MultiLine; + + @property({ type: String }) + frequencySelectionMode: EditDonationFrequencySelectionMode = + EditDonationFrequencySelectionMode.Button; + + render(): TemplateResult { + switch (this.mode) { + case DonationHeaderMode.Summary: + return this.donationSummaryTemplate; + case DonationHeaderMode.Edit: + return this.editDonationTemplate; + } + } + + private get editDonationTemplate(): TemplateResult { + return html` + + + `; + } + + private get donationSummaryTemplate(): TemplateResult { + return html` + + + `; + } + + private donationInfoChanged(e: CustomEvent): void { + this.donationInfo = e.detail.donationInfo as DonationPaymentInfo; + this.dispatchEvent( + new CustomEvent('donationInfoChanged', { + detail: { donationInfo: this.donationInfo }, + }), + ); + } + + private editDonationError(e: CustomEvent): void { + this.dispatchEvent( + new CustomEvent('editDonationError', { detail: e.detail }), + ); + } + + private summaryEditClicked(): void { + this.mode = DonationHeaderMode.Edit; + } + + private showSummaryClicked(): void { + this.mode = DonationHeaderMode.Summary; + } +} diff --git a/src/elements/ia-donation-form/form-elements/ia-donation-payment-selector.test.ts b/src/elements/ia-donation-form/form-elements/ia-donation-payment-selector.test.ts new file mode 100644 index 0000000..f6c83a2 --- /dev/null +++ b/src/elements/ia-donation-form/form-elements/ia-donation-payment-selector.test.ts @@ -0,0 +1,156 @@ +import { elementUpdated, fixture } from '@open-wc/testing-helpers'; +import { describe, expect, test } from 'vitest'; +import { html } from 'lit'; + +import { MockPaymentProviders } from '../braintree/test-helpers/mock-managers.test-helpers'; +import { oneEvent, promisedSleep } from '../test-helpers/dom.test-helpers'; +import type { IADonationPaymentSelector } from './ia-donation-payment-selector'; +import './ia-donation-payment-selector'; + +function button(el: IADonationPaymentSelector, selector: string): HTMLElement { + return el.shadowRoot!.querySelector(selector)!; +} + +describe('IADonationPaymentSelector', () => { + test('starts every provider button in the loading state', async () => { + const el = await fixture( + html``, + ); + + for (const name of [ + '.applepay', + '.googlepay', + '.venmo', + '.paypal-container', + ]) { + expect( + button(el, name).classList.contains('loading'), + `${name} should be loading`, + ).to.be.true; + } + }); + + test('shows the providers the handlers say are available', async () => { + const el = await fixture( + html``, + ); + + el.paymentProviders = new MockPaymentProviders(); + await elementUpdated(el); + await promisedSleep(50); + + expect(button(el, '.venmo').classList.contains('available')).to.be.true; + expect(button(el, '.applepay').classList.contains('available')).to.be.true; + expect(button(el, '.googlepay').classList.contains('available')).to.be.true; + }); + + test('can show the PayPal button when told to', async () => { + const el = await fixture( + html``, + ); + + el.showPaypalButton(); + await elementUpdated(el); + + expect(button(el, '.paypal-container').classList.contains('available')).to + .be.true; + }); + + test('emits paypalBlockerSelected when the local PayPal button is pressed', async () => { + const el = await fixture( + html``, + ); + + const selected = oneEvent(el, 'paypalBlockerSelected'); + button(el, '.paypal-local-button').click(); + expect(await selected).to.exist; + }); + + test('emits applePaySelected with the click event', async () => { + const el = await fixture( + html``, + ); + + const selected = oneEvent(el, 'applePaySelected'); + const clickEvent = new MouseEvent('click'); + button(el, '.applepay').dispatchEvent(clickEvent); + expect((await selected).detail.originalEvent).to.equal(clickEvent); + }); + + test.each([ + ['.googlepay', 'googlePaySelected'], + ['.venmo', 'venmoSelected'], + ['.credit-card-button', 'creditCardSelected'], + ])('emits %s -> %s', async (selector, eventName) => { + const el = await fixture( + html``, + ); + + const selected = oneEvent(el, eventName); + button(el, selector).click(); + expect(await selected).to.exist; + }); + + test('collapses the other buttons once one is picked, and a change button restores them', async () => { + const el = await fixture( + html``, + ); + expect(el.shadowRoot!.querySelector('#change-payment-method')).to.be.null; + + button(el, '.credit-card-button').click(); + await elementUpdated(el); + + const container = el.shadowRoot!.querySelector('.payment-container')!; + expect(container.classList.contains('payment-selected')).to.be.true; + expect(button(el, '.credit-card-button').classList.contains('selected')).to + .be.true; + expect(getComputedStyle(button(el, '.venmo')).display).to.equal('none'); + + const reset = oneEvent(el, 'resetPaymentMethod'); + button(el, '#change-payment-method').click(); + await reset; + await elementUpdated(el); + expect(container.classList.contains('payment-selected')).to.be.false; + expect(el.shadowRoot!.querySelector('#change-payment-method')).to.be.null; + }); + + test('keeps the PayPal cover in place after it is pressed with an invalid amount', async () => { + const el = await fixture( + html``, + ); + el.showPaypalButton(); + await elementUpdated(el); + + const selected = oneEvent(el, 'paypalBlockerSelected'); + button(el, '.paypal-local-button').click(); + await selected; + await elementUpdated(el); + + // The cover sits over PayPal's own button so a bad amount never reaches it + expect( + getComputedStyle(button(el, '.paypal-local-button')).display, + ).to.not.equal('none'); + expect( + getComputedStyle(button(el, '.paypal-container')).display, + ).to.not.equal('none'); + expect(getComputedStyle(button(el, '.venmo')).display).to.equal('none'); + }); + + test('labels the brand buttons for screen readers', async () => { + const el = await fixture( + html``, + ); + + expect(button(el, '.applepay').getAttribute('aria-label')).to.equal( + 'Pay with Apple Pay', + ); + expect( + button(el, '.paypal-local-button').getAttribute('aria-label'), + ).to.equal('Pay with PayPal'); + expect(button(el, '.applepay img').getAttribute('alt')).to.equal( + 'Apple Pay', + ); + }); +}); diff --git a/src/elements/ia-donation-form/form-elements/ia-donation-payment-selector.ts b/src/elements/ia-donation-form/form-elements/ia-donation-payment-selector.ts new file mode 100644 index 0000000..33d283d --- /dev/null +++ b/src/elements/ia-donation-form/form-elements/ia-donation-payment-selector.ts @@ -0,0 +1,388 @@ +import { + css, + html, + LitElement, + nothing, + type CSSResultGroup, + type PropertyValues, + type TemplateResult, +} from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { msg } from '@lit/localize'; + +import themeStyles from '@src/themes/theme-styles'; +import type { PaymentProvidersInterface } from '../braintree/payment-providers-interface'; +import { applePayLogo, googlePayLogo, paypalLogo, venmoLogo } from '../icons'; + +/** A provider button starts hidden-but-reserved, then shows or disappears once its availability is known. */ +const PaymentButtonMode = { + Loading: 'loading', + Available: 'available', + Unavailable: 'unavailable', +} as const; + +type PaymentButtonMode = + (typeof PaymentButtonMode)[keyof typeof PaymentButtonMode]; + +type PaymentMode = 'apple' | 'google' | 'venmo' | 'cc' | 'paypal'; + +/** + * The row of payment method buttons. Each provider button asks its handler + * whether the browser supports it and hides itself if not. Once one is picked + * the others collapse behind a "Change payment method" button. + * + * The real PayPal button is slotted in from the light DOM (it can't live in + * a shadow root); the local PayPal button sits over it to catch clicks while + * the donation amount is invalid. + */ +@customElement('ia-donation-payment-selector') +export class IADonationPaymentSelector extends LitElement { + @property({ type: Boolean }) donationInfoValid = true; + + @property({ type: Object }) paymentProviders?: PaymentProvidersInterface; + + @state() private applePayMode: PaymentButtonMode = PaymentButtonMode.Loading; + + @state() private googlePayMode: PaymentButtonMode = PaymentButtonMode.Loading; + + @state() private venmoMode: PaymentButtonMode = PaymentButtonMode.Loading; + + @state() private payPalMode: PaymentButtonMode = PaymentButtonMode.Loading; + + @state() private paymentModeSelected?: PaymentMode; + + render(): TemplateResult { + const paymentSelectedCss = this.paymentModeSelected + ? 'payment-selected' + : ''; + return html` +
+
+ + + + + + +
+
+ + +
+
+
+ +
+ +
+
+ + ${this.paymentModeSelected + ? html` + + ` + : nothing} + `; + } + + firstUpdated(): void { + this.dispatchEvent(new Event('firstUpdated')); + } + + updated(changed: PropertyValues): void { + if (changed.has('paymentProviders')) { + this.setButtonVisibility(); + } + } + + showPaypalButton(): void { + this.payPalMode = PaymentButtonMode.Available; + } + + /** Asks each provider whether this browser can use it, and shows or hides its button. */ + private async setButtonVisibility(): Promise { + const providers = this.paymentProviders; + if (!providers) return; + + const mode = (supported: boolean): PaymentButtonMode => + supported ? PaymentButtonMode.Available : PaymentButtonMode.Unavailable; + + providers.venmoHandler + .get() + .then(async (handler) => { + if (!handler) return false; + return handler.isBrowserSupported(); + }) + .then((supported) => { + this.venmoMode = mode(supported); + }) + .catch((reason) => { + console.error('venmo unavailable', reason); + this.venmoMode = PaymentButtonMode.Unavailable; + }); + + providers.applePayHandler + .get() + .then((handler) => handler.isAvailable()) + .then((supported) => { + this.applePayMode = mode(supported); + }) + .catch((reason) => { + console.error('apple pay unavailable', reason); + this.applePayMode = PaymentButtonMode.Unavailable; + }); + + providers.googlePayHandler + .get() + .then((handler) => handler.isBrowserSupported()) + .then((supported) => { + this.googlePayMode = mode(supported); + }) + .catch((reason) => { + console.error('google pay unavailable', reason); + this.googlePayMode = PaymentButtonMode.Unavailable; + }); + } + + private googlePaySelected(): void { + this.dispatchEvent(new Event('googlePaySelected')); + } + + private applePaySelected(e: Event): void { + this.dispatchEvent( + new CustomEvent('applePaySelected', { detail: { originalEvent: e } }), + ); + } + + private venmoSelected(): void { + this.dispatchEvent(new Event('venmoSelected')); + } + + private creditCardSelected(): void { + this.dispatchEvent(new Event('creditCardSelected')); + } + + private localPaypalButtonClicked(): void { + this.dispatchEvent(new Event('paypalBlockerSelected')); + } + + static get styles(): CSSResultGroup { + return [ + themeStyles, + css` + :host { + --donation-selector-base-font-size--: var( + --ia-donation-form-base-font-size, + 10px + ); + --donation-selector-button-width--: var( + --ia-donation-payment-button-width, + calc(var(--donation-selector-base-font-size--) * 5) + ); + --donation-selector-button-height--: var( + --ia-donation-payment-button-height, + calc(var(--donation-selector-base-font-size--) * 3.2) + ); + --donation-selector-cc-font-size--: var( + --ia-donation-credit-card-font-size, + calc(var(--donation-selector-base-font-size--) * 1.8) + ); + --donation-selector-cc-font-color--: var( + --ia-donation-credit-card-button-font-color, + var(--mid-gray) + ); + --donation-selector-cc-color--: var( + --ia-donation-credit-card-button-color, + var(--true-white) + ); + } + + button { + color: inherit; + font-family: inherit; + } + + .payment-container { + width: 100%; + } + + .payment-provider-container { + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr; + grid-gap: var(--donation-selector-base-font-size--); + margin-bottom: var(--donation-selector-base-font-size--); + max-width: calc(var(--donation-selector-base-font-size--) * 23); + } + + .provider-button { + border: 0; + padding: 0; + background: none; + cursor: pointer; + width: var(--donation-selector-button-width--); + height: var(--donation-selector-button-height--); + } + + .provider-button.unavailable { + display: none; + } + + .provider-button.loading { + border: 1px solid #ddd; + border-radius: 2px; + /* Accounts for the border that goes away once the provider loads, so the layout doesn't shift */ + margin-bottom: -2px; + } + + .provider-button.loading .payment-image { + display: none; + } + + .payment-image, + .brand-logo { + width: 100%; + height: 100%; + } + + .brand-logo { + display: block; + object-fit: contain; + } + + .paypal-local-button { + position: absolute; + border: 0; + padding: 0; + background: none; + cursor: pointer; + width: var(--donation-selector-button-width--); + height: var(--donation-selector-button-height--); + } + + .donation-info-valid .paypal-local-button { + z-index: 0; + } + + .donation-info-invalid .paypal-local-button { + z-index: 250; + } + + .credit-card-button { + color: var(--donation-selector-cc-font-color--); + background-color: var(--donation-selector-cc-color--); + border: 1px solid var(--mid-gray); + border-radius: 4px; + cursor: pointer; + margin: 0; + padding: calc(var(--donation-selector-base-font-size--) * 0.7) + var(--donation-selector-base-font-size--); + width: 100%; + } + + .credit-card-button .cc-background { + height: calc(var(--donation-selector-base-font-size--) * 2.4); + width: 100%; + background-repeat: no-repeat; + background-image: url(https://archive.org/images/cc_logos.png); + background-position: 50% 50%; + background-size: contain; + } + + .credit-card-button .cc-title { + font-size: var(--donation-selector-cc-font-size--); + font-weight: 700; + margin-bottom: var(--padding-sm); + } + + button#change-payment-method { + margin-top: 10px; + background: var(--true-white); + border: 1px solid; + border-radius: 3px; + padding: 5px; + cursor: pointer; + } + + .payment-selected .provider-button:not(.selected), + .payment-selected .credit-card-button:not(.selected) { + display: none; + } + `, + ]; + } +} diff --git a/src/elements/ia-donation-form/form-elements/ia-donation-summary.test.ts b/src/elements/ia-donation-form/form-elements/ia-donation-summary.test.ts new file mode 100644 index 0000000..0fc3fb1 --- /dev/null +++ b/src/elements/ia-donation-form/form-elements/ia-donation-summary.test.ts @@ -0,0 +1,70 @@ +import { fixture } from '@open-wc/testing-helpers'; +import { describe, expect, test } from 'vitest'; +import { html } from 'lit'; + +import { DonationPaymentInfo } from '../models/donation-payment-info'; +import { DonationType } from '../models/donation-type'; +import { oneEvent } from '../test-helpers/dom.test-helpers'; +import type { IADonationSummary } from './ia-donation-summary'; +import './ia-donation-summary'; + +function title(el: IADonationSummary): string { + return el.shadowRoot!.querySelector('h1')!.textContent!.trim(); +} + +describe('IADonationSummary', () => { + test('is empty without donation info', async () => { + const el = await fixture( + html``, + ); + expect(title(el)).to.equal(''); + }); + + test('titles a one-time donation', async () => { + const el = await fixture( + html``, + ); + expect(title(el)).to.equal('$3.50 Donation'); + }); + + test('titles a monthly donation', async () => { + const el = await fixture( + html``, + ); + expect(title(el)).to.equal('$7.50 Monthly Donation'); + }); + + test('drops the cents for a whole-dollar amount', async () => { + const el = await fixture( + html``, + ); + expect(title(el)).to.equal('$25 Donation'); + }); + + test('emits editClicked from its button', async () => { + const el = await fixture( + html``, + ); + const clicked = oneEvent(el, 'editClicked'); + el.shadowRoot!.querySelector('button')!.click(); + expect(await clicked).to.exist; + }); +}); diff --git a/src/elements/ia-donation-form/form-elements/ia-donation-summary.ts b/src/elements/ia-donation-form/form-elements/ia-donation-summary.ts new file mode 100644 index 0000000..66c6347 --- /dev/null +++ b/src/elements/ia-donation-form/form-elements/ia-donation-summary.ts @@ -0,0 +1,66 @@ +import { + css, + html, + LitElement, + type CSSResultGroup, + type TemplateResult, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { msg, str } from '@lit/localize'; +import currency from 'currency.js'; + +import themeStyles from '@src/themes/theme-styles'; +import type { DonationPaymentInfo } from '../models/donation-payment-info'; +import { DonationType } from '../models/donation-type'; + +/** The collapsed view of the chosen donation, with a button to go back and edit it. */ +@customElement('ia-donation-summary') +export class IADonationSummary extends LitElement { + @property({ type: Object }) donationInfo?: DonationPaymentInfo; + + render(): TemplateResult { + return html` +

${this.displayTitle}

+ + `; + } + + get displayTitle(): string { + if (!this.donationInfo) { + return ''; + } + + const amount = this.donationInfo.amount; + // Whole dollars don't need the cents + const precision = amount === Math.round(amount) ? 0 : 2; + const displayAmount = currency(amount, { symbol: '$', precision }).format(); + + return this.donationInfo.donationType === DonationType.Monthly + ? msg(str`${displayAmount} Monthly Donation`) + : msg(str`${displayAmount} Donation`); + } + + private editClicked(): void { + this.dispatchEvent(new Event('editClicked')); + } + + static get styles(): CSSResultGroup { + return [ + themeStyles, + css` + :host { + display: flex; + justify-content: center; + align-content: center; + } + + button { + border: 0; + background: none; + color: var(--link-color); + cursor: pointer; + } + `, + ]; + } +} diff --git a/src/elements/ia-donation-form/form-elements/ia-donation-total-amount.test.ts b/src/elements/ia-donation-form/form-elements/ia-donation-total-amount.test.ts new file mode 100644 index 0000000..dbc445d --- /dev/null +++ b/src/elements/ia-donation-form/form-elements/ia-donation-total-amount.test.ts @@ -0,0 +1,47 @@ +import { fixture } from '@open-wc/testing-helpers'; +import { describe, expect, test } from 'vitest'; +import { html } from 'lit'; + +import { DonationPaymentInfo } from '../models/donation-payment-info'; +import { DonationType } from '../models/donation-type'; +import type { IADonationTotalAmount } from './ia-donation-total-amount'; +import './ia-donation-total-amount'; + +function total(el: IADonationTotalAmount): string { + return el.shadowRoot!.querySelector('.total-line')!.textContent!.trim(); +} + +describe('IADonationTotalAmount', () => { + test('is empty without donation info', async () => { + const el = await fixture( + html``, + ); + expect(total(el)).to.equal(''); + }); + + test('shows the total including a covered fee', async () => { + const el = await fixture( + html``, + ); + expect(total(el)).to.equal('Total: $5.40'); + }); + + test('appends /month for a monthly donation', async () => { + const el = await fixture( + html``, + ); + expect(total(el)).to.equal('Total: $10.00/month'); + }); +}); diff --git a/src/elements/ia-donation-form/form-elements/ia-donation-total-amount.ts b/src/elements/ia-donation-form/form-elements/ia-donation-total-amount.ts new file mode 100644 index 0000000..cf0322f --- /dev/null +++ b/src/elements/ia-donation-form/form-elements/ia-donation-total-amount.ts @@ -0,0 +1,78 @@ +import { + css, + html, + LitElement, + type CSSResultGroup, + type TemplateResult, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { msg, str } from '@lit/localize'; +import currency from 'currency.js'; + +import themeStyles from '@src/themes/theme-styles'; +import type { DonationPaymentInfo } from '../models/donation-payment-info'; +import { DonationType } from '../models/donation-type'; + +/** The "Total: $X" line under the amount picker, with "/month" for monthly gifts. */ +@customElement('ia-donation-total-amount') +export class IADonationTotalAmount extends LitElement { + @property({ type: Object }) donationInfo?: DonationPaymentInfo; + + render(): TemplateResult { + return html` +
+
${this.totalLine}
+ `; + } + + private get totalLine(): string { + if (!this.donationInfo) return ''; + const amount = currency(this.donationInfo.total, { symbol: '$' }).format(); + return this.donationInfo.donationType === DonationType.Monthly + ? msg(str`Total: ${amount}/month`) + : msg(str`Total: ${amount}`); + } + + static get styles(): CSSResultGroup { + return [ + themeStyles, + css` + :host { + --donation-total-base-font-size--: var( + --ia-donation-form-base-font-size, + 10px + ); + --donation-total-line-color--: var( + --ia-donation-total-amount-line-color, + var(--mid-gray) + ); + --donation-total-line-thickness--: var( + --ia-donation-total-amount-line-thickness, + 2px + ); + --donation-total-vertical-spacing--: var( + --ia-donation-total-amount-vertical-spacing, + var(--padding-sm) + ); + --donation-total-font-size--: var( + --ia-donation-total-amount-font-size, + calc(var(--donation-total-base-font-size--) * 2.6) + ); + } + + .top-line { + width: 100%; + height: var(--donation-total-line-thickness--); + background-color: var(--donation-total-line-color--); + } + + .total-line { + font-size: var(--donation-total-font-size--); + font-weight: bold; + text-align: center; + margin-top: var(--donation-total-vertical-spacing--); + } + `, + ]; + } +} diff --git a/src/elements/ia-donation-form/ia-donation-form-controller-story.ts b/src/elements/ia-donation-form/ia-donation-form-controller-story.ts new file mode 100644 index 0000000..0fc4b67 --- /dev/null +++ b/src/elements/ia-donation-form/ia-donation-form-controller-story.ts @@ -0,0 +1,411 @@ +import { html, LitElement } from 'lit'; +import { customElement, query, state } from 'lit/decorators.js'; +import type { + AnalyticsEvent, + AnalyticsManagerInterface, +} from '@internetarchive/analytics-manager'; +import type { ModalManagerInterface } from '@internetarchive/modal-manager'; +import '@internetarchive/modal-manager'; + +import type { PropInputSettings } from '@demo/story-components/story-prop-settings'; +import type { StyleInputSettings } from '@demo/story-components/story-styles-settings'; +import type { BraintreeEndpointManagerInterface } from './braintree/braintree-manager-interface'; +import { HostingEnvironment } from './braintree/hosting-environment'; +import type { IADonationFormController } from './ia-donation-form-controller'; +import type { DonationRequest } from './models/donation-request'; +import { + DonationResponse, + type SuccessResponse, +} from './models/donation-response'; +import { DonationType } from './models/donation-type'; +import { UpsellModalCTAMode } from './modals/ia-donation-upsell-modal-content'; + +import './ia-donation-form-controller'; +import '@demo/story-template'; + +/** Braintree sandbox credentials from the old package's demo. Nothing is charged. */ +const sandbox = { + braintreeAuthToken: 'sandbox_x634jsj7_7zybks4ybp63pbmd', + recaptchaSiteKey: '6LeTUvYUAAAAAPTvW98MaXyS8c6vxk4-9n8DI1ve', + venmoProfileId: '1953896702662410263', +}; + +/** + * Posts donations to the petabox sandbox charge endpoint, as the old demo did. + * With Braintree's sandbox token and test card numbers nothing is charged. + */ +class DemoEndpointManager implements BraintreeEndpointManagerInterface { + private onSuccess: (summary: string) => void; + + constructor(onSuccess: (summary: string) => void) { + this.onSuccess = onSuccess; + } + + async submitData(request: DonationRequest): Promise { + const response = await fetch( + 'https://ia-petabox.archive.org/services/donations/braintree-charge.php?debug=true', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(request), + }, + ); + return new DonationResponse(await response.json()); + } + + donationSuccessful(options: { + successResponse: SuccessResponse; + upsellSuccessResponse?: SuccessResponse; + }): void { + const { successResponse, upsellSuccessResponse } = options; + let summary = `${successResponse.paymentProvider} ${successResponse.donationType} $${successResponse.amount}, transaction ${successResponse.transaction_id}`; + if (upsellSuccessResponse) { + summary += ` + monthly upsell $${upsellSuccessResponse.amount}`; + } + this.onSuccess(summary); + } +} + +/** Shows each analytics event in the story instead of sending it anywhere. */ +class DemoAnalyticsManager implements AnalyticsManagerInterface { + private onEvent: (description: string) => void; + + constructor(onEvent: (description: string) => void) { + this.onEvent = onEvent; + } + + sendPing(values: Record): void { + this.onEvent(`ping ${JSON.stringify(values)}`); + } + + sendEvent(options: AnalyticsEvent): void { + this.onEvent( + `${options.category} / ${options.action} / ${options.label ?? ''}`, + ); + } + + sendEventNoSampling(options: AnalyticsEvent): void { + this.onEvent( + `${options.category} / ${options.action} / ${options.label ?? ''} (unsampled)`, + ); + } +} + +const styleInputSettings: StyleInputSettings[] = [ + { + label: 'Base font size', + cssVariable: '--ia-donation-form-base-font-size', + defaultValue: 10, + inputType: 'range', + min: 8, + max: 16, + step: 1, + unit: 'px', + }, + { + section: 'Color', + label: 'Text', + cssVariable: '--ia-donation-form-text-color', + defaultValue: '#333333', + inputType: 'color', + }, + { + section: 'Color', + label: 'Step badge', + cssVariable: '--ia-donation-form-badge-background-color', + defaultValue: '#333333', + inputType: 'color', + }, + { + section: 'Color', + label: 'Selected option', + cssVariable: '--ia-donation-form-selected-option-background-color', + defaultValue: '#f9bf3b', + inputType: 'color', + }, + { + section: 'Color', + label: 'Donate button', + cssVariable: '--ia-donation-form-donate-button-color', + defaultValue: '#31a481', + inputType: 'color', + }, + { + section: 'Color', + label: 'Background', + cssVariable: '--ia-donation-form-background-color', + defaultValue: 'transparent', + inputType: 'text', + presets: [{ label: 'Mint', value: '#d1faed', note: 'donate page' }], + presetsInline: true, + }, +]; + +const propInputSettings: PropInputSettings[] = [ + { + label: 'Frequency choice', + propertyName: 'frequencySelectionMode', + defaultValue: 'button', + inputType: 'radio', + radioOptions: ['button', 'checkbox'], + }, + { + label: 'Amount layout', + propertyName: 'amountSelectionLayout', + defaultValue: 'multi-line', + inputType: 'radio', + radioOptions: ['multi-line', 'single-line'], + }, + { + label: 'Donor email', + propertyName: 'donorEmail', + defaultValue: '', + }, + { + label: 'Referrer', + propertyName: 'referrer', + defaultValue: 'elements-demo', + }, +]; + +/** + * The full form against the Braintree sandbox. Test card numbers are at + * https://developer.paypal.com/braintree/docs/reference/general/testing; + * 4111 1111 1111 1111 with any future expiry and any CVV goes through. + * + * The payment providers load their scripts from Braintree, PayPal, Google and + * reCAPTCHA, so the story only connects to the sandbox when asked. The demo + * page renders every story at once and shouldn't reach out to any of them on + * its own. + */ +@customElement('ia-donation-form-controller-story') +export class IADonationFormControllerStory extends LitElement { + @state() private sandboxConnected = false; + + @state() private lastAnalyticsEvent = ''; + + @state() private lastSuccess = ''; + + @query('ia-donation-form-controller') + private controller!: IADonationFormController; + + @query('modal-manager') private modalManager!: ModalManagerInterface; + + @query('#recaptcha') private recaptchaElement!: HTMLElement; + + private endpointManager = new DemoEndpointManager((summary) => { + this.lastSuccess = summary; + }); + + private analyticsHandler = new DemoAnalyticsManager((description) => { + this.lastAnalyticsEvent = description; + }); + + /** + * Light DOM: Braintree and PayPal find their containers with + * `document.querySelector`, which can't see into a shadow root + */ + createRenderRoot(): this { + return this; + } + + firstUpdated(): void { + // The modal manager and the recaptcha container have to exist in the + // page before the controller can wire itself to them + this.controller.modalManager = this.modalManager; + this.controller.recaptchaElement = this.recaptchaElement; + this.controller.analyticsHandler = this.analyticsHandler; + } + + render() { + const connected = this.sandboxConnected; + return html` + + + + + +
+

Braintree sandbox

+ ${connected + ? html` +

+ Connected. Nothing is charged. Test card + 4111 1111 1111 1111, any future expiry, any CVV. +

+

Modals

+
+ + + + +
+ ` + : html` +
+ +
+

+ Loads the Braintree, PayPal, Google Pay and reCAPTCHA scripts + and turns on the payment buttons. +

+ `} +

Events

+
+
analytics
+
${this.lastAnalyticsEvent || html`none yet`}
+
donationSuccessful
+
${this.lastSuccess || html`none yet`}
+
+
+
+ +
+ + + +
+
+
+
+ `; + } + + private showUpsell(): void { + this.controller.showUpsellModalDev({ + oneTimeAmount: this.controller.donationInfo.amount, + ctaMode: UpsellModalCTAMode.YesButton, + yesSelected: (amount) => { + this.lastSuccess = `upsell yes: $${amount}`; + this.modalManager.closeModal(); + }, + noSelected: () => this.modalManager.closeModal(), + }); + } + + private showPayPalUpsell(): void { + this.controller.showUpsellModalDev({ + oneTimeAmount: this.controller.donationInfo.amount, + ctaMode: UpsellModalCTAMode.PayPalUpsellSlot, + noSelected: () => this.modalManager.closeModal(), + }); + } + + private showConfirmation(donationType: DonationType): void { + this.controller.showConfirmationStepDev({ + donationType, + amount: donationType === DonationType.Upsell ? 8 : 33, + currencyType: 'USD', + cancelDonationCB: () => { + this.lastSuccess = 'confirmation cancelled'; + this.modalManager.closeModal(); + }, + confirmDonationCB: () => { + this.lastSuccess = 'confirmation confirmed'; + this.modalManager.closeModal(); + }, + }); + } +} diff --git a/src/elements/ia-donation-form/ia-donation-form-controller.test.ts b/src/elements/ia-donation-form/ia-donation-form-controller.test.ts new file mode 100644 index 0000000..ab52abc --- /dev/null +++ b/src/elements/ia-donation-form/ia-donation-form-controller.test.ts @@ -0,0 +1,234 @@ +import { elementUpdated, fixture } from '@open-wc/testing-helpers'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { html } from 'lit'; + +import { HostingEnvironment } from './braintree/hosting-environment'; +import { + MockGrecaptcha, + MockGrecaptchaMode, + MockHostedFieldsClient, +} from './braintree/test-helpers/mock-clients.test-helpers'; +import { + MockEndpointManager, + MockPaymentClients, +} from './braintree/test-helpers/mock-managers.test-helpers'; +import { setGlobal } from './braintree/test-helpers/mock-models.test-helpers'; +import type { IADonationContactForm } from './form-elements/contact-form/ia-donation-contact-form'; +import type { IADonationEditDonation } from './form-elements/ia-donation-edit-donation'; +import type { IADonationHeader } from './form-elements/ia-donation-header'; +import type { IADonationPaymentSelector } from './form-elements/ia-donation-payment-selector'; +import type { IADonationForm } from './ia-donation-form'; +import type { IADonationFormController } from './ia-donation-form-controller'; +import { + fillInContactForm, + oneEvent, + promisedSleep, +} from './test-helpers/dom.test-helpers'; +import { MockAnalyticsManager } from './test-helpers/mock-analytics.test-helpers'; +import { MockPaymentFlowHandlers } from './test-helpers/mock-flow-handlers.test-helpers'; +import type { MockModalManager } from './test-helpers/mock-modal-manager.test-helpers'; +import './test-helpers/mock-modal-manager.test-helpers'; +import './ia-donation-form-controller'; + +function donationForm(controller: IADonationFormController): IADonationForm { + return controller.querySelector('ia-donation-form')!; +} + +function paymentSelector( + controller: IADonationFormController, +): IADonationPaymentSelector { + return donationForm(controller).shadowRoot!.querySelector( + 'ia-donation-payment-selector', + )!; +} + +async function setup(analyticsCategory = 'FooCategory'): Promise<{ + controller: IADonationFormController; + analytics: MockAnalyticsManager; +}> { + const analytics = new MockAnalyticsManager(); + const controller = await fixture(html` + + `); + return { controller, analytics }; +} + +describe('IADonationFormController', () => { + beforeEach(() => { + setGlobal('grecaptcha', new MockGrecaptcha(MockGrecaptchaMode.Success)); + }); + + afterEach(() => { + setGlobal('grecaptcha', undefined); + }); + + test('renders into the light DOM', async () => { + const { controller } = await setup(); + expect(controller.shadowRoot).to.be.null; + expect(controller.querySelector('ia-donation-form')).to.exist; + expect(controller.querySelector('ia-donation-contact-form')).to.exist; + expect(controller.querySelector('#braintree-creditcard')).to.exist; + }); + + test('can submit a credit card donation end to end', async () => { + const { controller, analytics } = await setup(); + + const endpointManager = new MockEndpointManager(); + const paymentClients = new MockPaymentClients(); + const modalManager = await fixture( + html``, + ); + const recaptchaElement = await fixture(html`
`); + controller.paymentClients = paymentClients; + controller.modalManager = modalManager; + controller.environment = HostingEnvironment.Development; + controller.recaptchaElement = recaptchaElement; + controller.endpointManager = endpointManager; + controller.braintreeAuthToken = 'foo'; + controller.recaptchaSiteKey = 'bar'; + controller.venmoProfileId = 'baz'; + controller.referrer = 'test-referrer'; + controller.loggedInUser = 'test-user'; + await elementUpdated(controller); + await promisedSleep(50); + + // Pick credit card, which shows the contact form + paymentSelector(controller) + .shadowRoot!.querySelector('.credit-card-button')! + .click(); + await elementUpdated(donationForm(controller)); + await promisedSleep(50); + fillInContactForm( + controller.querySelector( + 'ia-donation-contact-form', + )!, + ); + + // The donor fills in the card, which the hosted fields report as valid + const hostedFields = + (await paymentClients.hostedFields.get()) as unknown as MockHostedFieldsClient; + hostedFields.emitValidityChangedEvent(true); + await promisedSleep(50); + expect(endpointManager.requestSubmitted).to.be.undefined; + + donationForm(controller) + .shadowRoot!.querySelector('#donate-button')! + .click(); + await promisedSleep(100); + + expect(analytics.sendEventOptions?.action).to.equal('PaymentFlowStarted'); + const request = endpointManager.requestSubmitted; + expect(request).to.exist; + expect(request?.paymentProvider).to.equal('Credit Card'); + expect(request?.recaptchaToken).to.equal('foo'); + expect(request?.customer.email).to.equal('foo@bar.com'); + expect(request?.customFields.referrer).to.equal('test-referrer'); + expect(request?.customFields.logged_in_user).to.equal('test-user'); + // The processing modal went up, then the upsell for a one-time gift + expect(modalManager.showModalOptions).to.exist; + }); + + test('sends a Viewed event when it first renders', async () => { + const { analytics } = await setup(); + expect(analytics.sendEventOptions?.category).to.equal('FooCategory'); + expect(analytics.sendEventOptions?.action).to.equal('Viewed'); + }); + + test('sends DonationInfoChanged when the donor changes the amount', async () => { + const { controller, analytics } = await setup(); + const header = + donationForm(controller).shadowRoot!.querySelector( + 'ia-donation-header', + )!; + const editDonation = + header.shadowRoot!.querySelector( + 'ia-donation-edit-donation', + )!; + + editDonation + .shadowRoot!.querySelector( + '#donationType-monthly-option', + )! + .click(); + + expect(analytics.sendEventOptions?.category).to.equal('FooCategory'); + expect(analytics.sendEventOptions?.action).to.equal('DonationInfoChanged'); + expect(controller.donationInfo.donationType).to.equal('monthly'); + }); + + test('sends ProviderFirstSelected and then ProviderChangedTo events', async () => { + const { controller, analytics } = await setup(); + const form = donationForm(controller); + const selector = paymentSelector(controller); + + let selected = oneEvent(form, 'paymentProviderSelected'); + selector + .shadowRoot!.querySelector('.credit-card-button')! + .click(); + await selected; + expect(analytics.sendEventOptions?.action).to.equal( + 'ProviderFirstSelected-CreditCard', + ); + + // The Venmo button is collapsed but still in the DOM, so a programmatic + // click switches providers + selected = oneEvent(form, 'paymentProviderSelected'); + selector.shadowRoot!.querySelector('.venmo')!.click(); + await selected; + expect(analytics.sendEventOptions?.action).to.equal( + 'ProviderChangedTo-Venmo', + ); + expect(analytics.sendEventOptions?.label).to.equal( + 'ProviderChangedFrom-CreditCard', + ); + + // Change payment method clears the provider + selected = oneEvent(form, 'paymentProviderSelected'); + selector + .shadowRoot!.querySelector('#change-payment-method')! + .click(); + await selected; + expect(analytics.sendEventOptions?.action).to.equal( + 'ProviderChangedTo-unset', + ); + expect(analytics.sendEventOptions?.label).to.equal( + 'ProviderChangedFrom-Venmo', + ); + }); + + test('sends PaymentFlowCancelled and PaymentFlowError with the provider', async () => { + const { controller, analytics } = await setup(); + const form = donationForm(controller); + const flowHandlers = new MockPaymentFlowHandlers(); + form.paymentFlowHandlers = flowHandlers; + await elementUpdated(form); + await promisedSleep(50); + + flowHandlers.paypalHandler.emitPaymentCancelledEvent(); + expect(analytics.sendEventOptions?.action).to.equal('PaymentFlowCancelled'); + expect(analytics.sendEventOptions?.label).to.equal('PayPal'); + + flowHandlers.paypalHandler.emitPaymentErrorEvent(); + expect(analytics.sendEventOptions?.action).to.equal('PaymentFlowError'); + expect(analytics.sendEventOptions?.label).to.equal('PayPal-foo-error'); + }); + + test('logs referrer and origin as unsampled flow events', async () => { + const { controller, analytics } = await setup(); + + controller.referrer = 'https://archive.org/details/foo'; + controller.origin = 'DonateBanner-Test-Variant'; + await elementUpdated(controller); + + const flowEvents = analytics.events.filter( + (e) => e.category === 'DonationFlow', + ); + expect(flowEvents.map((e) => [e.action, e.label])).to.deep.equal([ + ['referrer', 'https://archive.org/details/foo'], + ['origin', 'DonateBanner-Test-Variant'], + ]); + }); +}); diff --git a/src/elements/ia-donation-form/ia-donation-form-controller.ts b/src/elements/ia-donation-form/ia-donation-form-controller.ts new file mode 100644 index 0000000..2cfcd3a --- /dev/null +++ b/src/elements/ia-donation-form/ia-donation-form-controller.ts @@ -0,0 +1,702 @@ +import type * as braintree from 'braintree-web'; +import { + html, + LitElement, + type PropertyValues, + type TemplateResult, +} from 'lit'; +import { customElement, property, query } from 'lit/decorators.js'; +import { msg } from '@lit/localize'; +import currency from 'currency.js'; +import { + LazyLoaderService, + type LazyLoaderServiceInterface, +} from '@internetarchive/lazy-loader-service'; +import type { ModalManagerInterface } from '@internetarchive/modal-manager'; +import type { + AnalyticsEvent, + AnalyticsManagerInterface, +} from '@internetarchive/analytics-manager'; + +import { BraintreeManager } from './braintree/braintree-manager'; +import type { + BraintreeEndpointManagerInterface, + BraintreeManagerInterface, +} from './braintree/braintree-manager-interface'; +import type { HostingEnvironment } from './braintree/hosting-environment'; +import { + PaymentClients, + type PaymentClientsInterface, +} from './braintree/payment-clients'; +import { HostedFieldConfiguration } from './braintree/payment-providers/credit-card/hosted-field-configuration'; +import { HostedFieldContainer } from './braintree/payment-providers/credit-card/hosted-field-container'; +import { + PaymentFlowHandlers, + type PaymentFlowHandlersInterface, +} from './flow-handlers/payment-flow-handlers'; +import type { IADonationContactForm } from './form-elements/contact-form/ia-donation-contact-form'; +import { + EditDonationAmountSelectionLayout, + EditDonationFrequencySelectionMode, +} from './form-elements/ia-donation-edit-donation'; +import type { IADonationForm } from './ia-donation-form'; +import { calendarIcon, creditCardIcon, lockIcon } from './icons'; +import { + DonationPaymentInfo, + defaultDonationAmounts, + defaultSelectedDonationInfo, +} from './models/donation-payment-info'; +import { DonationType } from './models/donation-type'; +import type { PaymentProvider } from './models/payment-provider'; +import type { UpsellModalCTAMode } from './modals/ia-donation-upsell-modal-content'; +import { + RecaptchaManager, + type RecaptchaManagerInterface, +} from './recaptcha-manager'; +import './form-elements/contact-form/ia-donation-contact-form'; +import './form-elements/ia-donation-badged-input'; +import './ia-donation-form'; + +/** + * Wires the donation form to everything around it: Braintree and the payment + * libraries, the modal manager, recaptcha, and analytics. Give it the + * environment, tokens and an endpoint manager and it builds the rest. + * + * It renders into the light DOM, because Braintree's hosted fields, the + * PayPal button and recaptcha all refuse to work inside a shadow root. Those + * are rendered here and slotted into ``. + */ +@customElement('ia-donation-form-controller') +export class IADonationFormController extends LitElement { + @property({ type: String }) environment?: HostingEnvironment; + + @property({ type: String }) braintreeAuthToken?: string; + + @property({ type: String }) recaptchaSiteKey?: string; + + @property({ type: String }) venmoProfileId?: string; + + @property({ type: String }) googlePayMerchantId?: string; + + @property({ type: String }) analyticsCategory = 'DonationForm'; + + @property({ type: Array }) amountOptions: number[] = defaultDonationAmounts; + + @property({ type: Object }) donationInfo: DonationPaymentInfo = + defaultSelectedDonationInfo; + + @property({ type: String }) + amountSelectionLayout: EditDonationAmountSelectionLayout = + EditDonationAmountSelectionLayout.MultiLine; + + @property({ type: String }) + frequencySelectionMode: EditDonationFrequencySelectionMode = + EditDonationFrequencySelectionMode.Button; + + @property({ type: String }) referrer?: string; + + @property({ type: String }) loggedInUser?: string; + + @property({ type: String }) origin?: string; + + @property({ type: String }) donorEmail: string = ''; + + @property({ type: Object }) + endpointManager?: BraintreeEndpointManagerInterface; + + @property({ type: Object }) analyticsHandler?: AnalyticsManagerInterface; + + @property({ type: Object }) modalManager?: ModalManagerInterface; + + /** The element recaptcha renders its (invisible) widget into. Must be in the light DOM. */ + @property({ type: Object }) recaptchaElement?: HTMLElement; + + @property({ type: Object }) braintreeManager?: BraintreeManagerInterface; + + @property({ type: Object }) recaptchaManager?: RecaptchaManagerInterface; + + @property({ type: Object }) + paymentFlowHandlers?: PaymentFlowHandlersInterface; + + @property({ type: Object }) paymentClients?: PaymentClientsInterface; + + /** The clients this element built itself, so it knows when it may rebuild them */ + private defaultPaymentClients?: PaymentClientsInterface; + + @property({ type: Object }) + lazyLoaderService: LazyLoaderServiceInterface = new LazyLoaderService(); + + @query('ia-donation-form') private donationForm!: IADonationForm; + + @query('#braintree-creditcard') private braintreeNumberInput!: HTMLDivElement; + + @query('#braintree-cvv') private braintreeCVVInput!: HTMLDivElement; + + @query('#braintree-expiration') + private braintreeExpirationDateInput!: HTMLDivElement; + + @query('#braintree-error-message') + private braintreeErrorMessage!: HTMLDivElement; + + @query('ia-donation-contact-form') + private contactForm?: IADonationContactForm; + + updated(changedProperties: PropertyValues): void { + if (changedProperties.has('referrer') && this.referrer) { + this.braintreeManager?.setReferrer(this.referrer); + this.logDonationFlowEvent('referrer', this.referrer); + } + + if (changedProperties.has('loggedInUser') && this.loggedInUser) { + this.braintreeManager?.setLoggedInUser(this.loggedInUser); + } + + if (changedProperties.has('origin') && this.origin) { + this.braintreeManager?.setOrigin(this.origin); + this.logDonationFlowEvent('origin', this.origin); + } + + if ( + changedProperties.has('paymentClients') || + changedProperties.has('braintreeAuthToken') || + changedProperties.has('endpointManager') || + changedProperties.has('environment') + ) { + this.setupBraintreeManager(); + this.setupRecaptchaManager(); + } + + if (changedProperties.has('recaptchaSiteKey')) { + this.setupRecaptchaManager(); + } + + if ( + changedProperties.has('braintreeManager') || + changedProperties.has('recaptchaManager') || + changedProperties.has('modalManager') || + changedProperties.has('recaptchaElement') + ) { + this.setupPaymentFlowHandlers(); + } + + // Build the default clients for the environment, unless the consumer + // supplied their own + if ( + (changedProperties.has('environment') || + changedProperties.has('lazyLoaderService')) && + this.environment && + (this.paymentClients === undefined || + this.paymentClients === this.defaultPaymentClients) + ) { + this.defaultPaymentClients = new PaymentClients( + this.lazyLoaderService, + this.environment, + ); + this.paymentClients = this.defaultPaymentClients; + } + } + + /** Developer convenience: show the confirmation step without a payment. */ + async showConfirmationStepDev(options: { + donationType: DonationType; + amount: number; + currencyType: string; + cancelDonationCB: () => void; + confirmDonationCB: () => void; + }): Promise { + this.donationForm.showConfirmationModalDev(options); + } + + /** Developer convenience: show the upsell modal without a payment. */ + async showUpsellModalDev(options: { + oneTimeAmount: number; + ctaMode?: UpsellModalCTAMode; + yesSelected?: (amount: number) => void; + noSelected?: () => void; + amountChanged?: (amount: number) => void; + userClosedModalCallback?: () => void; + }): Promise { + this.donationForm.showUpsellModalDev(options); + } + + private setupBraintreeManager(): void { + if ( + this.braintreeManager === undefined && + this.braintreeAuthToken && + this.endpointManager && + this.paymentClients && + this.environment + ) { + this.braintreeManager = new BraintreeManager({ + paymentClients: this.paymentClients, + endpointManager: this.endpointManager, + authorizationToken: this.braintreeAuthToken, + venmoProfileId: this.venmoProfileId, + googlePayMerchantId: this.googlePayMerchantId, + hostedFieldConfig: this.hostedFieldConfig, + hostingEnvironment: this.environment, + referrer: this.referrer, + loggedInUser: this.loggedInUser, + origin: this.origin, + }); + + this.braintreeManager.on( + 'paymentProvidersHostedFieldsRetry', + (retryNumber: number) => { + this.dispatchEvent( + new CustomEvent('paymentProvidersHostedFieldsRetry', { + detail: { retryNumber }, + }), + ); + }, + ); + + this.braintreeManager.on( + 'paymentProvidersHostedFieldsFailed', + (error: unknown) => { + this.dispatchEvent( + new CustomEvent('paymentProvidersHostedFieldsFailed', { + detail: { error }, + }), + ); + }, + ); + } + } + + private recaptchaManagerSetup = false; + + private async setupRecaptchaManager(): Promise { + if ( + !this.recaptchaSiteKey || + !this.paymentClients || + this.recaptchaManagerSetup + ) { + return; + } + this.recaptchaManagerSetup = true; + const grecaptchaLibrary = await this.paymentClients.recaptchaLibrary.get(); + this.recaptchaManager = new RecaptchaManager({ + grecaptchaLibrary, + siteKey: this.recaptchaSiteKey, + }); + } + + firstUpdated(): void { + this.configureFromQueryParams(); + this.trackViewedEvent(); + } + + /** + * The donate page links carry the starting configuration in the query + * string: `amt`, `contrib_type`, `coverFees`, `dollarAmounts`, + * `amountLayout` and `frequencyMode`. + */ + private configureFromQueryParams(): void { + const urlParams = new URLSearchParams(window.location.search); + + let amountOptions = this.amountOptions; + const amountOptionsParam = urlParams.get('dollarAmounts'); + if (amountOptionsParam) { + // e.g. `[5,10,25]` + const stripBrackets = amountOptionsParam.slice(1, -1); + amountOptions = stripBrackets + .split(',') + .map((value) => parseFloat(value)) + .filter((value) => !isNaN(value)); + } + + let coverFees = this.donationInfo.coverFees; + const coverFeesParam = urlParams.get('coverFees'); + if (coverFeesParam) { + coverFees = coverFeesParam === 'true'; + } + + let frequency = this.donationInfo.donationType; + const frequencyParam = urlParams.get('contrib_type'); + if (frequencyParam === 'monthly') { + frequency = DonationType.Monthly; + } + + let amount = this.donationInfo.amount; + const amountParam = urlParams.get('amt'); + if (amountParam) { + const parsedAmount = currency(amountParam).value; + if (parsedAmount > 0) { + amount = parsedAmount; + } + } + + const amountLayoutParam = urlParams.get('amountLayout'); + if (amountLayoutParam) { + const amountLayout = + amountLayoutParam as EditDonationAmountSelectionLayout; + if ( + Object.values(EditDonationAmountSelectionLayout).includes(amountLayout) + ) { + this.amountSelectionLayout = amountLayout; + } + } + + const frequencyModeParam = urlParams.get('frequencyMode'); + if (frequencyModeParam) { + const frequencyMode = + frequencyModeParam as EditDonationFrequencySelectionMode; + if ( + Object.values(EditDonationFrequencySelectionMode).includes( + frequencyMode, + ) + ) { + this.frequencySelectionMode = frequencyMode; + } + } + + this.amountOptions = amountOptions; + this.donationInfo = new DonationPaymentInfo({ + donationType: frequency, + amount, + coverFees, + }); + } + + private setupPaymentFlowHandlers(): void { + if (this.paymentFlowHandlers) { + return; + } + + if ( + !this.braintreeManager || + !this.recaptchaManager || + !this.modalManager || + !this.recaptchaElement + ) { + return; + } + + this.paymentFlowHandlers = new PaymentFlowHandlers({ + braintreeManager: this.braintreeManager, + modalManager: this.modalManager, + recaptchaManager: this.recaptchaManager, + analytics: { + logEvent: this.logEvent.bind(this), + logDonationFlowEvent: this.logDonationFlowEvent.bind(this), + }, + }); + + this.donationForm.braintreeManager = this.braintreeManager; + this.donationForm.paymentFlowHandlers = this.paymentFlowHandlers; + + this.braintreeManager.startup(); + this.paymentFlowHandlers.startup(); + this.recaptchaManager.setup(this.recaptchaElement, 1, 'light', 'image'); + } + + private get hostedFieldConfig(): HostedFieldConfiguration { + const hostedFieldStyle: Record> = { + input: { + 'font-size': '16px', + 'font-family': '"Helvetica Neue", Helvetica, Arial, sans-serif', + 'font-weight': '700', + color: '#333', + }, + ':focus': { + color: '#333', + }, + '.valid': {}, + '.invalid': { + color: '#b00b00', + }, + }; + + const hostedFieldFieldOptions: braintree.HostedFieldFieldOptions = { + number: { + selector: '#braintree-creditcard', + placeholder: msg('Card number'), + }, + cvv: { + selector: '#braintree-cvv', + placeholder: msg('CVC'), + }, + expirationDate: { + selector: '#braintree-expiration', + placeholder: msg('MM / YY'), + }, + }; + + const hostedFieldContainer = new HostedFieldContainer({ + number: this.braintreeNumberInput, + cvv: this.braintreeCVVInput, + expirationDate: this.braintreeExpirationDateInput, + errorContainer: this.braintreeErrorMessage, + }); + + return new HostedFieldConfiguration({ + hostedFieldStyle, + hostedFieldFieldOptions, + hostedFieldContainer, + }); + } + + render(): TemplateResult { + return html` +
+ + +
+
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ + +
+ +
+ +
+
+
+ + +
+
+ + ${this.getStyles} + `; + } + + /** Light DOM, for the payment libraries that can't render into a shadow root */ + createRenderRoot(): this { + return this; + } + + private donationInfoChanged(e: CustomEvent): void { + this.logEvent('DonationInfoChanged'); + this.donationInfo = e.detail.donationInfo; + } + + private trackViewedEvent(): void { + this.logEvent('Viewed'); + } + + private paymentProviderSelected(e: CustomEvent): void { + const paymentProvider = e.detail.paymentProvider as + | PaymentProvider + | undefined; + const previousPaymentProvider = e.detail.previousPaymentProvider as + | PaymentProvider + | undefined; + const providerNoSpaces = this.removeSpaces(paymentProvider ?? 'unset'); + let eventName = `ProviderFirstSelected-${providerNoSpaces}`; + let previousProviderInfo: string | undefined; + if (previousPaymentProvider !== undefined) { + eventName = `ProviderChangedTo-${providerNoSpaces}`; + previousProviderInfo = `ProviderChangedFrom-${this.removeSpaces( + previousPaymentProvider, + )}`; + } + + this.logEvent(eventName, previousProviderInfo); + } + + private paymentFlowConfirmed(e: CustomEvent): void { + this.logEvent( + 'PaymentFlowConfirmed', + this.removeSpaces(e.detail.paymentProvider), + ); + } + + private paymentFlowStarted(e: CustomEvent): void { + this.logEvent( + 'PaymentFlowStarted', + this.removeSpaces(e.detail.paymentProvider), + ); + } + + private paymentFlowCancelled(e: CustomEvent): void { + this.logEvent( + 'PaymentFlowCancelled', + this.removeSpaces(e.detail.paymentProvider), + ); + } + + private paymentFlowError(e: CustomEvent): void { + const providerNoSpaces = this.removeSpaces(e.detail.paymentProvider); + this.logEvent('PaymentFlowError', `${providerNoSpaces}-${e.detail.error}`); + } + + private removeSpaces(original: string): string { + return original.replace(/\s+/g, ''); + } + + /** A sampled event in this form's category */ + private logEvent(action: string, label?: string): void { + const analyticEvent: AnalyticsEvent = { + action, + label, + category: this.analyticsCategory, + }; + this.analyticsHandler?.sendEvent(analyticEvent); + } + + /** An unsampled event in the `DonationFlow` category */ + private logDonationFlowEvent(action: string, label?: string): void { + const analyticEvent: AnalyticsEvent = { + action, + label, + category: 'DonationFlow', + }; + this.analyticsHandler?.sendEventNoSampling(analyticEvent); + } + + /** + * The element renders into the light DOM, so instead of a `styles` block + * it writes its own ` + `; + } +} diff --git a/src/elements/ia-donation-form/ia-donation-form.test.ts b/src/elements/ia-donation-form/ia-donation-form.test.ts new file mode 100644 index 0000000..9335769 --- /dev/null +++ b/src/elements/ia-donation-form/ia-donation-form.test.ts @@ -0,0 +1,217 @@ +import { elementUpdated, fixture } from '@open-wc/testing-helpers'; +import { describe, expect, test } from 'vitest'; +import { html } from 'lit'; + +import { MockBraintreeManager } from './braintree/test-helpers/mock-managers.test-helpers'; +import { fiveDollars } from './braintree/test-helpers/mock-models.test-helpers'; +import type { IADonationEditDonation } from './form-elements/ia-donation-edit-donation'; +import type { IADonationHeader } from './form-elements/ia-donation-header'; +import type { IADonationPaymentSelector } from './form-elements/ia-donation-payment-selector'; +import type { IADonationForm } from './ia-donation-form'; +import { DonationPaymentInfo } from './models/donation-payment-info'; +import { DonationType } from './models/donation-type'; +import { PaymentProvider } from './models/payment-provider'; +import { oneEvent, promisedSleep } from './test-helpers/dom.test-helpers'; +import { MockPaymentFlowHandlers } from './test-helpers/mock-flow-handlers.test-helpers'; +import './ia-donation-form'; + +function paymentSelector(el: IADonationForm): IADonationPaymentSelector { + return el.shadowRoot!.querySelector('ia-donation-payment-selector')!; +} + +function selectorButton(el: IADonationForm, selector: string): HTMLElement { + return paymentSelector(el).shadowRoot!.querySelector(selector)!; +} + +function contactFormSection(el: IADonationForm): HTMLDivElement { + return el.shadowRoot!.querySelector('.contact-form-section')!; +} + +describe('IADonationForm', () => { + test('starts with nothing configured', async () => { + const el = await fixture( + html``, + ); + + expect(el.braintreeManager).to.be.undefined; + expect(el.donationRequest).to.be.undefined; + }); + + test('updates donationInfo when the donor changes it in the header', async () => { + const el = await fixture( + html``, + ); + const header = + el.shadowRoot!.querySelector('ia-donation-header')!; + const editDonation = + header.shadowRoot!.querySelector( + 'ia-donation-edit-donation', + )!; + + const changed = oneEvent(el, 'donationInfoChanged'); + editDonation + .shadowRoot!.querySelector( + '#donationType-monthly-option', + )! + .click(); + await changed; + await elementUpdated(el); + + expect(el.donationInfo?.donationType).to.equal(DonationType.Monthly); + }); + + test('numbers the payment step after the amount steps', async () => { + const el = await fixture( + html``, + ); + const sections = () => + Array.from(el.shadowRoot!.querySelectorAll('ia-donation-section')).map( + (s) => (s as HTMLElement & { sectionBadge: string }).sectionBadge, + ); + + // Total (unnumbered), payment, contact, donate + expect(sections()).to.deep.equal(['0', '3', '4', '5']); + + el.frequencySelectionMode = 'checkbox'; + await elementUpdated(el); + expect(sections()).to.deep.equal(['0', '2', '3', '4']); + }); + + test('shows the contact form and card fields when Credit Card is selected', async () => { + const el = await fixture( + html``, + ); + expect(contactFormSection(el).classList.contains('hidden')).to.be.true; + + selectorButton(el, '.credit-card-button').click(); + await elementUpdated(el); + + expect(contactFormSection(el).classList.contains('hidden')).to.be.false; + expect( + el + .shadowRoot!.querySelector('.credit-card-fields')! + .classList.contains('hidden'), + ).to.be.false; + }); + + test('shows the contact form without card fields when Venmo is selected', async () => { + const el = await fixture( + html``, + ); + el.braintreeManager = new MockBraintreeManager(); + await elementUpdated(el); + + selectorButton(el, '.venmo').click(); + await elementUpdated(el); + + expect(contactFormSection(el).classList.contains('hidden')).to.be.false; + expect( + el + .shadowRoot!.querySelector('.credit-card-fields')! + .classList.contains('hidden'), + ).to.be.true; + expect( + el + .shadowRoot!.querySelector('#contactFormSection')! + .getAttribute('headline'), + ).to.equal('Help us stay in touch'); + }); + + test('hands the donation info to the PayPal handler when the flow handlers are set', async () => { + const donationInfo = new DonationPaymentInfo({ + donationType: DonationType.Monthly, + amount: 3.5, + coverFees: false, + }); + const el = await fixture( + html``, + ); + const flowHandlers = new MockPaymentFlowHandlers(); + + el.paymentFlowHandlers = flowHandlers; + await elementUpdated(el); + await promisedSleep(50); + + expect(flowHandlers.paypalHandler.donationInfo).to.equal(donationInfo); + expect(flowHandlers.paypalHandler.renderPayPalCalledDonationInfo).to.equal( + donationInfo, + ); + expect( + selectorButton(el, '.paypal-container').classList.contains('available'), + ).to.be.true; + }); + + test('emits paymentProviderSelected with the previous provider', async () => { + const el = await fixture( + html``, + ); + el.braintreeManager = new MockBraintreeManager(); + await elementUpdated(el); + + let selected = oneEvent(el, 'paymentProviderSelected'); + selectorButton(el, '.credit-card-button').click(); + let detail = (await selected).detail; + expect(detail.paymentProvider).to.equal(PaymentProvider.CreditCard); + expect(detail.previousPaymentProvider).to.be.undefined; + + // Venmo is collapsed behind "change payment method" now, but the button + // is still in the DOM, so a programmatic click reaches it + selected = oneEvent(el, 'paymentProviderSelected'); + selectorButton(el, '.venmo').click(); + detail = (await selected).detail; + expect(detail.paymentProvider).to.equal(PaymentProvider.Venmo); + expect(detail.previousPaymentProvider).to.equal(PaymentProvider.CreditCard); + + // Changing the payment method clears the selection + selected = oneEvent(el, 'paymentProviderSelected'); + selectorButton(el, '#change-payment-method').click(); + detail = (await selected).detail; + expect(detail.paymentProvider).to.be.undefined; + expect(detail.previousPaymentProvider).to.equal(PaymentProvider.Venmo); + }); + + test('relays a PayPal cancellation and error as flow events', async () => { + const el = await fixture( + html``, + ); + const flowHandlers = new MockPaymentFlowHandlers(); + el.paymentFlowHandlers = flowHandlers; + await elementUpdated(el); + + const cancelled = oneEvent(el, 'paymentFlowCancelled'); + flowHandlers.paypalHandler.emitPaymentCancelledEvent(); + expect((await cancelled).detail.paymentProvider).to.equal( + PaymentProvider.PayPal, + ); + + const errored = oneEvent(el, 'paymentFlowError'); + flowHandlers.paypalHandler.emitPaymentErrorEvent(); + const errorDetail = (await errored).detail; + expect(errorDetail.paymentProvider).to.equal(PaymentProvider.PayPal); + expect(errorDetail.error).to.equal('foo-error'); + }); + + test('starts the Google Pay flow straight from its button', async () => { + const el = await fixture( + html``, + ); + const flowHandlers = new MockPaymentFlowHandlers(); + el.paymentFlowHandlers = flowHandlers; + await elementUpdated(el); + + const started = oneEvent(el, 'paymentFlowStarted'); + selectorButton(el, '.googlepay').click(); + expect((await started).detail.paymentProvider).to.equal( + PaymentProvider.GooglePay, + ); + expect( + flowHandlers.googlePayHandler.paymentInitiatedDonationInfo?.amount, + ).to.equal(5); + }); +}); diff --git a/src/elements/ia-donation-form/ia-donation-form.ts b/src/elements/ia-donation-form/ia-donation-form.ts new file mode 100644 index 0000000..075c1fe --- /dev/null +++ b/src/elements/ia-donation-form/ia-donation-form.ts @@ -0,0 +1,611 @@ +import type * as paypal from 'paypal-checkout-components'; +import { + css, + html, + LitElement, + type CSSResultGroup, + type PropertyValues, + type TemplateResult, +} from 'lit'; +import { customElement, property, query, state } from 'lit/decorators.js'; +import { msg } from '@lit/localize'; + +import themeStyles from '@src/themes/theme-styles'; +import type { BraintreeManagerInterface } from './braintree/braintree-manager-interface'; +import type { PaymentFlowHandlersInterface } from './flow-handlers/payment-flow-handlers'; +import type { IADonationContactForm } from './form-elements/contact-form/ia-donation-contact-form'; +import { + EditDonationAmountSelectionLayout, + EditDonationFrequencySelectionMode, +} from './form-elements/ia-donation-edit-donation'; +import type { IADonationHeader } from './form-elements/ia-donation-header'; +import type { IADonationPaymentSelector } from './form-elements/ia-donation-payment-selector'; +import { + DonationSectionBadgeMode, + type IADonationSection, +} from './form-elements/ia-donation-section'; +import { lockIcon } from './icons'; +import { + DonationPaymentInfo, + defaultDonationAmounts, + defaultSelectedDonationInfo, +} from './models/donation-payment-info'; +import type { DonationRequest } from './models/donation-request'; +import { DonationType } from './models/donation-type'; +import type { DonorContactInfo } from './models/donor-contact-info'; +import { PaymentProvider } from './models/payment-provider'; +import { UpsellModalCTAMode } from './modals/ia-donation-upsell-modal-content'; +import './form-elements/ia-donation-header'; +import './form-elements/ia-donation-payment-selector'; +import './form-elements/ia-donation-section'; +import './form-elements/ia-donation-total-amount'; + +/** + * The donation form: amount picker, total, payment method buttons, then the + * contact form and Donate button for the methods that need them. The + * controller wires it to Braintree, the modals and recaptcha; on its own it + * renders and emits events. + */ +@customElement('ia-donation-form') +export class IADonationForm extends LitElement { + @property({ type: Object }) braintreeManager?: BraintreeManagerInterface; + + @property({ type: Object }) + paymentFlowHandlers?: PaymentFlowHandlersInterface; + + @property({ type: Object }) donationRequest?: DonationRequest; + + @property({ type: Object }) + donationInfo: DonationPaymentInfo = defaultSelectedDonationInfo; + + /** The contact form lives in the light DOM (for autofill) and is handed in here */ + @property({ type: Object }) contactForm?: IADonationContactForm; + + @property({ type: Array }) amountOptions: number[] = defaultDonationAmounts; + + @property({ type: String }) + amountSelectionLayout: EditDonationAmountSelectionLayout = + EditDonationAmountSelectionLayout.MultiLine; + + @property({ type: String }) + frequencySelectionMode: EditDonationFrequencySelectionMode = + EditDonationFrequencySelectionMode.Button; + + @state() private creditCardVisible = false; + + @state() private contactFormVisible = false; + + @state() private donationInfoValid = true; + + @state() private selectedPaymentProvider?: PaymentProvider; + + @query('#contactFormSection') contactFormSection?: IADonationSection; + + @query('ia-donation-header') donationFormHeader!: IADonationHeader; + + @query('ia-donation-payment-selector') + paymentSelector!: IADonationPaymentSelector; + + private paypalButtonNeedsRender = true; + + render(): TemplateResult { + return html` + + + + + + + + + + { + this.selectedPaymentProvider = undefined; + this.contactFormVisible = false; + }} + > + + + + +
+ ${this.contactFormSectionTemplate} +
+ `; + } + + /** Developer convenience: show the confirmation step without a payment. */ + async showConfirmationModalDev(options: { + donationType: DonationType; + amount: number; + currencyType: string; + cancelDonationCB: () => void; + confirmDonationCB: () => void; + }): Promise { + this.paymentFlowHandlers?.showConfirmationStepModal(options); + } + + /** + * Developer convenience: show the upsell modal without going through a + * payment. With the PayPal slot mode it also renders the PayPal button. + */ + async showUpsellModalDev(options: { + oneTimeAmount: number; + ctaMode?: UpsellModalCTAMode; + yesSelected?: (amount: number) => void; + noSelected?: () => void; + amountChanged?: (amount: number) => void; + userClosedModalCallback?: () => void; + }): Promise { + this.paymentFlowHandlers?.showUpsellModal(options); + + if (options.ctaMode === UpsellModalCTAMode.PayPalUpsellSlot) { + const handler = + await this.braintreeManager?.paymentProviders.paypalHandler.get(); + const donationInfo = new DonationPaymentInfo({ + amount: options.oneTimeAmount, + donationType: DonationType.OneTime, + coverFees: false, + }); + handler?.renderPayPalButton({ + selector: '#paypal-upsell-button', + style: { + color: 'blue' as paypal.ButtonColorOption, + label: 'paypal' as paypal.ButtonLabelOption, + shape: 'rect' as paypal.ButtonShapeOption, + size: 'responsive' as paypal.ButtonSizeOption, + tagline: false, + }, + donationInfo, + }); + } + } + + get contactFormSectionTemplate(): TemplateResult { + const headline = + this.selectedPaymentProvider === PaymentProvider.Venmo + ? msg('Help us stay in touch') + : msg('Enter payment information'); + + return html` + + +
+ +
+
+ + + + + +
+ ${lockIcon} ${msg('Your payment will be securely processed')} +
+
+ `; + } + + /** + * The step number of the payment selector. With the frequency buttons + * showing they're step 1 and the amount is 2, so payment is 3. In checkbox + * mode the amount picker is step 1 and payment is 2. + */ + private get paymentSelectorNumberingStart(): number { + return this.frequencySelectionMode === + EditDonationFrequencySelectionMode.Button + ? 3 + : 2; + } + + private editDonationError(): void { + this.donationInfoValid = false; + } + + private paymentSelectorFirstUpdated(): void { + if (this.paymentFlowHandlers?.paypalHandler) { + this.renderPayPalButtonIfNeeded(); + } + } + + private applePaySelected(e: CustomEvent): void { + this.selectedPaymentProvider = PaymentProvider.ApplePay; + this.contactFormVisible = false; + this.creditCardVisible = false; + + if (!this.donationInfoValid) { + this.showInvalidDonationInfoAlert(); + return; + } + + const originalEvent = e.detail.originalEvent; + if (this.donationInfo) { + this.paymentFlowHandlers?.applePayHandler?.paymentInitiated( + this.donationInfo, + originalEvent, + ); + } + this.emitPaymentFlowStartedEvent(); + } + + private googlePaySelected(): void { + this.selectedPaymentProvider = PaymentProvider.GooglePay; + this.contactFormVisible = false; + this.creditCardVisible = false; + + if (!this.donationInfoValid) { + this.showInvalidDonationInfoAlert(); + return; + } + if (this.donationInfo) { + this.paymentFlowHandlers?.googlePayHandler?.paymentInitiated( + this.donationInfo, + ); + } + this.emitPaymentFlowStartedEvent(); + } + + private async creditCardSelected(): Promise { + if (!this.donationInfoValid) { + this.showInvalidDonationInfoAlert(); + return; + } + this.selectedPaymentProvider = PaymentProvider.CreditCard; + this.contactFormVisible = true; + this.creditCardVisible = true; + this.focusContactForm(); + } + + private async venmoSelected(): Promise { + if (!this.donationInfoValid) { + this.showInvalidDonationInfoAlert(); + return; + } + this.selectedPaymentProvider = PaymentProvider.Venmo; + this.contactFormVisible = true; + this.creditCardVisible = false; + this.focusContactForm(); + } + + private paypalBlockerSelected(): void { + this.contactFormVisible = false; + this.creditCardVisible = false; + this.showInvalidDonationInfoAlert(); + } + + private async focusContactForm(): Promise { + await this.updateComplete; + if (this.contactFormSection) { + this.contactForm?.focus(); + } + } + + private async donateClicked(): Promise { + if (!this.contactForm) { + alert(msg('Please enter contact info.')); + return; + } + if (!this.donationInfoValid || !this.donationInfo) { + this.showInvalidDonationInfoAlert(); + return; + } + + const contactInfo = this.contactForm.donorContactInfo; + + switch (this.selectedPaymentProvider) { + case PaymentProvider.CreditCard: + this.handleCreditCardDonationFlow(contactInfo, this.donationInfo); + break; + case PaymentProvider.Venmo: + this.handleVenmoDonationFlow(contactInfo, this.donationInfo); + break; + } + } + + private async handleCreditCardDonationFlow( + contactInfo: DonorContactInfo, + donationInfo: DonationPaymentInfo, + ): Promise { + const creditCardFlowHandler = this.paymentFlowHandlers?.creditCardHandler; + const creditCardHandler = + await this.braintreeManager?.paymentProviders.creditCardHandler.get(); + creditCardHandler?.hideErrorMessage(); + const valid = this.contactForm?.reportValidity(); + const hostedFieldsResponse = await creditCardFlowHandler?.tokenizeFields(); + + if (!valid || hostedFieldsResponse === undefined) { + return; + } + + this.emitPaymentFlowStartedEvent(); + creditCardFlowHandler?.paymentInitiated( + hostedFieldsResponse, + donationInfo, + contactInfo, + ); + } + + private async handleVenmoDonationFlow( + contactInfo: DonorContactInfo, + donationInfo: DonationPaymentInfo, + ): Promise { + const valid = this.contactForm?.reportValidity(); + if (!valid) { + return; + } + this.paymentFlowHandlers?.venmoHandler?.paymentInitiated( + contactInfo, + donationInfo, + ); + } + + private emitPaymentFlowEvent(name: string, extra: object = {}): void { + if (!this.selectedPaymentProvider) { + return; + } + this.dispatchEvent( + new CustomEvent(name, { + detail: { paymentProvider: this.selectedPaymentProvider, ...extra }, + }), + ); + } + + private emitPaymentFlowStartedEvent(): void { + this.emitPaymentFlowEvent('paymentFlowStarted'); + } + + private emitPaymentFlowConfirmedEvent(): void { + this.emitPaymentFlowEvent('paymentFlowConfirmed'); + } + + private emitPaymentFlowCancelledEvent(): void { + this.emitPaymentFlowEvent('paymentFlowCancelled'); + } + + private emitPaymentFlowErrorEvent(error?: string): void { + this.emitPaymentFlowEvent('paymentFlowError', { error }); + } + + private showInvalidDonationInfoAlert(): void { + alert(msg('Please enter a valid donation amount.')); + } + + private async renderPayPalButtonIfNeeded(): Promise { + if (!this.paypalButtonNeedsRender) { + return; + } + this.paypalButtonNeedsRender = false; + if (this.donationInfo) { + await this.paymentFlowHandlers?.paypalHandler?.renderPayPalButton( + this.donationInfo, + ); + } + this.paymentSelector.showPaypalButton(); + } + + updated(changedProperties: PropertyValues): void { + if (changedProperties.has('donationInfo') && this.donationInfo) { + // The PayPal button holds its own copy of the donation info, since the + // donor starts that payment from the button rather than from our code + this.paymentFlowHandlers?.paypalHandler?.updateDonationInfo( + this.donationInfo, + ); + this.donationFormHeader.donationInfo = this.donationInfo; + } + + if ( + (changedProperties.has('paymentFlowHandlers') || + changedProperties.has('donationInfo')) && + this.donationInfo && + this.paymentFlowHandlers + ) { + this.setupFlowHandlers(); + } + + if (changedProperties.has('donationInfoValid')) { + this.paymentSelector.donationInfoValid = this.donationInfoValid; + } + + if (changedProperties.has('selectedPaymentProvider')) { + this.dispatchEvent( + new CustomEvent('paymentProviderSelected', { + detail: { + paymentProvider: this.selectedPaymentProvider, + previousPaymentProvider: changedProperties.get( + 'selectedPaymentProvider', + ), + }, + }), + ); + } + } + + private flowHandlersConfigured = false; + + private setupFlowHandlers(): void { + if (this.flowHandlersConfigured) { + return; + } + this.flowHandlersConfigured = true; + this.bindFlowListenerEvents(); + this.renderPayPalButtonIfNeeded(); + if (this.donationInfo) { + this.paymentFlowHandlers?.paypalHandler?.updateDonationInfo( + this.donationInfo, + ); + } + } + + private bindFlowListenerEvents(): void { + const paypal = this.paymentFlowHandlers?.paypalHandler; + paypal?.on('payPalPaymentStarted', () => { + this.selectedPaymentProvider = PaymentProvider.PayPal; + this.emitPaymentFlowStartedEvent(); + }); + paypal?.on('payPalPaymentConfirmed', () => { + this.selectedPaymentProvider = PaymentProvider.PayPal; + this.emitPaymentFlowConfirmedEvent(); + }); + paypal?.on('payPalPaymentCancelled', () => { + this.selectedPaymentProvider = PaymentProvider.PayPal; + this.emitPaymentFlowCancelledEvent(); + }); + paypal?.on('payPalPaymentError', (_datasource, error) => { + this.selectedPaymentProvider = PaymentProvider.PayPal; + this.emitPaymentFlowErrorEvent(error); + }); + + this.paymentFlowHandlers?.googlePayHandler?.on('paymentCancelled', () => { + this.selectedPaymentProvider = PaymentProvider.GooglePay; + this.emitPaymentFlowCancelledEvent(); + }); + } + + private donationInfoChanged(e: CustomEvent): void { + const donationInfo: DonationPaymentInfo = e.detail.donationInfo; + this.donationInfo = new DonationPaymentInfo({ + amount: donationInfo.amount, + donationType: donationInfo.donationType, + coverFees: donationInfo.coverFees, + }); + this.donationInfoValid = true; + this.dispatchEvent( + new CustomEvent('donationInfoChanged', { detail: { donationInfo } }), + ); + } + + static get styles(): CSSResultGroup { + return [ + themeStyles, + css` + :host { + /* + * The donation form was built for a 10px root font size. Sizing off + * this base keeps it self-contained, so it looks the same whatever + * the page's root size is. Every part of the form reads it, and the + * two parts with their own base variable get it passed through. + */ + --donation-form-base-font-size--: var( + --ia-donation-form-base-font-size, + 10px + ); + --ia-donation-section-base-font-size: var( + --donation-form-base-font-size-- + ); + --ia-donation-edit-base-font-size: var( + --donation-form-base-font-size-- + ); + + --donation-form-donate-button-font-size--: var( + --ia-donation-form-donate-button-font-size, + calc(var(--donation-form-base-font-size--) * 2.6) + ); + --donation-form-donate-button-height--: var( + --ia-donation-form-donate-button-height, + calc(var(--donation-form-base-font-size--) * 4) + ); + --donation-form-donate-button-color--: var( + --ia-donation-form-donate-button-color, + var(--mint-green) + ); + --donation-form-donate-button-text-color--: var( + --ia-donation-form-donate-button-text-color, + var(--true-white) + ); + --donation-form-donate-button-hover-color--: var( + --ia-donation-form-donate-button-hover-color, + #278367 + ); + --donation-form-total-top-margin--: var( + --ia-donation-form-total-amount-top-margin, + calc(var(--donation-form-base-font-size--) * 1.5) + ); + --donation-form-total-bottom-margin--: var( + --ia-donation-form-total-amount-bottom-margin, + calc(var(--donation-form-base-font-size--) * 1.2) + ); + } + + h1 { + margin: 0; + padding: 0; + } + + .hidden { + display: none; + } + + .secure-process-note { + margin-top: 0.5em; + font-size: 0.75em; + text-align: center; + } + + .secure-process-note .ia-icon { + width: calc(var(--donation-form-base-font-size--) * 1.2); + height: calc(var(--donation-form-base-font-size--) * 1.5); + vertical-align: bottom; + background-color: currentColor; + } + + #donate-button { + width: 100%; + appearance: none; + font-size: var(--donation-form-donate-button-font-size--); + font-weight: bold; + text-align: center; + color: var(--donation-form-donate-button-text-color--); + cursor: pointer; + border: none; + border-radius: 5px; + background-color: var(--donation-form-donate-button-color--); + padding-top: var(--padding-sm); + padding-bottom: var(--padding-sm); + height: var(--donation-form-donate-button-height--); + } + + #donate-button:hover { + background-color: var(--donation-form-donate-button-hover-color--); + } + + #total-amount-section { + display: block; + margin-top: var(--donation-form-total-top-margin--); + margin-bottom: var(--donation-form-total-bottom-margin--); + } + `, + ]; + } +} diff --git a/src/elements/ia-donation-form/icons.ts b/src/elements/ia-donation-form/icons.ts new file mode 100644 index 0000000..b313241 --- /dev/null +++ b/src/elements/ia-donation-form/icons.ts @@ -0,0 +1,39 @@ +import { html, type TemplateResult } from 'lit'; + +import { maskedIcon } from '@src/util/masked-icon'; + +import applePayUrl from './icons/apple-pay.svg'; +import calendarUrl from './icons/calendar.svg'; +import creditCardUrl from './icons/credit-card.svg'; +import emailUrl from './icons/email.svg'; +import googlePayUrl from './icons/google-pay.svg'; +import localePinUrl from './icons/locale-pin.svg'; +import lockUrl from './icons/lock.svg'; +import paypalUrl from './icons/paypal.svg'; +import userUrl from './icons/user.svg'; +import venmoUrl from './icons/venmo.svg'; + +/** + * Icons taken from the `@internetarchive/icon-*` packages (v1.3.4). They ship + * as standalone `.svg` files so their path data stays out of the JS bundle. + * + * The single-colour field glyphs render as CSS masks (see `maskedIcon`) so + * the host can paint them with `background-color`. The payment brand logos + * are multicolour and render as images. + */ + +/** A brand logo, drawn at the size of its container. */ +const brandLogo = (url: string, alt: string): TemplateResult => + html``; + +export const applePayLogo = brandLogo(applePayUrl, 'Apple Pay'); +export const googlePayLogo = brandLogo(googlePayUrl, 'Google Pay'); +export const paypalLogo = brandLogo(paypalUrl, 'PayPal'); +export const venmoLogo = brandLogo(venmoUrl, 'Venmo'); + +export const calendarIcon = maskedIcon(calendarUrl); +export const creditCardIcon = maskedIcon(creditCardUrl); +export const emailIcon = maskedIcon(emailUrl); +export const localePinIcon = maskedIcon(localePinUrl); +export const lockIcon = maskedIcon(lockUrl); +export const userIcon = maskedIcon(userUrl); diff --git a/src/elements/ia-donation-form/icons/apple-pay.svg b/src/elements/ia-donation-form/icons/apple-pay.svg new file mode 100644 index 0000000..a15e17b --- /dev/null +++ b/src/elements/ia-donation-form/icons/apple-pay.svg @@ -0,0 +1,22 @@ + + + + + + + + + + diff --git a/src/elements/ia-donation-form/icons/calendar.svg b/src/elements/ia-donation-form/icons/calendar.svg new file mode 100644 index 0000000..8efd5a7 --- /dev/null +++ b/src/elements/ia-donation-form/icons/calendar.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/src/elements/ia-donation-form/icons/credit-card.svg b/src/elements/ia-donation-form/icons/credit-card.svg new file mode 100644 index 0000000..9d17b1c --- /dev/null +++ b/src/elements/ia-donation-form/icons/credit-card.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/elements/ia-donation-form/icons/email.svg b/src/elements/ia-donation-form/icons/email.svg new file mode 100644 index 0000000..85c4a9b --- /dev/null +++ b/src/elements/ia-donation-form/icons/email.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/elements/ia-donation-form/icons/google-pay.svg b/src/elements/ia-donation-form/icons/google-pay.svg new file mode 100644 index 0000000..6e3b05e --- /dev/null +++ b/src/elements/ia-donation-form/icons/google-pay.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + diff --git a/src/elements/ia-donation-form/icons/locale-pin.svg b/src/elements/ia-donation-form/icons/locale-pin.svg new file mode 100644 index 0000000..f916907 --- /dev/null +++ b/src/elements/ia-donation-form/icons/locale-pin.svg @@ -0,0 +1,7 @@ + + + diff --git a/src/elements/ia-donation-form/icons/lock.svg b/src/elements/ia-donation-form/icons/lock.svg new file mode 100644 index 0000000..0a04da5 --- /dev/null +++ b/src/elements/ia-donation-form/icons/lock.svg @@ -0,0 +1,6 @@ + + + diff --git a/src/elements/ia-donation-form/icons/paypal.svg b/src/elements/ia-donation-form/icons/paypal.svg new file mode 100644 index 0000000..f942ef9 --- /dev/null +++ b/src/elements/ia-donation-form/icons/paypal.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/elements/ia-donation-form/icons/user.svg b/src/elements/ia-donation-form/icons/user.svg new file mode 100644 index 0000000..472e8c2 --- /dev/null +++ b/src/elements/ia-donation-form/icons/user.svg @@ -0,0 +1,6 @@ + + + diff --git a/src/elements/ia-donation-form/icons/venmo.svg b/src/elements/ia-donation-form/icons/venmo.svg new file mode 100644 index 0000000..f95249c --- /dev/null +++ b/src/elements/ia-donation-form/icons/venmo.svg @@ -0,0 +1,9 @@ + + + + + + diff --git a/src/elements/ia-donation-form/modals/ia-donation-confirm-modal.test.ts b/src/elements/ia-donation-form/modals/ia-donation-confirm-modal.test.ts new file mode 100644 index 0000000..e211eef --- /dev/null +++ b/src/elements/ia-donation-form/modals/ia-donation-confirm-modal.test.ts @@ -0,0 +1,85 @@ +import { fixture } from '@open-wc/testing-helpers'; +import { describe, expect, test } from 'vitest'; +import { html } from 'lit'; + +import { DonationType } from '../models/donation-type'; +import type { IADonationConfirmModal } from './ia-donation-confirm-modal'; +import './ia-donation-confirm-modal'; + +function text(el: IADonationConfirmModal): string { + return el + .shadowRoot!.querySelector('p')! + .textContent!.replace(/\s+/g, ' ') + .trim(); +} + +describe('IADonationConfirmModal', () => { + test('describes a one-time donation and offers to complete it', async () => { + const el = await fixture( + html``, + ); + + expect(text(el)).to.equal( + 'You are about to make a one-time donation of $33.00 USD to the Internet Archive.', + ); + expect( + el.shadowRoot!.querySelector('#confirm')?.textContent?.trim(), + ).to.equal('Complete donation'); + }); + + test('describes an upsell as the start of monthly donations', async () => { + const el = await fixture( + html``, + ); + + expect(text(el)).to.include('begin making monthly donations of $8.00 USD'); + expect(text(el)).to.include( + 'first recurring contribution will be next month', + ); + expect( + el.shadowRoot!.querySelector('#confirm')?.textContent?.trim(), + ).to.equal('Start monthly donation'); + }); + + test('uses the symbol for the currency', async () => { + const el = await fixture( + html``, + ); + + expect(el.currencySymbol).to.equal('€'); + expect(text(el)).to.include('€10.00 EUR'); + + el.currencyType = 'CAD'; + expect(el.currencySymbol).to.equal('CA$'); + el.currencyType = 'NZD'; + expect(el.currencySymbol).to.equal('$'); + }); + + test('calls back on confirm and cancel', async () => { + let confirmed = 0; + let cancelled = 0; + const el = await fixture( + html` confirmed++} + .cancelDonation=${() => cancelled++} + >`, + ); + + el.shadowRoot!.querySelector('#confirm')!.click(); + el.shadowRoot!.querySelector('#cancel')!.click(); + + expect(confirmed).to.equal(1); + expect(cancelled).to.equal(1); + }); +}); diff --git a/src/elements/ia-donation-form/modals/ia-donation-confirm-modal.ts b/src/elements/ia-donation-form/modals/ia-donation-confirm-modal.ts new file mode 100644 index 0000000..b8148fd --- /dev/null +++ b/src/elements/ia-donation-form/modals/ia-donation-confirm-modal.ts @@ -0,0 +1,190 @@ +import { + css, + html, + LitElement, + type CSSResultGroup, + type TemplateResult, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { msg } from '@lit/localize'; +import currency from 'currency.js'; + +import themeStyles from '@src/themes/theme-styles'; +import { DonationType } from '../models/donation-type'; + +/** + * The confirmation step shown before a PayPal donation goes through: what the + * donor is about to give, with Complete and Cancel buttons. + */ +@customElement('ia-donation-confirm-modal') +export class IADonationConfirmModal extends LitElement { + @property({ type: Number }) amount = 5; + + /** An ISO currency code, e.g. `USD`. Drives the symbol shown with the amount. */ + @property({ type: String }) currencyType = 'USD'; + + @property({ type: String }) donationType: DonationType = DonationType.OneTime; + + @property({ attribute: false }) confirmDonation: () => void = () => {}; + + @property({ attribute: false }) cancelDonation: () => void = () => {}; + + private get formattedAmount(): string { + return currency(this.amount, { symbol: this.currencySymbol }).format(); + } + + get confirmationText(): TemplateResult { + return msg(html` +

+ You are about to make a ${this.donationType} donation of + ${this.formattedAmount} ${this.currencyType} to the Internet + Archive. +

+ `); + } + + get confirmUpsellText(): TemplateResult { + return msg(html` +

+ You are about to begin making monthly donations of + ${this.formattedAmount} ${this.currencyType} to the Internet + Archive. (Your first recurring contribution will be next month.) +

+ `); + } + + get confirmCTA(): string { + return this.donationType === DonationType.Upsell + ? msg('Start monthly donation') + : msg('Complete donation'); + } + + render(): TemplateResult { + return html` + ${this.donationType === DonationType.Upsell + ? this.confirmUpsellText + : this.confirmationText} + +
+ + +
+ `; + } + + static get styles(): CSSResultGroup { + return [ + themeStyles, + css` + :host { + --donation-confirm-base-font-size--: var( + --ia-donation-form-base-font-size, + 10px + ); + --donation-confirm-cta-color--: var( + --ia-donation-upsell-cta-button-color, + var(--navy-blue) + ); + --donation-confirm-cta-disabled-color--: var( + --ia-donation-upsell-cta-button-disabled-color, + rgba(109, 148, 201, 0.5) + ); + + display: block; + } + + button { + outline: none; + cursor: pointer; + } + + button#confirm { + font-size: calc(var(--donation-confirm-base-font-size--) * 2); + display: block; + width: 100%; + margin-top: var(--padding-sm); + padding: var(--donation-confirm-base-font-size--) + calc(var(--donation-confirm-base-font-size--) * 2); + background-color: var(--donation-confirm-cta-color--); + color: var(--true-white); + border-radius: 5px; + border: 0; + font-weight: bold; + line-height: normal; + } + + button#cancel { + margin-top: var(--donation-confirm-base-font-size--); + border: 0; + text-decoration: underline; + background-color: transparent; + } + + button:disabled { + background-color: var(--donation-confirm-cta-disabled-color--); + cursor: not-allowed; + } + `, + ]; + } + + /** + * The symbol for a PayPal-supported currency code. Dollars for anything + * unlisted, which covers USD and NZD. + * https://developer.paypal.com/docs/reports/reference/paypal-supported-currencies/ + */ + get currencySymbol(): string { + switch (this.currencyType) { + case 'AUD': + return 'AU$'; + case 'BRL': + return 'R$'; + case 'CAD': + return 'CA$'; + case 'CHF': + return 'Fr'; + case 'CNY': + return '¥'; + case 'CZK': + return 'Kč'; + case 'DKK': + return 'Kr'; + case 'EUR': + return '€'; + case 'GBP': + return '£'; + case 'HKD': + return 'HK$'; + case 'HUF': + return 'Ft'; + case 'ILS': + return '₪'; + case 'JPY': + return '¥'; + case 'MXN': + return 'MX$'; + case 'MYR': + return 'RM'; + case 'NOK': + return 'kr'; + case 'PLN': + return 'zł'; + case 'RUB': + return '₽'; + case 'SEK': + return 'kr'; + case 'SGD': + return 'S$'; + case 'THB': + return '฿'; + case 'TWD': + return 'NT$'; + default: + return '$'; + } + } +} diff --git a/src/elements/ia-donation-form/modals/ia-donation-error-modal-content.test.ts b/src/elements/ia-donation-form/modals/ia-donation-error-modal-content.test.ts new file mode 100644 index 0000000..bec6b36 --- /dev/null +++ b/src/elements/ia-donation-form/modals/ia-donation-error-modal-content.test.ts @@ -0,0 +1,23 @@ +import { fixture } from '@open-wc/testing-helpers'; +import { describe, expect, test } from 'vitest'; +import { html } from 'lit'; + +import type { IADonationErrorModalContent } from './ia-donation-error-modal-content'; +import './ia-donation-error-modal-content'; + +describe('IADonationErrorModalContent', () => { + test('renders a "Questions?" link to the help article in a new tab', async () => { + const el = await fixture( + html``, + ); + + const link = + el.shadowRoot!.querySelector('.container a')!; + expect(link.textContent?.trim()).to.equal('Questions?'); + expect(link.href).to.equal( + 'https://help.archive.org/help/why-is-there-a-problem-processing-my-donation/', + ); + expect(link.target).to.equal('_blank'); + expect(link.rel).to.equal('noopener'); + }); +}); diff --git a/src/elements/ia-donation-form/modals/ia-donation-error-modal-content.ts b/src/elements/ia-donation-form/modals/ia-donation-error-modal-content.ts new file mode 100644 index 0000000..afbf607 --- /dev/null +++ b/src/elements/ia-donation-form/modals/ia-donation-error-modal-content.ts @@ -0,0 +1,67 @@ +import { + css, + html, + LitElement, + type CSSResultGroup, + type TemplateResult, +} from 'lit'; +import { customElement } from 'lit/decorators.js'; +import { msg } from '@lit/localize'; + +import themeStyles from '@src/themes/theme-styles'; + +/** The footer of the error modal: a link to the help article on failed donations. */ +@customElement('ia-donation-error-modal-content') +export class IADonationErrorModalContent extends LitElement { + render(): TemplateResult { + return html` + + `; + } + + static get styles(): CSSResultGroup { + return [ + themeStyles, + css` + :host { + --donation-error-modal-base-font-size--: var( + --ia-donation-form-base-font-size, + 10px + ); + --donation-error-modal-link-top-margin--: var( + --ia-donation-error-modal-link-top-margin, + var(--donation-error-modal-base-font-size--) + ); + --donation-error-modal-link-color--: var( + --ia-donation-error-modal-link-color, + var(--mid-gray) + ); + --donation-error-modal-link-font-size--: var( + --ia-donation-error-modal-link-font-size, + calc(var(--donation-error-modal-base-font-size--) * 1.4) + ); + } + + .container { + margin-top: var(--donation-error-modal-link-top-margin--); + text-align: center; + } + + a, + a:link, + a:visited { + color: var(--donation-error-modal-link-color--); + font-size: var(--donation-error-modal-link-font-size--); + } + `, + ]; + } +} diff --git a/src/elements/ia-donation-form/modals/ia-donation-upsell-modal-content.test.ts b/src/elements/ia-donation-form/modals/ia-donation-upsell-modal-content.test.ts new file mode 100644 index 0000000..b59da88 --- /dev/null +++ b/src/elements/ia-donation-form/modals/ia-donation-upsell-modal-content.test.ts @@ -0,0 +1,166 @@ +import { fixture } from '@open-wc/testing-helpers'; +import { describe, expect, test } from 'vitest'; +import { html } from 'lit'; + +import { oneEvent } from '../test-helpers/dom.test-helpers'; +import { + UpsellModalCTAMode, + type IADonationUpsellModalContent, +} from './ia-donation-upsell-modal-content'; +import './ia-donation-upsell-modal-content'; + +function amountInput(el: IADonationUpsellModalContent): HTMLInputElement { + return el.shadowRoot!.querySelector('#amount-input')!; +} + +function typeAmount(el: IADonationUpsellModalContent, value: string): void { + const input = amountInput(el); + input.value = value; + input.dispatchEvent(new Event('input')); +} + +function errorText(el: IADonationUpsellModalContent): string { + return el + .shadowRoot!.querySelector('.error')! + .textContent!.replace(/\s+/g, ' ') + .trim(); +} + +describe('IADonationUpsellModalContent', () => { + describe('buttons', () => { + test('shows a continue button', async () => { + const el = await fixture( + html``, + ); + + expect(el.shadowRoot!.querySelector('#no-button')).to.exist; + }); + + test('shows the yes button in YesButton mode', async () => { + const el = await fixture( + html``, + ); + + expect(el.shadowRoot!.querySelector('#yes-button')).to.exist; + expect(el.shadowRoot!.querySelector('.paypal-upsell-slot-container')).to + .be.null; + }); + + test('shows the PayPal slot in PayPalUpsellSlot mode', async () => { + const el = await fixture( + html``, + ); + + expect(el.shadowRoot!.querySelector('.paypal-upsell-slot-container')).to + .exist; + expect(el.shadowRoot!.querySelector('#yes-button')).to.be.null; + }); + }); + + describe('events', () => { + test('emits yesSelected with the amount', async () => { + const el = await fixture( + html``, + ); + + const selected = oneEvent(el, 'yesSelected'); + el.shadowRoot!.querySelector('#yes-button')!.click(); + expect((await selected).detail.amount).to.equal(7.5); + }); + + test('emits noThanksSelected on continue', async () => { + const el = await fixture( + html``, + ); + + const selected = oneEvent(el, 'noThanksSelected'); + el.shadowRoot!.querySelector('#no-button')!.click(); + expect(await selected).to.exist; + }); + }); + + describe('amount', () => { + test('emits amountChanged with a valid amount', async () => { + const el = await fixture( + html``, + ); + + const changed = oneEvent(el, 'amountChanged'); + typeAmount(el, '3.50'); + expect((await changed).detail.amount).to.equal(3.5); + expect(el.amount).to.equal(3.5); + }); + + test('does nothing for an empty field', async () => { + const el = await fixture( + html``, + ); + + typeAmount(el, ''); + await el.updateComplete; + expect(el.error).to.be.undefined; + expect(el.amount).to.equal(5); + }); + + test('shows an error for a non-number', async () => { + const el = await fixture( + html``, + ); + + typeAmount(el, 'a'); + await el.updateComplete; + expect(errorText(el)).to.equal('Please enter a valid amount.'); + }); + + test('shows an error and disables yes for an amount under $1', async () => { + const el = await fixture( + html``, + ); + + typeAmount(el, '0.99'); + await el.updateComplete; + expect(errorText(el)).to.equal('The minimum donation amount is $1.'); + expect( + el.shadowRoot!.querySelector('#yes-button')! + .disabled, + ).to.be.true; + }); + + test('shows an error for $10,000 or more', async () => { + const el = await fixture( + html``, + ); + + typeAmount(el, '10000'); + await el.updateComplete; + expect(errorText(el)).to.include('To make a donation of $10,000 or more'); + }); + + test('covers the PayPal button while there is an error', async () => { + const el = await fixture( + html``, + ); + const blocker = el.shadowRoot!.querySelector( + '.paypal-upsell-slot-blocker', + )!; + expect(blocker.classList.contains('hidden')).to.be.true; + + typeAmount(el, '0.5'); + await el.updateComplete; + expect(blocker.classList.contains('hidden')).to.be.false; + + typeAmount(el, '5'); + await el.updateComplete; + expect(blocker.classList.contains('hidden')).to.be.true; + }); + }); +}); diff --git a/src/elements/ia-donation-form/modals/ia-donation-upsell-modal-content.ts b/src/elements/ia-donation-form/modals/ia-donation-upsell-modal-content.ts new file mode 100644 index 0000000..654731d --- /dev/null +++ b/src/elements/ia-donation-form/modals/ia-donation-upsell-modal-content.ts @@ -0,0 +1,323 @@ +import { + css, + html, + LitElement, + nothing, + type CSSResultGroup, + type TemplateResult, +} from 'lit'; +import { customElement, property, query } from 'lit/decorators.js'; +import { msg } from '@lit/localize'; + +import themeStyles from '@src/themes/theme-styles'; +import { CurrencyValidator } from '../currency-validator'; + +/** How the "yes" side of the upsell is offered: our own button, or a slot for the PayPal button. */ +export const UpsellModalCTAMode = { + YesButton: 'YesButton', + PayPalUpsellSlot: 'PayPalUpsellSlot', +} as const; + +export type UpsellModalCTAMode = + (typeof UpsellModalCTAMode)[keyof typeof UpsellModalCTAMode]; + +/** + * The content of the upsell modal shown after a one-time donation: a thank + * you, a monthly amount field, and a yes button (or the PayPal button, when + * the donation came through PayPal). + */ +@customElement('ia-donation-upsell-modal-content') +export class IADonationUpsellModalContent extends LitElement { + @property({ type: String }) yesButtonMode: UpsellModalCTAMode = + UpsellModalCTAMode.YesButton; + + @property({ type: Number }) amount = 5; + + @property({ type: Object }) error?: TemplateResult; + + @query('#amount-input') amountInput!: HTMLInputElement; + + private currencyValidator: CurrencyValidator = new CurrencyValidator(); + + render(): TemplateResult { + return html` +

${msg('Thank you for donating!')}

+ +

${msg('or')}

+

${msg('Join our Monthly Giving Circle')}

+

+ ${msg('Monthly support helps us reliably plan for the future.')} +

+
+

${msg('Enter your monthly amount')}

+
+ $ + +
+
${this.error}
+
+ + ${this.yesButton} + `; + } + + private get yesButton(): TemplateResult | typeof nothing { + switch (this.yesButtonMode) { + case UpsellModalCTAMode.YesButton: + return html` + + `; + case UpsellModalCTAMode.PayPalUpsellSlot: + return html` +
+
+ + +
+ `; + default: + return nothing; + } + } + + private amountChanged(e: Event): void { + const target = e.target as HTMLInputElement; + const amount = target.value; + if (amount.length === 0) { + return; + } + this.handleCustomAmountInput(amount); + } + + private handleCustomAmountInput(value: string): void { + const amount = parseFloat(value); + if (isNaN(amount)) { + this.error = html`${msg('Please enter a valid amount.')}`; + } else { + this.processAmount(amount); + } + } + + private processAmount(amount: number): void { + if (amount >= 10000) { + this.error = msg(html` + To make a donation of $10,000 or more, please contact our philanthropy + department at + donations@archive.org + `); + return; + } + + if (amount < 1) { + if (this.amountInput && this.amountInput.value.length > 0) { + this.error = html`${msg('The minimum donation amount is $1.')}`; + } + return; + } + + this.error = undefined; + + this.amount = amount; + + const event = new CustomEvent('amountChanged', { + detail: { amount: this.amount }, + }); + this.dispatchEvent(event); + } + + private yesSelected(): void { + const event = new CustomEvent('yesSelected', { + detail: { amount: this.amount }, + }); + this.dispatchEvent(event); + } + + private noThanksSelected(): void { + this.dispatchEvent(new Event('noThanksSelected')); + } + + static get styles(): CSSResultGroup { + return [ + themeStyles, + css` + :host { + --donation-upsell-base-font-size--: var( + --ia-donation-form-base-font-size, + 10px + ); + --donation-upsell-cta-color--: var( + --ia-donation-upsell-cta-button-color, + var(--navy-blue) + ); + --donation-upsell-cta-disabled-color--: var( + --ia-donation-upsell-cta-button-disabled-color, + rgba(109, 148, 201, 0.5) + ); + /* Shifts the amount field left a little so the dollar sign and field read as centred */ + --donation-upsell-amount-input-offset--: var( + --ia-donation-upsell-amount-input-offset, + calc(var(--donation-upsell-base-font-size--) * -1) + ); + --donation-upsell-error-color--: var(--color-danger); + } + + .monthly-amount { + background-color: var(--true-white); + padding: var(--padding-sm) 0.625rem; + border-radius: 5px; + text-align: center; + margin-bottom: var(--padding-sm); + margin-top: 0; + } + + .monthly-amount h1 { + font-size: calc(var(--donation-upsell-base-font-size--) * 1.8); + font-weight: bold; + text-align: center; + line-height: 1.2em; + margin: 0; + padding: var(--padding-sm) 0 0 0; + } + + .hidden { + display: none; + } + + h3 { + text-align: center; + font-size: calc(var(--donation-upsell-base-font-size--) * 1.8); + margin: 0 var(--donation-upsell-base-font-size--) var(--padding-sm) + var(--donation-upsell-base-font-size--); + } + + .appeal { + text-align: center; + font-size: calc(var(--donation-upsell-base-font-size--) * 1.6); + margin: var(--padding-sm) var(--donation-upsell-base-font-size--); + } + + .amount-input { + transform: translate(var(--donation-upsell-amount-input-offset--), 0); + } + + .amount-input .dollar-symbol { + font-size: calc(var(--donation-upsell-base-font-size--) * 1.8); + font-weight: bold; + } + + .amount-input input { + width: 100px; + text-align: center; + border: none; + border-bottom: 1px solid gray; + font-weight: bold; + font-size: calc(var(--donation-upsell-base-font-size--) * 3.4); + } + + .cta-button { + font-size: calc(var(--donation-upsell-base-font-size--) * 2); + display: block; + width: 100%; + margin-top: var(--padding-sm); + padding: var(--donation-upsell-base-font-size--) + calc(var(--donation-upsell-base-font-size--) * 2); + background-color: var(--donation-upsell-cta-color--); + color: var(--true-white); + border-radius: 5px; + border: 0; + font-weight: bold; + line-height: normal; + outline: none; + cursor: pointer; + } + + .cta-button:disabled { + background-color: var(--donation-upsell-cta-disabled-color--); + cursor: not-allowed; + } + + .paypal-upsell-slot { + text-align: center; + } + + .paypal-upsell-slot-blocker { + position: absolute; + width: 100%; + height: calc(var(--donation-upsell-base-font-size--) * 4.5); + bottom: 0; + z-index: 250; + cursor: not-allowed; + background-color: rgba(255, 255, 255, 0.5); + } + + .paypal-upsell-slot-blocker.hidden { + display: none; + } + + #paypal-cover-button { + position: absolute; + width: 100%; + bottom: 0; + } + + .paypal-upsell-slot-container { + position: relative; + } + + .error { + font-size: calc(var(--donation-upsell-base-font-size--) * 1.4); + margin: var(--padding-sm) 0; + color: var(--donation-upsell-error-color--); + } + + .or_separator { + position: relative; + margin: 0 calc(var(--donation-upsell-base-font-size--) * 2); + font-size: calc(var(--donation-upsell-base-font-size--) * 2.6); + font-weight: bold; + text-transform: uppercase; + text-align: center; + } + + .or_separator:before { + position: absolute; + top: calc(50% - 1px); + right: 0; + left: 0; + height: 2px; + content: ''; + background: var(--mid-gray); + } + + .or_separator span { + display: inline-block; + position: relative; + padding: 0 var(--donation-upsell-base-font-size--); + background: #f5f5f7; + } + `, + ]; + } +} diff --git a/src/elements/ia-donation-form/test-helpers/dom.test-helpers.ts b/src/elements/ia-donation-form/test-helpers/dom.test-helpers.ts new file mode 100644 index 0000000..9fbbda2 --- /dev/null +++ b/src/elements/ia-donation-form/test-helpers/dom.test-helpers.ts @@ -0,0 +1,28 @@ +import type { IADonationContactForm } from '../form-elements/contact-form/ia-donation-contact-form'; + +/** Gives async work a moment to settle. */ +export function promisedSleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Resolves with the next event of that name, so an action can be awaited. */ +export function oneEvent(el: Element, name: string): Promise { + return new Promise((resolve) => { + el.addEventListener(name, (e) => resolve(e as CustomEvent), { + once: true, + }); + }); +} + +/** Fills every field of the contact form with a valid US address. */ +export function fillInContactForm(contactForm: IADonationContactForm): void { + contactForm.emailField.value = 'foo@bar.com'; + contactForm.firstNameField.value = 'Fooey'; + contactForm.lastNameField.value = 'McBarrison'; + contactForm.streetAddressField.value = '123 Fake St'; + contactForm.extendedAddressField.value = 'Apt 123'; + contactForm.localityField.value = 'San Francisco'; + contactForm.regionField.value = 'CA'; + contactForm.postalCodeField.value = '12345'; + contactForm.countryCodeAlpha2Field.value = 'US'; +} diff --git a/src/elements/ia-donation-form/test-helpers/mock-analytics.test-helpers.ts b/src/elements/ia-donation-form/test-helpers/mock-analytics.test-helpers.ts new file mode 100644 index 0000000..815bc71 --- /dev/null +++ b/src/elements/ia-donation-form/test-helpers/mock-analytics.test-helpers.ts @@ -0,0 +1,42 @@ +import type { + AnalyticsEvent, + AnalyticsManagerInterface, +} from '@internetarchive/analytics-manager'; + +import type { DonationEventLoggerInterface } from '../analytics'; + +/** Keeps the last event of each kind so tests can read it back. */ +export class MockAnalyticsManager implements AnalyticsManagerInterface { + sendPingValues?: Record; + sendEventOptions?: AnalyticsEvent; + sendEventNoSamplingOptions?: AnalyticsEvent; + events: AnalyticsEvent[] = []; + + sendPing(values: Record): void { + this.sendPingValues = values; + } + + sendEvent(options: AnalyticsEvent): void { + this.sendEventOptions = options; + this.events.push(options); + } + + sendEventNoSampling(options: AnalyticsEvent): void { + this.sendEventNoSamplingOptions = options; + this.events.push(options); + } +} + +/** Records every logged event as `[action, label]`. */ +export class MockDonationEventLogger implements DonationEventLoggerInterface { + events: [string, string | undefined][] = []; + flowEvents: [string, string | undefined][] = []; + + logEvent(action: string, label?: string): void { + this.events.push([action, label]); + } + + logDonationFlowEvent(action: string, label?: string): void { + this.flowEvents.push([action, label]); + } +} diff --git a/src/elements/ia-donation-form/test-helpers/mock-flow-handlers.test-helpers.ts b/src/elements/ia-donation-form/test-helpers/mock-flow-handlers.test-helpers.ts new file mode 100644 index 0000000..61094de --- /dev/null +++ b/src/elements/ia-donation-form/test-helpers/mock-flow-handlers.test-helpers.ts @@ -0,0 +1,240 @@ +import type * as braintree from 'braintree-web'; +import type * as paypal from 'paypal-checkout-components'; +import { createNanoEvents, type Emitter, type Unsubscribe } from 'nanoevents'; + +import type { + PayPalButtonDataSourceDelegate, + PayPalButtonDataSourceInterface, +} from '../braintree/payment-providers/paypal/paypal-button-datasource'; +import type { ApplePayFlowHandlerInterface } from '../flow-handlers/handlers/apple-pay-flow-handler'; +import type { + CreditCardFlowHandlerEvents, + CreditCardFlowHandlerInterface, +} from '../flow-handlers/handlers/credit-card-flow-handler'; +import type { + GooglePayFlowHandlerEvents, + GooglePayFlowHandlerInterface, +} from '../flow-handlers/handlers/google-pay-flow-handler'; +import type { + PayPalFlowHandlerEvents, + PayPalFlowHandlerInterface, +} from '../flow-handlers/handlers/paypal-flow-handler'; +import type { VenmoFlowHandlerInterface } from '../flow-handlers/handlers/venmo-flow-handler'; +import type { PaymentFlowHandlersInterface } from '../flow-handlers/payment-flow-handlers'; +import { DonationPaymentInfo } from '../models/donation-payment-info'; +import { DonationType } from '../models/donation-type'; +import type { DonorContactInfo } from '../models/donor-contact-info'; + +/** A PayPal button data source that reports back on demand. */ +export class MockPayPalButtonDataSource + implements PayPalButtonDataSourceInterface +{ + delegate?: PayPalButtonDataSourceDelegate; + + donationInfo: DonationPaymentInfo = new DonationPaymentInfo({ + donationType: DonationType.OneTime, + amount: 5, + coverFees: false, + }); + + async payment(): Promise { + this.delegate?.payPalPaymentStarted(this, {}); + return 'foo'; + } + + async onAuthorize(): Promise { + this.delegate?.payPalPaymentAuthorized(this, { + nonce: 'foo', + type: 'bar', + details: { + email: 'foo@bar.com', + payerId: '1234', + firstName: 'Joe', + lastName: 'Boop', + }, + } as unknown as paypal.TokenizePayload); + return undefined; + } + + onCancel(data: paypal.CancellationData): void { + this.delegate?.payPalPaymentCancelled(this, data); + } + + onError(error: string): void { + this.delegate?.payPalPaymentError(this, error); + } +} + +export class MockCreditCardFlowHandler + implements CreditCardFlowHandlerInterface +{ + startupCalled = false; + paymentInitiatedDonationInfo?: DonationPaymentInfo; + paymentInitiatedDonorContactInfo?: DonorContactInfo; + hostedFieldsResponse?: braintree.HostedFieldsTokenizePayload; + + private emitter: Emitter = + createNanoEvents(); + + emitValidHostedFieldsEvent(): void { + this.emitter.emit('validityChanged', true); + } + + async startup(): Promise { + this.startupCalled = true; + } + + async tokenizeFields(): Promise< + braintree.HostedFieldsTokenizePayload | undefined + > { + return undefined; + } + + async paymentInitiated( + hostedFieldsResponse: braintree.HostedFieldsTokenizePayload, + donationInfo: DonationPaymentInfo, + donorContactInfo: DonorContactInfo, + ): Promise { + this.hostedFieldsResponse = hostedFieldsResponse; + this.paymentInitiatedDonationInfo = donationInfo; + this.paymentInitiatedDonorContactInfo = donorContactInfo; + } + + on( + event: E, + callback: CreditCardFlowHandlerEvents[E], + ): Unsubscribe { + return this.emitter.on(event, callback); + } +} + +export class MockPayPalFlowHandler implements PayPalFlowHandlerInterface { + private emitter: Emitter = + createNanoEvents(); + + donationInfo?: DonationPaymentInfo; + upsellDonationInfo?: DonationPaymentInfo; + renderPayPalCalledDonationInfo?: DonationPaymentInfo; + + emitPaymentStartedEvent(): void { + this.emitter.emit( + 'payPalPaymentStarted', + new MockPayPalButtonDataSource(), + {}, + ); + } + + emitPaymentCancelledEvent(): void { + this.emitter.emit( + 'payPalPaymentCancelled', + new MockPayPalButtonDataSource(), + {}, + ); + } + + emitPaymentErrorEvent(): void { + this.emitter.emit( + 'payPalPaymentError', + new MockPayPalButtonDataSource(), + 'foo-error', + ); + } + + on( + event: E, + callback: PayPalFlowHandlerEvents[E], + ): Unsubscribe { + return this.emitter.on(event, callback); + } + + updateDonationInfo(donationInfo: DonationPaymentInfo): void { + this.donationInfo = donationInfo; + } + + updateUpsellDonationInfo(donationInfo: DonationPaymentInfo): void { + this.upsellDonationInfo = donationInfo; + } + + async renderPayPalButton(donationInfo: DonationPaymentInfo): Promise { + this.renderPayPalCalledDonationInfo = donationInfo; + } +} + +export class MockApplePayFlowHandler implements ApplePayFlowHandlerInterface { + paymentInitiatedDonationInfo?: DonationPaymentInfo; + paymentInitiatedEvent?: Event; + + async paymentInitiated( + donationInfo: DonationPaymentInfo, + e: Event, + ): Promise { + this.paymentInitiatedDonationInfo = donationInfo; + this.paymentInitiatedEvent = e; + } +} + +export class MockGooglePayFlowHandler implements GooglePayFlowHandlerInterface { + private emitter: Emitter = + createNanoEvents(); + + paymentInitiatedDonationInfo?: DonationPaymentInfo; + + emitPaymentCancelledEvent(): void { + this.emitter.emit('paymentCancelled'); + } + + on( + event: E, + callback: GooglePayFlowHandlerEvents[E], + ): Unsubscribe { + return this.emitter.on(event, callback); + } + + async paymentInitiated(donationInfo: DonationPaymentInfo): Promise { + this.paymentInitiatedDonationInfo = donationInfo; + } +} + +export class MockVenmoFlowHandler implements VenmoFlowHandlerInterface { + startupCalled = false; + paymentInitiatedDonationInfo?: DonationPaymentInfo; + paymentInitiatedDonorInfo?: DonorContactInfo; + + async startup(): Promise { + this.startupCalled = true; + } + + async paymentInitiated( + contactInfo: DonorContactInfo, + donationInfo: DonationPaymentInfo, + ): Promise { + this.paymentInitiatedDonationInfo = donationInfo; + this.paymentInitiatedDonorInfo = contactInfo; + } +} + +export class MockPaymentFlowHandlers implements PaymentFlowHandlersInterface { + startupCalled = false; + + showUpsellModalOptions?: object; + showConfirmationStepModalOptions?: object; + + creditCardHandler = new MockCreditCardFlowHandler(); + paypalHandler = new MockPayPalFlowHandler(); + applePayHandler = new MockApplePayFlowHandler(); + venmoHandler = new MockVenmoFlowHandler(); + googlePayHandler = new MockGooglePayFlowHandler(); + + async showUpsellModal(options: object): Promise { + this.showUpsellModalOptions = options; + } + + async showConfirmationStepModal(options: object): Promise { + this.showConfirmationStepModalOptions = options; + } + + async startup(): Promise { + this.startupCalled = true; + this.venmoHandler.startup(); + } +} diff --git a/src/elements/ia-donation-form/test-helpers/mock-modal-manager.test-helpers.ts b/src/elements/ia-donation-form/test-helpers/mock-modal-manager.test-helpers.ts new file mode 100644 index 0000000..33323b2 --- /dev/null +++ b/src/elements/ia-donation-form/test-helpers/mock-modal-manager.test-helpers.ts @@ -0,0 +1,42 @@ +import type { + ModalConfig, + ModalManagerInterface, +} from '@internetarchive/modal-manager'; +import { ModalManagerMode } from '@internetarchive/modal-manager'; +import { html, LitElement, type TemplateResult } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +/** Records what it was asked to show instead of rendering a modal. */ +@customElement('test-mock-modal-manager') +export class MockModalManager + extends LitElement + implements ModalManagerInterface +{ + closeCalled = false; + + showModalOptions?: { + config: ModalConfig; + customModalContent?: TemplateResult; + userClosedModalCallback?: () => void; + }; + + closeModal(): void { + this.closeCalled = true; + } + + getMode(): ModalManagerMode { + return ModalManagerMode.Closed; + } + + async showModal(options: { + config: ModalConfig; + customModalContent?: TemplateResult; + userClosedModalCallback?: () => void; + }): Promise { + this.showModalOptions = options; + } + + render(): TemplateResult { + return html``; + } +} diff --git a/src/elements/index.ts b/src/elements/index.ts index 1d0191e..fa8f60b 100644 --- a/src/elements/index.ts +++ b/src/elements/index.ts @@ -25,10 +25,29 @@ export * from './ia-donation-form/braintree/payment-providers/paypal/paypal-inte export * from './ia-donation-form/braintree/payment-providers/venmo/venmo'; export * from './ia-donation-form/braintree/payment-providers/venmo/venmo-interface'; export * from './ia-donation-form/braintree/payment-providers/venmo/venmo-restoration-state-handler'; +export * from './ia-donation-form/analytics'; export * from './ia-donation-form/currency-validator'; export * from './ia-donation-form/donation-form-error'; +export * from './ia-donation-form/flow-handlers/donation-flow-modal-manager'; +export * from './ia-donation-form/flow-handlers/handlers/apple-pay-flow-handler'; +export * from './ia-donation-form/flow-handlers/handlers/credit-card-flow-handler'; +export * from './ia-donation-form/flow-handlers/handlers/google-pay-flow-handler'; +export * from './ia-donation-form/flow-handlers/handlers/paypal-flow-handler'; +export * from './ia-donation-form/flow-handlers/handlers/venmo-flow-handler'; +export * from './ia-donation-form/flow-handlers/payment-flow-handlers'; +export * from './ia-donation-form/form-elements/contact-form/ia-donation-contact-form'; +export * from './ia-donation-form/form-elements/ia-donation-badged-input'; export * from './ia-donation-form/form-elements/ia-donation-edit-donation'; +export * from './ia-donation-form/form-elements/ia-donation-header'; +export * from './ia-donation-form/form-elements/ia-donation-payment-selector'; export * from './ia-donation-form/form-elements/ia-donation-section'; +export * from './ia-donation-form/form-elements/ia-donation-summary'; +export * from './ia-donation-form/form-elements/ia-donation-total-amount'; +export * from './ia-donation-form/ia-donation-form'; +export * from './ia-donation-form/ia-donation-form-controller'; +export * from './ia-donation-form/modals/ia-donation-confirm-modal'; +export * from './ia-donation-form/modals/ia-donation-error-modal-content'; +export * from './ia-donation-form/modals/ia-donation-upsell-modal-content'; export * from './ia-donation-form/models/donation-payment-info'; export * from './ia-donation-form/models/donation-request'; export * from './ia-donation-form/models/donation-response';