From 015585ea2dc40054211acf752782323b68969266 Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Mon, 14 Sep 2026 23:00:04 -0700 Subject: [PATCH 1/3] WEBDEV-9054: Migrate the donation form elements and controller into elements The rest of donation-form lands under src/elements/ia-donation-form/: the form, controller, header, summary, total amount, badged input, payment selector and contact form as ia-donation-* elements, the three modal contents under modals/, and the payment flow handlers with the donation flow modal manager under flow-handlers/. The icon-* packages become SVGs, masked for the monochrome ones and plain for the brand logos. The controller gets a story wired to the Braintree sandbox with a demo endpoint manager and buttons for the modals. The story renders in the light DOM, since Braintree and PayPal find their containers with document.querySelector, and only loads the third-party scripts once you press "Connect to the sandbox", so the demo page and its test stay off the network. Beyond the move: the payment selector's PayPal cover is a real button with an aria-label like the other providers, the brand logos have alt text, and the collapse rule only hides the provider buttons so the cover survives being pressed with an invalid amount. The upsell thank-you logs and calls donationSuccessful once instead of twice. The credit card container lost its duplicate class attribute so the hidden class applies. The controller no longer replaces a consumer-supplied paymentClients when environment is set in a later update. The form defaults donationInfo to the standard selection so the header renders without one. The PayPal button render reads the funding constants off window.paypal defensively. TYD is TWD, the confirm modal's currencyType defaults to USD, the hosted field placeholders go through msg(), and a Google Pay billing name is trimmed before the first/last split. The form's theming variables are --ia-donation-form-* and the base font size flows down to the section and edit elements from the form's host. modal-manager and analytics-manager are npm dependencies. Tests move to Vitest, with a mock modal manager element and mock flow handlers under test-helpers/. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QnX6VHo3EJRzcKfhzBChCf --- package.json | 2 + pnpm-lock.yaml | 42 ++ src/elements/ia-donation-form/analytics.ts | 11 + .../payment-providers/paypal/paypal.ts | 8 +- .../ia-donation-form/element-names.test.ts | 38 +- .../donation-flow-modal-manager.test.ts | 258 +++++++ .../donation-flow-modal-manager.ts | 347 +++++++++ .../handlers/apple-pay-flow-handler.ts | 117 +++ .../handlers/credit-card-flow-handler.ts | 190 +++++ .../handlers/google-pay-flow-handler.ts | 114 +++ .../handlers/paypal-flow-handler.ts | 336 +++++++++ .../handlers/venmo-flow-handler.ts | 125 ++++ .../flow-handlers/payment-flow-handlers.ts | 161 ++++ .../autocomplete-field-options.ts | 63 ++ .../form-elements/contact-form/countries.ts | 253 +++++++ .../ia-donation-contact-form.test.ts | 188 +++++ .../contact-form/ia-donation-contact-form.ts | 518 +++++++++++++ .../ia-donation-badged-input.test.ts | 69 ++ .../form-elements/ia-donation-badged-input.ts | 162 ++++ .../form-elements/ia-donation-header.test.ts | 95 +++ .../form-elements/ia-donation-header.ts | 102 +++ .../ia-donation-payment-selector.test.ts | 156 ++++ .../ia-donation-payment-selector.ts | 388 ++++++++++ .../form-elements/ia-donation-summary.test.ts | 70 ++ .../form-elements/ia-donation-summary.ts | 66 ++ .../ia-donation-total-amount.test.ts | 47 ++ .../form-elements/ia-donation-total-amount.ts | 78 ++ .../ia-donation-form-controller.test.ts | 234 ++++++ .../ia-donation-form-controller.ts | 702 ++++++++++++++++++ .../ia-donation-form-story.ts | 404 ++++++++++ .../ia-donation-form/ia-donation-form.test.ts | 217 ++++++ .../ia-donation-form/ia-donation-form.ts | 611 +++++++++++++++ src/elements/ia-donation-form/icons.ts | 39 + .../ia-donation-form/icons/apple-pay.svg | 22 + .../ia-donation-form/icons/calendar.svg | 15 + .../ia-donation-form/icons/credit-card.svg | 15 + src/elements/ia-donation-form/icons/email.svg | 3 + .../ia-donation-form/icons/google-pay.svg | 38 + .../ia-donation-form/icons/locale-pin.svg | 7 + src/elements/ia-donation-form/icons/lock.svg | 6 + .../ia-donation-form/icons/paypal.svg | 39 + src/elements/ia-donation-form/icons/user.svg | 6 + src/elements/ia-donation-form/icons/venmo.svg | 9 + .../modals/ia-donation-confirm-modal.test.ts | 85 +++ .../modals/ia-donation-confirm-modal.ts | 190 +++++ .../ia-donation-error-modal-content.test.ts | 23 + .../modals/ia-donation-error-modal-content.ts | 67 ++ .../ia-donation-upsell-modal-content.test.ts | 166 +++++ .../ia-donation-upsell-modal-content.ts | 323 ++++++++ .../test-helpers/dom.test-helpers.ts | 28 + .../mock-analytics.test-helpers.ts | 42 ++ .../mock-flow-handlers.test-helpers.ts | 240 ++++++ .../mock-modal-manager.test-helpers.ts | 42 ++ src/elements/index.ts | 19 + 54 files changed, 7592 insertions(+), 4 deletions(-) create mode 100644 src/elements/ia-donation-form/analytics.ts create mode 100644 src/elements/ia-donation-form/flow-handlers/donation-flow-modal-manager.test.ts create mode 100644 src/elements/ia-donation-form/flow-handlers/donation-flow-modal-manager.ts create mode 100644 src/elements/ia-donation-form/flow-handlers/handlers/apple-pay-flow-handler.ts create mode 100644 src/elements/ia-donation-form/flow-handlers/handlers/credit-card-flow-handler.ts create mode 100644 src/elements/ia-donation-form/flow-handlers/handlers/google-pay-flow-handler.ts create mode 100644 src/elements/ia-donation-form/flow-handlers/handlers/paypal-flow-handler.ts create mode 100644 src/elements/ia-donation-form/flow-handlers/handlers/venmo-flow-handler.ts create mode 100644 src/elements/ia-donation-form/flow-handlers/payment-flow-handlers.ts create mode 100644 src/elements/ia-donation-form/form-elements/contact-form/autocomplete-field-options.ts create mode 100644 src/elements/ia-donation-form/form-elements/contact-form/countries.ts create mode 100644 src/elements/ia-donation-form/form-elements/contact-form/ia-donation-contact-form.test.ts create mode 100644 src/elements/ia-donation-form/form-elements/contact-form/ia-donation-contact-form.ts create mode 100644 src/elements/ia-donation-form/form-elements/ia-donation-badged-input.test.ts create mode 100644 src/elements/ia-donation-form/form-elements/ia-donation-badged-input.ts create mode 100644 src/elements/ia-donation-form/form-elements/ia-donation-header.test.ts create mode 100644 src/elements/ia-donation-form/form-elements/ia-donation-header.ts create mode 100644 src/elements/ia-donation-form/form-elements/ia-donation-payment-selector.test.ts create mode 100644 src/elements/ia-donation-form/form-elements/ia-donation-payment-selector.ts create mode 100644 src/elements/ia-donation-form/form-elements/ia-donation-summary.test.ts create mode 100644 src/elements/ia-donation-form/form-elements/ia-donation-summary.ts create mode 100644 src/elements/ia-donation-form/form-elements/ia-donation-total-amount.test.ts create mode 100644 src/elements/ia-donation-form/form-elements/ia-donation-total-amount.ts create mode 100644 src/elements/ia-donation-form/ia-donation-form-controller.test.ts create mode 100644 src/elements/ia-donation-form/ia-donation-form-controller.ts create mode 100644 src/elements/ia-donation-form/ia-donation-form-story.ts create mode 100644 src/elements/ia-donation-form/ia-donation-form.test.ts create mode 100644 src/elements/ia-donation-form/ia-donation-form.ts create mode 100644 src/elements/ia-donation-form/icons.ts create mode 100644 src/elements/ia-donation-form/icons/apple-pay.svg create mode 100644 src/elements/ia-donation-form/icons/calendar.svg create mode 100644 src/elements/ia-donation-form/icons/credit-card.svg create mode 100644 src/elements/ia-donation-form/icons/email.svg create mode 100644 src/elements/ia-donation-form/icons/google-pay.svg create mode 100644 src/elements/ia-donation-form/icons/locale-pin.svg create mode 100644 src/elements/ia-donation-form/icons/lock.svg create mode 100644 src/elements/ia-donation-form/icons/paypal.svg create mode 100644 src/elements/ia-donation-form/icons/user.svg create mode 100644 src/elements/ia-donation-form/icons/venmo.svg create mode 100644 src/elements/ia-donation-form/modals/ia-donation-confirm-modal.test.ts create mode 100644 src/elements/ia-donation-form/modals/ia-donation-confirm-modal.ts create mode 100644 src/elements/ia-donation-form/modals/ia-donation-error-modal-content.test.ts create mode 100644 src/elements/ia-donation-form/modals/ia-donation-error-modal-content.ts create mode 100644 src/elements/ia-donation-form/modals/ia-donation-upsell-modal-content.test.ts create mode 100644 src/elements/ia-donation-form/modals/ia-donation-upsell-modal-content.ts create mode 100644 src/elements/ia-donation-form/test-helpers/dom.test-helpers.ts create mode 100644 src/elements/ia-donation-form/test-helpers/mock-analytics.test-helpers.ts create mode 100644 src/elements/ia-donation-form/test-helpers/mock-flow-handlers.test-helpers.ts create mode 100644 src/elements/ia-donation-form/test-helpers/mock-modal-manager.test-helpers.ts 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.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-story.ts b/src/elements/ia-donation-form/ia-donation-form-story.ts new file mode 100644 index 0000000..b077010 --- /dev/null +++ b/src/elements/ia-donation-form/ia-donation-form-story.ts @@ -0,0 +1,404 @@ +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-story') +export class IADonationFormStory 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.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'; From 9e31d03a3be17d23cb8a0b5f3115276f41707510 Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Mon, 14 Sep 2026 23:25:50 -0700 Subject: [PATCH 2/3] WEBDEV-9054: Hide the story's modal manager until a modal is open The modal-manager element paints its backdrop whenever it's in the DOM, so the demo page was covered by a black layer that swallowed every click on every story. The host is expected to hide it, as its README says. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QnX6VHo3EJRzcKfhzBChCf --- src/elements/ia-donation-form/ia-donation-form-story.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/elements/ia-donation-form/ia-donation-form-story.ts b/src/elements/ia-donation-form/ia-donation-form-story.ts index b077010..905d860 100644 --- a/src/elements/ia-donation-form/ia-donation-form-story.ts +++ b/src/elements/ia-donation-form/ia-donation-form-story.ts @@ -234,11 +234,18 @@ export class IADonationFormStory extends LitElement { z-index: 10; } + /* The modal manager covers the page whenever it is in the DOM, so + only show it while a modal is open */ ia-donation-form-story modal-manager { + display: none; --modalBottomMargin: 10px; --modalWidth: 320px; } + ia-donation-form-story modal-manager[mode='open'] { + display: block; + } + ia-donation-form-story .dev-tools h4 { margin: 0 0 0.25rem; } From f1d321652164ccbd2a697a99c0e5d29143211e7a Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Mon, 14 Sep 2026 23:35:01 -0700 Subject: [PATCH 3/3] WEBDEV-9054: Name the story after the controller it demos The demo derives the sidebar entry and the anchor from the story's file name, so ia-donation-form-story.ts showed up as while the card inside said . Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QnX6VHo3EJRzcKfhzBChCf --- ...s => ia-donation-form-controller-story.ts} | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) rename src/elements/ia-donation-form/{ia-donation-form-story.ts => ia-donation-form-controller-story.ts} (94%) diff --git a/src/elements/ia-donation-form/ia-donation-form-story.ts b/src/elements/ia-donation-form/ia-donation-form-controller-story.ts similarity index 94% rename from src/elements/ia-donation-form/ia-donation-form-story.ts rename to src/elements/ia-donation-form/ia-donation-form-controller-story.ts index 905d860..0fc4b67 100644 --- a/src/elements/ia-donation-form/ia-donation-form-story.ts +++ b/src/elements/ia-donation-form/ia-donation-form-controller-story.ts @@ -181,8 +181,8 @@ const propInputSettings: PropInputSettings[] = [ * page renders every story at once and shouldn't reach out to any of them on * its own. */ -@customElement('ia-donation-form-story') -export class IADonationFormStory extends LitElement { +@customElement('ia-donation-form-controller-story') +export class IADonationFormControllerStory extends LitElement { @state() private sandboxConnected = false; @state() private lastAnalyticsEvent = ''; @@ -224,56 +224,56 @@ export class IADonationFormStory extends LitElement { const connected = this.sandboxConnected; return html`