diff --git a/.github/workflows/jest-tests.yml b/.github/workflows/jest-tests.yml index 4237d52a..3f5021f0 100644 --- a/.github/workflows/jest-tests.yml +++ b/.github/workflows/jest-tests.yml @@ -24,3 +24,21 @@ jobs: - name: Run Jest working-directory: packages/join-flow run: npm test + + prettier: + name: Prettier + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + # Only Prettier is needed, so skip the postinstall (Composer and the + # join-flow install). + - name: Install root deps + run: npm ci --ignore-scripts + + - name: Check formatting + run: npx prettier --check packages/join-flow diff --git a/package-lock.json b/package-lock.json index d902dd06..840bee3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,20 +9,21 @@ "version": "2.0.0", "hasInstallScript": true, "devDependencies": { - "prettier": "^2.1.1", + "prettier": "^3.9.10", "typescript": "^5.9.3" } }, "node_modules/prettier": { - "version": "2.8.8", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", - "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", + "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==", "dev": true, + "license": "MIT", "bin": { - "prettier": "bin-prettier.js" + "prettier": "bin/prettier.cjs" }, "engines": { - "node": ">=10.13.0" + "node": ">=14" }, "funding": { "url": "https://github.com/prettier/prettier?sponsor=1" diff --git a/package.json b/package.json index e686971a..1224b1d6 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "prettier": "prettier --write packages/join-flow" }, "devDependencies": { - "prettier": "^2.1.1", + "prettier": "^3.9.10", "typescript": "^5.9.3" } } diff --git a/packages/join-flow/__mocks__/fileMock.js b/packages/join-flow/__mocks__/fileMock.js index 86059f36..0a445d06 100644 --- a/packages/join-flow/__mocks__/fileMock.js +++ b/packages/join-flow/__mocks__/fileMock.js @@ -1 +1 @@ -module.exports = 'test-file-stub'; +module.exports = "test-file-stub"; diff --git a/packages/join-flow/jest.config.js b/packages/join-flow/jest.config.js index 691bc076..c3403016 100644 --- a/packages/join-flow/jest.config.js +++ b/packages/join-flow/jest.config.js @@ -1,11 +1,9 @@ module.exports = { - preset: '@wordpress/jest-preset-default', - testEnvironment: 'jsdom', - testMatch: ['/src/**/*.test.(ts|tsx)'], + preset: "@wordpress/jest-preset-default", + testEnvironment: "jsdom", + testMatch: ["/src/**/*.test.(ts|tsx)"], moduleNameMapper: { - '\\.(css|scss|svg|png)$': '/__mocks__/fileMock.js', + "\\.(css|scss|svg|png)$": "/__mocks__/fileMock.js" }, - transformIgnorePatterns: [ - '/node_modules/(?!(lodash-es|@hookform)/)', - ], + transformIgnorePatterns: ["/node_modules/(?!(lodash-es|@hookform)/)"] }; diff --git a/packages/join-flow/public/index.html b/packages/join-flow/public/index.html index 4836975a..20050696 100644 --- a/packages/join-flow/public/index.html +++ b/packages/join-flow/public/index.html @@ -1,4 +1,4 @@ - + diff --git a/packages/join-flow/scss/bootstrap/alert.scss b/packages/join-flow/scss/bootstrap/alert.scss index 6bd48685..54c715a5 100644 --- a/packages/join-flow/scss/bootstrap/alert.scss +++ b/packages/join-flow/scss/bootstrap/alert.scss @@ -4,83 +4,80 @@ */ .alert { - position: relative; - padding: $alert-padding-y $alert-padding-x; - margin-bottom: $alert-margin-bottom; - border: $alert-border-width solid transparent; - @include border-radius($alert-border-radius); + position: relative; + padding: $alert-padding-y $alert-padding-x; + margin-bottom: $alert-margin-bottom; + border: $alert-border-width solid transparent; + @include border-radius($alert-border-radius); } // Headings for larger alerts .alert-heading { - // Specified to prevent conflicts of changing $headings-color - color: inherit; + // Specified to prevent conflicts of changing $headings-color + color: inherit; } // Provide class for links that match alerts .alert-link { - font-weight: $alert-link-font-weight; + font-weight: $alert-link-font-weight; } - // Dismissible alerts // // Expand the right padding and account for the close button's positioning. .alert-dismissible { - padding-right: $close-font-size + $alert-padding-x * 2; + padding-right: $close-font-size + $alert-padding-x * 2; - // Adjust close link position - .close { - position: absolute; - top: 0; - right: 0; - z-index: 2; - padding: $alert-padding-y $alert-padding-x; - color: inherit; - } + // Adjust close link position + .close { + position: absolute; + top: 0; + right: 0; + z-index: 2; + padding: $alert-padding-y $alert-padding-x; + color: inherit; + } } - // Alternate styles // // Generate contextual modifier classes for colorizing the alert. @mixin alert-variant($background, $border, $color) { - color: $color; - background: $background; - border-color: $border; - - hr { - border-top-color: color-mix(in lch, $border 95%, black), - } - - .alert-link { - color: color-mix(in lch, $color 90%, black), - } + color: $color; + background: $background; + border-color: $border; + + hr { + border-top-color: color-mix(in lch, $border 95%, black); } - + + .alert-link { + color: color-mix(in lch, $color 90%, black); + } +} .alert-danger { - @include alert-variant( - color-mix(in lch, $danger 20%, white), - color-mix(in lch, $danger 40%, white), - color-mix(in lch, $danger 50%, black), - ); + @include alert-variant( + color-mix(in lch, $danger 20%, white), + color-mix(in lch, $danger 40%, white), + color-mix(in lch, $danger 50%, black) + ); } .alert-warning { - @include alert-variant( - color-mix(in lch, $warning 20%, white), - color-mix(in lch, $warning 40%, white), - color-mix(in lch, $warning 50%, black), - ); + @include alert-variant( + color-mix(in lch, $warning 20%, white), + color-mix(in lch, $warning 40%, white), + color-mix(in lch, $warning 50%, black) + ); } .alert-info { - @include alert-variant( - color-mix(in lch, $info 20%, white), - color-mix(in lch, $info 40%, white), - color-mix(in lch, $info 50%, black), - ); + @include alert-variant( + color-mix(in lch, $info 20%, white), + color-mix(in lch, $info 40%, white), + color-mix(in lch, $info 50%, black) + ); } diff --git a/packages/join-flow/scss/components/buttons.scss b/packages/join-flow/scss/components/buttons.scss index c4b1ad30..bdb0de48 100644 --- a/packages/join-flow/scss/components/buttons.scss +++ b/packages/join-flow/scss/components/buttons.scss @@ -3,7 +3,8 @@ background-color: $primary !important; color: $white; - &:hover,&:focus { + &:hover, + &:focus { background-color: $primary-focus !important; } } @@ -20,8 +21,8 @@ &::after { content: ""; background-color: var(--btn-chevron-color, currentColor); - -webkit-mask: url('graphics/chevron.svg') no-repeat 0 0; - mask: url('graphics/chevron.svg') no-repeat 0 0; + -webkit-mask: url("graphics/chevron.svg") no-repeat 0 0; + mask: url("graphics/chevron.svg") no-repeat 0 0; height: 30px; position: absolute; right: -8px; @@ -32,4 +33,4 @@ .btn.btn-outline-dark::after { background-color: var(--btn-chevron-color, #000); -} \ No newline at end of file +} diff --git a/packages/join-flow/scss/components/details.scss b/packages/join-flow/scss/components/details.scss index c3eba87d..84b72bba 100644 --- a/packages/join-flow/scss/components/details.scss +++ b/packages/join-flow/scss/components/details.scss @@ -1,14 +1,15 @@ .details-card { - border-radius: 0.5rem; + border-radius: 0.5rem; } .details-panel { - a { - color: inherit; - text-decoration: underline; + a { + color: inherit; + text-decoration: underline; - &:hover,&:focus { - text-decoration: none; - } + &:hover, + &:focus { + text-decoration: none; } -} \ No newline at end of file + } +} diff --git a/packages/join-flow/scss/components/header.scss b/packages/join-flow/scss/components/header.scss index 49171e82..e861f931 100644 --- a/packages/join-flow/scss/components/header.scss +++ b/packages/join-flow/scss/components/header.scss @@ -1,28 +1,28 @@ .ck-join-page-header { - padding-top: 5.5rem; - position: relative; + padding-top: 5.5rem; + position: relative; - h1 { - font-size: 4rem; - left: 50%; - text-align: center; - position: absolute; - top: 1rem; - transform: translateX(-50%); - white-space: nowrap; - z-index: 1; - } + h1 { + font-size: 4rem; + left: 50%; + text-align: center; + position: absolute; + top: 1rem; + transform: translateX(-50%); + white-space: nowrap; + z-index: 1; + } - img { - position: relative; - z-index: 2; - } + img { + position: relative; + z-index: 2; + } } @include media-breakpoint-up(md) { - .ck-join-page-header { - h1 { - font-size: 6rem; - } + .ck-join-page-header { + h1 { + font-size: 6rem; } -} \ No newline at end of file + } +} diff --git a/packages/join-flow/scss/components/join-form-link.scss b/packages/join-flow/scss/components/join-form-link.scss index 1b2ddb11..5ca3e711 100644 --- a/packages/join-flow/scss/components/join-form-link.scss +++ b/packages/join-flow/scss/components/join-form-link.scss @@ -1,42 +1,42 @@ .ck-join-form-link { - background-color: $background; - border-radius: 0.5rem; - margin-bottom: 2rem; - padding: 2rem; - - .form-group { - margin-bottom: 0; - } - - h2 { - font-size: 3.75rem; - } - - p { - margin-bottom: 0; - } - - a { - color: inherit !important; - display: block; - - &:focus, - &:hover { - text-decoration: underline; - } + background-color: $background; + border-radius: 0.5rem; + margin-bottom: 2rem; + padding: 2rem; + + .form-group { + margin-bottom: 0; + } + + h2 { + font-size: 3.75rem; + } + + p { + margin-bottom: 0; + } + + a { + color: inherit !important; + display: block; + + &:focus, + &:hover { + text-decoration: underline; } + } } .ck-join-form-link-intro:not(:last-child) { - margin-bottom: 1rem; + margin-bottom: 1rem; } .ck-join-form-link-input { - display: flex; - gap: 1rem; + display: flex; + gap: 1rem; - .form-control { - flex-grow: 1; - width: auto; - } -} \ No newline at end of file + .form-control { + flex-grow: 1; + width: auto; + } +} diff --git a/packages/join-flow/scss/components/progress.scss b/packages/join-flow/scss/components/progress.scss index 91e19cb2..9439621d 100644 --- a/packages/join-flow/scss/components/progress.scss +++ b/packages/join-flow/scss/components/progress.scss @@ -39,7 +39,7 @@ li.progress-step { &.progress-step--current { .progress-text { color: black; - font-weight:bold; + font-weight: bold; } .progress-circle { @@ -138,7 +138,7 @@ li.progress-step { margin-bottom: 0; margin-right: 0.75rem; } - + .progress-line { background-color: $primary; bottom: 22px; @@ -158,4 +158,4 @@ li.progress-step { display: inline; } } -} \ No newline at end of file +} diff --git a/packages/join-flow/scss/fonts.scss b/packages/join-flow/scss/fonts.scss index 06772fc4..0cd44601 100644 --- a/packages/join-flow/scss/fonts.scss +++ b/packages/join-flow/scss/fonts.scss @@ -1,28 +1,28 @@ h2 { - font-family: $font-regular; + font-family: $font-regular; } .text-md { - font-family: $font-regular; + font-family: $font-regular; } .text-xs { - font-family: $font-regular; + font-family: $font-regular; } .text-xxs { - font-family: $font-regular; + font-family: $font-regular; } .text-xl { - font-family: $font-heading; + font-family: $font-heading; } .text-lg, .text-l { - font-family: $font-heading; + font-family: $font-heading; } .font-family-heading { - font-family: $font-heading !important; -} \ No newline at end of file + font-family: $font-heading !important; +} diff --git a/packages/join-flow/scss/index.scss b/packages/join-flow/scss/index.scss index 094de498..3039fa02 100644 --- a/packages/join-flow/scss/index.scss +++ b/packages/join-flow/scss/index.scss @@ -3,31 +3,31 @@ @import "theme"; body { - background-color: $background; + background-color: $background; } // TODO: make this class name specific to the join block .ck-join-flow { - @import "bootstrap"; - @import "util.scss"; - @import "typography.scss"; - @import "components/buttons.scss"; - @import "components/details.scss"; - @import "components/forms.scss"; - @import "components/header.scss"; - @import "components/join-form-link.scss"; - @import "components/progress.scss"; - @import "components/page.scss"; + @import "bootstrap"; + @import "util.scss"; + @import "typography.scss"; + @import "components/buttons.scss"; + @import "components/details.scss"; + @import "components/forms.scss"; + @import "components/header.scss"; + @import "components/join-form-link.scss"; + @import "components/progress.scss"; + @import "components/page.scss"; } .ck-join-flow.custom-fonts { - @import "fonts.scss"; + @import "fonts.scss"; } .ck-join-form { - max-width: none !important; + max-width: none !important; - @include media-breakpoint-up(md) { - display: flex; - } -} \ No newline at end of file + @include media-breakpoint-up(md) { + display: flex; + } +} diff --git a/packages/join-flow/scss/theme.scss b/packages/join-flow/scss/theme.scss index 2664d4bf..b4d8f968 100644 --- a/packages/join-flow/scss/theme.scss +++ b/packages/join-flow/scss/theme.scss @@ -8,10 +8,19 @@ } $black: var(--wp--preset--color--black, #212529); -$gray: var(--ck-join-form-gray-color, var(--wp--preset--color--cyan-bluish-gray, #dfdcda)); -$background: var(--ck-join-form-background-color, var(--wp--preset--color--base, #f4f1ee)); - -$primary: var(--ck-join-form-primary-color, var(--wp--preset--color--accent, #007bff)); +$gray: var( + --ck-join-form-gray-color, + var(--wp--preset--color--cyan-bluish-gray, #dfdcda) +); +$background: var( + --ck-join-form-background-color, + var(--wp--preset--color--base, #f4f1ee) +); + +$primary: var( + --ck-join-form-primary-color, + var(--wp--preset--color--accent, #007bff) +); $primary-focus: native-rgb(from $primary r g b, 0.6); $danger: var(--wp--preset--color--accent-5, #e3220c); diff --git a/packages/join-flow/src/app.tsx b/packages/join-flow/src/app.tsx index d70f5c9e..3fbe5fdc 100644 --- a/packages/join-flow/src/app.tsx +++ b/packages/join-flow/src/app.tsx @@ -20,9 +20,21 @@ import { import { useOnce } from "./hooks/util"; import { PaymentDetailsPage } from "./pages/payment-details.page"; import { Stager } from "./components/stager"; -import { FormSchema, getPaymentPlan, getPlanRedirectUrl, getTestDataIfEnabled } from "./schema"; +import { + FormSchema, + getPaymentPlan, + getPlanRedirectUrl, + getTestDataIfEnabled +} from "./schema"; import { ConfirmationPage } from "./pages/confirm.page"; -import { get as getEnv, getStr as getEnvStr, getPaymentMethods, getPaymentProviders, PaymentMethod, PaymentProvider } from "./env"; +import { + get as getEnv, + getStr as getEnvStr, + getPaymentMethods, + getPaymentProviders, + PaymentMethod, + PaymentProvider +} from "./env"; import { usePostResource } from "./services/rest-resource.service"; import gocardless from "./images/gocardless.svg"; import chargebee from "./images/chargebee.png"; @@ -60,7 +72,11 @@ const stripePromise = loadStripe(getEnv("STRIPE_PUBLISHABLE_KEY")); const App = () => { let stages: Stage[] = [ { id: "enter-details", label: "Your Details", breadcrumb: true }, - { id: "plan", label: getEnvStr("MEMBERSHIP_STAGE_LABEL"), breadcrumb: true }, + { + id: "plan", + label: getEnvStr("MEMBERSHIP_STAGE_LABEL"), + breadcrumb: true + }, { id: "donation", label: "Can you chip in?", breadcrumb: false }, { id: "payment-details", label: "Payment", breadcrumb: true }, { id: "payment-method", label: "Payment", breadcrumb: false }, @@ -73,11 +89,11 @@ const App = () => { if (getEnv("DONATION_SUPPORTER_MODE")) { stages = [ - { id: "donation", label: "Your Donation", breadcrumb: true }, - { id: "enter-details", label: "Your Details", breadcrumb: true }, - { id: "payment-details", label: "Payment", breadcrumb: true }, - { id: "payment-method", label: "Payment", breadcrumb: false }, - { id: "confirm", label: "Confirm", breadcrumb: false } + { id: "donation", label: "Your Donation", breadcrumb: true }, + { id: "enter-details", label: "Your Details", breadcrumb: true }, + { id: "payment-details", label: "Payment", breadcrumb: true }, + { id: "payment-method", label: "Payment", breadcrumb: false }, + { id: "confirm", label: "Confirm", breadcrumb: false } ]; } @@ -116,10 +132,9 @@ const App = () => { usePostResource>( "/gocardless/auth" ); - const getChargeBeeRedirect = - usePostResource>( - "/chargebee/hosted-page" - ); + const getChargeBeeRedirect = usePostResource< + Partial + >("/chargebee/hosted-page"); const currentIndex = stages.findIndex((x) => x.id === router.state.stage); const handlePageCompleted = useCallback( @@ -141,9 +156,12 @@ const App = () => { nextStage = "enter-details"; } else if (router.state.stage === "enter-details") { nextStage = "payment-method"; - const response: any = await recordStep({ ...nextData, stage: "enter-details" }); - if (response?.status === 'blocked') { - setBlockingMessage(response.message || 'Unable to proceed.'); + const response: any = await recordStep({ + ...nextData, + stage: "enter-details" + }); + if (response?.status === "blocked") { + setBlockingMessage(response.message || "Unable to proceed."); return; } setBlockingMessage(null); @@ -152,11 +170,16 @@ const App = () => { } else if (router.state.stage === "enter-details") { nextStage = "plan"; // Send initial details to catch drop off - const response: any = await recordStep({ ...nextData, stage: "enter-details" }); + const response: any = await recordStep({ + ...nextData, + stage: "enter-details" + }); // Check if the form progression is blocked - if (response?.status === 'blocked') { - setBlockingMessage(response.message || 'Unable to proceed with this submission.'); + if (response?.status === "blocked") { + setBlockingMessage( + response.message || "Unable to proceed with this submission." + ); return; // Stop progression } @@ -180,9 +203,13 @@ const App = () => { // (e.g., when there are multiple payment methods to choose from) const plan = getPaymentPlan(nextData.membership); const isAmountZero = plan && Number(plan.amount) === 0; - const isCustomAmountAboveZero = (plan && plan.allowCustomAmount) ? Number(nextData.customMembershipAmount) > 0 : false; - const hasDonation = nextData.donationAmount && Number(nextData.donationAmount) > 0; - + const isCustomAmountAboveZero = + plan && plan.allowCustomAmount + ? Number(nextData.customMembershipAmount) > 0 + : false; + const hasDonation = + nextData.donationAmount && Number(nextData.donationAmount) > 0; + // Skip payment only if membership is free, no custom amount, AND no donation if (isAmountZero && !isCustomAmountAboveZero && !hasDonation) { nextStage = "confirm"; @@ -206,11 +233,19 @@ const App = () => { // payment-method page - both are needed to cover all navigation paths. const plan = getPaymentPlan(nextData.membership); const isAmountZero = plan && Number(plan.amount) === 0; - const isCustomAmountAboveZero = (plan && plan.allowCustomAmount) ? Number(nextData.customMembershipAmount) > 0 : false; - const hasDonation = nextData.donationAmount && Number(nextData.donationAmount) > 0; - + const isCustomAmountAboveZero = + plan && plan.allowCustomAmount + ? Number(nextData.customMembershipAmount) > 0 + : false; + const hasDonation = + nextData.donationAmount && Number(nextData.donationAmount) > 0; + // Only skip payment if there's truly nothing to pay (no membership fee, no custom amount, no donation) - const shouldSkipPayment = nextStage === "payment-method" && isAmountZero && !isCustomAmountAboveZero && !hasDonation; + const shouldSkipPayment = + nextStage === "payment-method" && + isAmountZero && + !isCustomAmountAboveZero && + !hasDonation; if (shouldSkipPayment) { // For zero-price memberships with no donation, skip directly to confirmation @@ -218,7 +253,10 @@ const App = () => { } else if (nextStage === "payment-method") { const paymentProviders = getPaymentProviders(); const paymentMethods = getPaymentMethods(); - if (Object.keys(paymentProviders).length < 2 || paymentMethods.length < 2) { + if ( + Object.keys(paymentProviders).length < 2 || + paymentMethods.length < 2 + ) { // Auto-skip payment method selection if there's only one option available // This only runs if we haven't already skipped payment entirely above nextStage = "payment-details"; @@ -282,22 +320,26 @@ const App = () => { [router, data, setBlockingMessage] ); - const paymentProviders: PaymentProvider[] = Object.keys(getPaymentProviders()) as PaymentProvider[]; - const paymentProviderLogos = paymentProviders.map((provider: PaymentProvider) => { - return provider === "gocardless" ? ( - - GoCardless - - ) : provider === "chargebee" ? ( - - Chargebee - - ) : provider === "stripe" ? ( - - Stripe - - ) : null; - }); + const paymentProviders: PaymentProvider[] = Object.keys( + getPaymentProviders() + ) as PaymentProvider[]; + const paymentProviderLogos = paymentProviders.map( + (provider: PaymentProvider) => { + return provider === "gocardless" ? ( + + GoCardless + + ) : provider === "chargebee" ? ( + + Chargebee + + ) : provider === "stripe" ? ( + + Stripe + + ) : null; + } + ); const fullJoinForm = ( <> @@ -369,8 +411,12 @@ const getInitialState = (): FormSchema => { membership: membershipPlans.length ? membershipPlans[0].value : "standard", paymentMethod: defaultMethod, // Default contact flags to true if not collecting consent, or if consent is checked by default - contactByEmail: !getEnv("COLLECT_PHONE_AND_EMAIL_CONTACT_CONSENT") || !!getEnv("CONSENT_CHECKED_BY_DEFAULT"), - contactByPhone: !getEnv("COLLECT_PHONE_AND_EMAIL_CONTACT_CONSENT") || !!getEnv("CONSENT_CHECKED_BY_DEFAULT") + contactByEmail: + !getEnv("COLLECT_PHONE_AND_EMAIL_CONTACT_CONSENT") || + !!getEnv("CONSENT_CHECKED_BY_DEFAULT"), + contactByPhone: + !getEnv("COLLECT_PHONE_AND_EMAIL_CONTACT_CONSENT") || + !!getEnv("CONSENT_CHECKED_BY_DEFAULT") }); const getSavedState = () => { @@ -421,7 +467,9 @@ const getInitialState = (): FormSchema => { // donationSupporterMode is a page-level flag derived from the block config; // always apply it after session restore so a stale session from a different // form type cannot suppress it. - ...(getEnv("DONATION_SUPPORTER_MODE") ? { donationSupporterMode: true } : {}), + ...(getEnv("DONATION_SUPPORTER_MODE") + ? { donationSupporterMode: true } + : {}), ...getProvidedStateFromQueryParams(), isUpdateFlow: getEnv("IS_UPDATE_FLOW"), webhookUuid: getEnv("WEBHOOK_UUID"), @@ -429,7 +477,10 @@ const getInitialState = (): FormSchema => { } as any; // Clamp paymentMethod to available methods — session storage may have a stale value // from a previous flow (e.g. "creditCard" persisted when STRIPE_DIRECT_DEBIT_ONLY is now active) - if (availableMethods.length && !availableMethods.includes(state.paymentMethod)) { + if ( + availableMethods.length && + !availableMethods.includes(state.paymentMethod) + ) { state.paymentMethod = defaultMethod; } return state; diff --git a/packages/join-flow/src/components/atoms.tsx b/packages/join-flow/src/components/atoms.tsx index 6650a99a..1c4bfaac 100644 --- a/packages/join-flow/src/components/atoms.tsx +++ b/packages/join-flow/src/components/atoms.tsx @@ -297,7 +297,7 @@ export const FormItem: FC = ({ return ( {label && ( - + {label}{" "} {required && ( <> diff --git a/packages/join-flow/src/components/summary.tsx b/packages/join-flow/src/components/summary.tsx index e2ca4f10..8ee24e9d 100644 --- a/packages/join-flow/src/components/summary.tsx +++ b/packages/join-flow/src/components/summary.tsx @@ -1,7 +1,12 @@ import React, { FC } from "react"; import { takeWhile } from "lodash-es"; import { get as getEnv, getPaymentMethods } from "../env"; -import { FormSchema, renderDonationSummary, renderPaymentMethod, renderPaymentPlan } from "../schema"; +import { + FormSchema, + renderDonationSummary, + renderPaymentMethod, + renderPaymentPlan +} from "../schema"; import { PageState, useCurrentRouter } from "../services/router.service"; import { DetailPanel, DetailsCard } from "./atoms"; @@ -16,17 +21,22 @@ export const Summary: FC = ({ data }) => { return !getEnv("DONATION_SUPPORTER_MODE"); } if (stage === "donation") { - return getEnv("ASK_FOR_ADDITIONAL_DONATION") || getEnv("DONATION_SUPPORTER_MODE"); + return ( + getEnv("ASK_FOR_ADDITIONAL_DONATION") || + getEnv("DONATION_SUPPORTER_MODE") + ); } if (stage === "payment-method") { - return getPaymentMethods().length > 1 + return getPaymentMethods().length > 1; } if (stage === "payment-details") { // Don't show the payment details page if external GoCardless pages were used - return getEnv("USE_GOCARDLESS_API") || data.paymentMethod !== "directDebit"; + return ( + getEnv("USE_GOCARDLESS_API") || data.paymentMethod !== "directDebit" + ); } - return true - }) + return true; + }); const stages = takeWhile( validStages, (stage) => stage !== router.state.stage diff --git a/packages/join-flow/src/constants.ts b/packages/join-flow/src/constants.ts index 3c2fb7d7..e798f385 100644 --- a/packages/join-flow/src/constants.ts +++ b/packages/join-flow/src/constants.ts @@ -2,28 +2,28 @@ import isoCountries from "iso-3166"; import { PhoneNumberUtil } from "google-libphonenumber"; export const sortedCountries = isoCountries.sort((a, b) => { - // Prioritize The United Kingdom - if (a.alpha2 === "GB") { - return -1 - } - if (b.alpha2 === "GB") { - return 1 - } - return a.name < b.name ? -1 : 1 -}) + // Prioritize The United Kingdom + if (a.alpha2 === "GB") { + return -1; + } + if (b.alpha2 === "GB") { + return 1; + } + return a.name < b.name ? -1 : 1; +}); const phoneUtil = PhoneNumberUtil.getInstance(); export interface PhoneCountry { - alpha2: string; - name: string; - dialingCode: number; + alpha2: string; + name: string; + dialingCode: number; } export const phoneCountries: PhoneCountry[] = sortedCountries - .map((c) => ({ - alpha2: c.alpha2, - name: c.name, - dialingCode: phoneUtil.getCountryCodeForRegion(c.alpha2) - })) - .filter((c) => c.dialingCode > 0); \ No newline at end of file + .map((c) => ({ + alpha2: c.alpha2, + name: c.name, + dialingCode: phoneUtil.getCountryCodeForRegion(c.alpha2) + })) + .filter((c) => c.dialingCode > 0); diff --git a/packages/join-flow/src/env.test.ts b/packages/join-flow/src/env.test.ts index 50b28d6b..bf113d1a 100644 --- a/packages/join-flow/src/env.test.ts +++ b/packages/join-flow/src/env.test.ts @@ -1,4 +1,8 @@ -import { getPaymentProviders, getPaymentMethods, resolveStripePaymentMethodTypes } from './env'; +import { + getPaymentProviders, + getPaymentMethods, + resolveStripePaymentMethodTypes +} from "./env"; function withEnv(overrides: Record, fn: () => void) { const original = (window as any).process?.env; @@ -10,60 +14,67 @@ function withEnv(overrides: Record, fn: () => void) { } } -describe('getPaymentProviders — Stripe direct debit flags', () => { - it('STRIPE_DIRECT_DEBIT_ONLY alone includes directDebit', () => { +describe("getPaymentProviders — Stripe direct debit flags", () => { + it("STRIPE_DIRECT_DEBIT_ONLY alone includes directDebit", () => { withEnv({ USE_STRIPE: true, STRIPE_DIRECT_DEBIT_ONLY: true }, () => { - expect(getPaymentProviders().stripe).toContain('directDebit'); + expect(getPaymentProviders().stripe).toContain("directDebit"); }); }); - it('STRIPE_DIRECT_DEBIT_ONLY alone excludes creditCard', () => { + it("STRIPE_DIRECT_DEBIT_ONLY alone excludes creditCard", () => { withEnv({ USE_STRIPE: true, STRIPE_DIRECT_DEBIT_ONLY: true }, () => { - expect(getPaymentProviders().stripe).not.toContain('creditCard'); + expect(getPaymentProviders().stripe).not.toContain("creditCard"); }); }); - it('STRIPE_DIRECT_DEBIT_ONLY alone produces a non-empty stripe provider so a provider is always resolved', () => { + it("STRIPE_DIRECT_DEBIT_ONLY alone produces a non-empty stripe provider so a provider is always resolved", () => { withEnv({ USE_STRIPE: true, STRIPE_DIRECT_DEBIT_ONLY: true }, () => { expect(getPaymentProviders().stripe?.length).toBeGreaterThan(0); }); }); - it('STRIPE_DIRECT_DEBIT without STRIPE_DIRECT_DEBIT_ONLY includes both methods', () => { + it("STRIPE_DIRECT_DEBIT without STRIPE_DIRECT_DEBIT_ONLY includes both methods", () => { withEnv({ USE_STRIPE: true, STRIPE_DIRECT_DEBIT: true }, () => { const methods = getPaymentProviders().stripe; - expect(methods).toContain('creditCard'); - expect(methods).toContain('directDebit'); + expect(methods).toContain("creditCard"); + expect(methods).toContain("directDebit"); }); }); - it('neither direct debit flag produces creditCard only', () => { + it("neither direct debit flag produces creditCard only", () => { withEnv({ USE_STRIPE: true }, () => { - expect(getPaymentProviders().stripe).toEqual(['creditCard']); + expect(getPaymentProviders().stripe).toEqual(["creditCard"]); }); }); - it('STRIPE_DIRECT_DEBIT_ONLY explicitly false (allow_cards_override) produces creditCard only', () => { + it("STRIPE_DIRECT_DEBIT_ONLY explicitly false (allow_cards_override) produces creditCard only", () => { withEnv({ USE_STRIPE: true, STRIPE_DIRECT_DEBIT_ONLY: false }, () => { - expect(getPaymentProviders().stripe).toEqual(['creditCard']); + expect(getPaymentProviders().stripe).toEqual(["creditCard"]); }); }); - it('STRIPE_DIRECT_DEBIT_ONLY explicitly false with STRIPE_DIRECT_DEBIT true produces both methods', () => { - withEnv({ USE_STRIPE: true, STRIPE_DIRECT_DEBIT_ONLY: false, STRIPE_DIRECT_DEBIT: true }, () => { - const methods = getPaymentProviders().stripe; - expect(methods).toContain('creditCard'); - expect(methods).toContain('directDebit'); - }); + it("STRIPE_DIRECT_DEBIT_ONLY explicitly false with STRIPE_DIRECT_DEBIT true produces both methods", () => { + withEnv( + { + USE_STRIPE: true, + STRIPE_DIRECT_DEBIT_ONLY: false, + STRIPE_DIRECT_DEBIT: true + }, + () => { + const methods = getPaymentProviders().stripe; + expect(methods).toContain("creditCard"); + expect(methods).toContain("directDebit"); + } + ); }); }); -describe('getPaymentMethods — STRIPE_DIRECT_DEBIT_ONLY', () => { - it('returns directDebit and not creditCard', () => { +describe("getPaymentMethods — STRIPE_DIRECT_DEBIT_ONLY", () => { + it("returns directDebit and not creditCard", () => { withEnv({ USE_STRIPE: true, STRIPE_DIRECT_DEBIT_ONLY: true }, () => { const methods = getPaymentMethods(); - expect(methods).toContain('directDebit'); - expect(methods).not.toContain('creditCard'); + expect(methods).toContain("directDebit"); + expect(methods).not.toContain("creditCard"); }); }); }); @@ -72,42 +83,44 @@ describe('getPaymentMethods — STRIPE_DIRECT_DEBIT_ONLY', () => { // resolveStripePaymentMethodTypes — Stripe Elements paymentMethodTypes // --------------------------------------------------------------------------- -describe('resolveStripePaymentMethodTypes', () => { - it('one-off donation is always card-only regardless of direct debit flags', () => { +describe("resolveStripePaymentMethodTypes", () => { + it("one-off donation is always card-only regardless of direct debit flags", () => { withEnv({ STRIPE_DIRECT_DEBIT_ONLY: true }, () => { - expect(resolveStripePaymentMethodTypes(true, 'gbp')).toEqual(['card']); + expect(resolveStripePaymentMethodTypes(true, "gbp")).toEqual(["card"]); }); }); - it('subscription with STRIPE_DIRECT_DEBIT_ONLY uses bacs_debit, not card', () => { + it("subscription with STRIPE_DIRECT_DEBIT_ONLY uses bacs_debit, not card", () => { withEnv({ STRIPE_DIRECT_DEBIT_ONLY: true }, () => { - expect(resolveStripePaymentMethodTypes(false, 'gbp')).toEqual(['bacs_debit']); + expect(resolveStripePaymentMethodTypes(false, "gbp")).toEqual([ + "bacs_debit" + ]); }); }); - it('subscription with STRIPE_DIRECT_DEBIT (non-only) in GBP includes both card and bacs_debit', () => { + it("subscription with STRIPE_DIRECT_DEBIT (non-only) in GBP includes both card and bacs_debit", () => { withEnv({ STRIPE_DIRECT_DEBIT: true }, () => { - const types = resolveStripePaymentMethodTypes(false, 'gbp'); - expect(types).toContain('card'); - expect(types).toContain('bacs_debit'); + const types = resolveStripePaymentMethodTypes(false, "gbp"); + expect(types).toContain("card"); + expect(types).toContain("bacs_debit"); }); }); - it('subscription with no direct debit flags is card-only', () => { + it("subscription with no direct debit flags is card-only", () => { withEnv({}, () => { - expect(resolveStripePaymentMethodTypes(false, 'gbp')).toEqual(['card']); + expect(resolveStripePaymentMethodTypes(false, "gbp")).toEqual(["card"]); }); }); - it('subscription with STRIPE_DIRECT_DEBIT in non-GBP currency does not include bacs_debit', () => { + it("subscription with STRIPE_DIRECT_DEBIT in non-GBP currency does not include bacs_debit", () => { withEnv({ STRIPE_DIRECT_DEBIT: true }, () => { - expect(resolveStripePaymentMethodTypes(false, 'eur')).toEqual(['card']); + expect(resolveStripePaymentMethodTypes(false, "eur")).toEqual(["card"]); }); }); - it('subscription with STRIPE_DIRECT_DEBIT_ONLY explicitly false (allow_cards_override) returns card-only', () => { + it("subscription with STRIPE_DIRECT_DEBIT_ONLY explicitly false (allow_cards_override) returns card-only", () => { withEnv({ STRIPE_DIRECT_DEBIT_ONLY: false }, () => { - expect(resolveStripePaymentMethodTypes(false, 'gbp')).toEqual(['card']); + expect(resolveStripePaymentMethodTypes(false, "gbp")).toEqual(["card"]); }); }); }); diff --git a/packages/join-flow/src/env.ts b/packages/join-flow/src/env.ts index 8dae5ce0..92480d07 100644 --- a/packages/join-flow/src/env.ts +++ b/packages/join-flow/src/env.ts @@ -1,189 +1,228 @@ interface StaticEnv { - ABOUT_YOU_COPY: string; - ABOUT_YOU_HEADING: string; - ASK_FOR_ADDITIONAL_DONATION: boolean; - DONATION_SUPPORTER_MODE: boolean; - ENABLE_INTERNATIONAL_PHONE_NUMBERS: boolean; - CHARGEBEE_API_PUBLISHABLE_KEY: string; - CHARGEBEE_SITE_NAME: string; - COLLECT_COUNTY: boolean; - COLLECT_DATE_OF_BIRTH: boolean; - DISABLE_TEST_DATA: boolean; - REQUIRE_AGE_18_OR_OVER: boolean; - MINIMUM_AGE: number; - COLLECT_HEAR_ABOUT_US: boolean; - COLLECT_PHONE_AND_EMAIL_CONTACT_CONSENT: boolean; - CONSENT_CHECKED_BY_DEFAULT: boolean; - CONTACT_CONSENT_COPY: string; - CONTACT_DETAILS_COPY: string; - CONTACT_DETAILS_HEADING: string; - CREATE_AUTH0_ACCOUNT: boolean; - CUSTOM_FIELDS: object[]; - CUSTOM_FIELDS_HEADING: string; - DATE_OF_BIRTH_COPY: string; - DATE_OF_BIRTH_HEADING: string; - HEAR_ABOUT_US_DETAILS: string; - HEAR_ABOUT_US_HEADING: string; - HEAR_ABOUT_US_OPTIONS: string[]; - HIDE_ADDRESS: boolean; - HIDE_HOME_ADDRESS_COPY: boolean; - HIDE_ZERO_PRICE_DISPLAY: boolean; - HOME_ADDRESS_COPY: string; - JOIN_FORM_SIDEBAR_HEADING: string; - JOINING_VERB: string; - MEMBERSHIP_PLANS: object[]; - MEMBERSHIP_STAGE_LABEL: string; - MEMBERSHIP_TIERS_HEADING: string; - MEMBERSHIP_TIERS_COPY: string; - CURRENCY_SELECTION_COPY: string; - ORGANISATION_NAME: string; - ORGANISATION_BANK_NAME: string; - ORGANISATION_EMAIL_ADDRESS: string; - PASSWORD_PURPOSE: string; - POSTCODE_ADDRESS_PROVIDER: string; - PRIVACY_COPY: string; - REQUIRE_ADDRESS: boolean; - REQUIRE_EMAIL_CONSENT: boolean; - REQUIRE_PHONE_CONSENT: boolean; - REQUIRE_PHONE_NUMBER: boolean; - SENTRY_DSN: string; - STRIPE_DIRECT_DEBIT: boolean; - STRIPE_DIRECT_DEBIT_ONLY: boolean; - STRIPE_PUBLISHABLE_KEY: string; - SUBSCRIPTION_DAY_OF_MONTH_COPY: string; - SUCCESS_REDIRECT: string; - IS_UPDATE_FLOW: boolean; // email must be provided through a URL parameter - INCLUDE_SKIP_PAYMENT_BUTTON: boolean; - USE_CHARGEBEE: boolean; - USE_CHARGEBEE_HOSTED_PAGES: boolean; - USE_GOCARDLESS: boolean; - USE_GOCARDLESS_API: boolean; - USE_MAILCHIMP: boolean; - USE_POSTCODE_LOOKUP: boolean; - USE_STRIPE: boolean; - USE_TEST_DATA: boolean; - WEBHOOK_UUID: string; // Connected to a URL in the wp_options table: `SELECT option_name FROM wp_options where option_value = :uuid` - WP_REST_API: string; + ABOUT_YOU_COPY: string; + ABOUT_YOU_HEADING: string; + ASK_FOR_ADDITIONAL_DONATION: boolean; + DONATION_SUPPORTER_MODE: boolean; + ENABLE_INTERNATIONAL_PHONE_NUMBERS: boolean; + CHARGEBEE_API_PUBLISHABLE_KEY: string; + CHARGEBEE_SITE_NAME: string; + COLLECT_COUNTY: boolean; + COLLECT_DATE_OF_BIRTH: boolean; + DISABLE_TEST_DATA: boolean; + REQUIRE_AGE_18_OR_OVER: boolean; + MINIMUM_AGE: number; + COLLECT_HEAR_ABOUT_US: boolean; + COLLECT_PHONE_AND_EMAIL_CONTACT_CONSENT: boolean; + CONSENT_CHECKED_BY_DEFAULT: boolean; + CONTACT_CONSENT_COPY: string; + CONTACT_DETAILS_COPY: string; + CONTACT_DETAILS_HEADING: string; + CREATE_AUTH0_ACCOUNT: boolean; + CUSTOM_FIELDS: object[]; + CUSTOM_FIELDS_HEADING: string; + DATE_OF_BIRTH_COPY: string; + DATE_OF_BIRTH_HEADING: string; + HEAR_ABOUT_US_DETAILS: string; + HEAR_ABOUT_US_HEADING: string; + HEAR_ABOUT_US_OPTIONS: string[]; + HIDE_ADDRESS: boolean; + HIDE_HOME_ADDRESS_COPY: boolean; + HIDE_ZERO_PRICE_DISPLAY: boolean; + HOME_ADDRESS_COPY: string; + JOIN_FORM_SIDEBAR_HEADING: string; + JOINING_VERB: string; + MEMBERSHIP_PLANS: object[]; + MEMBERSHIP_STAGE_LABEL: string; + MEMBERSHIP_TIERS_HEADING: string; + MEMBERSHIP_TIERS_COPY: string; + CURRENCY_SELECTION_COPY: string; + ORGANISATION_NAME: string; + ORGANISATION_BANK_NAME: string; + ORGANISATION_EMAIL_ADDRESS: string; + PASSWORD_PURPOSE: string; + POSTCODE_ADDRESS_PROVIDER: string; + PRIVACY_COPY: string; + REQUIRE_ADDRESS: boolean; + REQUIRE_EMAIL_CONSENT: boolean; + REQUIRE_PHONE_CONSENT: boolean; + REQUIRE_PHONE_NUMBER: boolean; + SENTRY_DSN: string; + STRIPE_DIRECT_DEBIT: boolean; + STRIPE_DIRECT_DEBIT_ONLY: boolean; + STRIPE_PUBLISHABLE_KEY: string; + SUBSCRIPTION_DAY_OF_MONTH_COPY: string; + SUCCESS_REDIRECT: string; + IS_UPDATE_FLOW: boolean; // email must be provided through a URL parameter + INCLUDE_SKIP_PAYMENT_BUTTON: boolean; + USE_CHARGEBEE: boolean; + USE_CHARGEBEE_HOSTED_PAGES: boolean; + USE_GOCARDLESS: boolean; + USE_GOCARDLESS_API: boolean; + USE_MAILCHIMP: boolean; + USE_POSTCODE_LOOKUP: boolean; + USE_STRIPE: boolean; + USE_TEST_DATA: boolean; + WEBHOOK_UUID: string; // Connected to a URL in the wp_options table: `SELECT option_name FROM wp_options where option_value = :uuid` + WP_REST_API: string; } const parseBooleanEnvVar = (name: string): boolean => { - return Boolean( - process.env[name] && process.env[name] !== "false" - ) -} + return Boolean(process.env[name] && process.env[name] !== "false"); +}; // This object holds the values that are in the .env file on disk. // Dynamic values from the WordPress settings page are available in window.process.env. // The get() function below checks the dynamic values first, and falls back to this object. const staticEnv: StaticEnv = { - ABOUT_YOU_COPY: process.env.REACT_APP_ABOUT_YOU_COPY || '', - ABOUT_YOU_HEADING: process.env.REACT_APP_ABOUT_YOU_HEADING || '', - ASK_FOR_ADDITIONAL_DONATION: parseBooleanEnvVar("REACT_APP_ASK_FOR_ADDITIONAL_DONATION"), - DONATION_SUPPORTER_MODE: parseBooleanEnvVar("REACT_APP_DONATION_SUPPORTER_MODE"), - ENABLE_INTERNATIONAL_PHONE_NUMBERS: parseBooleanEnvVar("REACT_APP_ENABLE_INTERNATIONAL_PHONE_NUMBERS"), - CHARGEBEE_API_PUBLISHABLE_KEY: process.env.REACT_APP_CHARGEBEE_API_PUBLISHABLE_KEY || '', - CHARGEBEE_SITE_NAME: process.env.REACT_APP_CHARGEBEE_SITE_NAME || '', - COLLECT_COUNTY: parseBooleanEnvVar("REACT_APP_COLLECT_COUNTY"), - COLLECT_DATE_OF_BIRTH: parseBooleanEnvVar("REACT_APP_COLLECT_DATE_OF_BIRTH"), - DISABLE_TEST_DATA: parseBooleanEnvVar("REACT_APP_DISABLE_TEST_DATA"), - REQUIRE_AGE_18_OR_OVER: parseBooleanEnvVar("REACT_APP_REQUIRE_AGE_18_OR_OVER"), - MINIMUM_AGE: parseInt(process.env.REACT_APP_MINIMUM_AGE || '18', 10), - COLLECT_PHONE_AND_EMAIL_CONTACT_CONSENT: parseBooleanEnvVar("REACT_APP_COLLECT_PHONE_AND_EMAIL_CONTACT_CONSENT"), - CONSENT_CHECKED_BY_DEFAULT: parseBooleanEnvVar("REACT_APP_CONSENT_CHECKED_BY_DEFAULT"), - COLLECT_HEAR_ABOUT_US: parseBooleanEnvVar("REACT_APP_COLLECT_HEAR_ABOUT_US"), - CONTACT_CONSENT_COPY: process.env.REACT_ENV_CONTACT_CONSENT_COPY || '', - CONTACT_DETAILS_COPY: process.env.REACT_APP_CONTACT_DETAILS_COPY || '', - CONTACT_DETAILS_HEADING: process.env.REACT_APP_CONTACT_DETAILS_HEADING || '', - CREATE_AUTH0_ACCOUNT: parseBooleanEnvVar("REACT_APP_CREATE_AUTH0_ACCOUNT"), - CUSTOM_FIELDS: JSON.parse(process.env.REACT_APP_CUSTOM_FIELDS || '[]') as object[], - CUSTOM_FIELDS_HEADING: process.env.REACT_APP_CUSTOM_FIELDS_HEADING || '', - DATE_OF_BIRTH_COPY: process.env.REACT_APP_DATE_OF_BIRTH_COPY || '', - DATE_OF_BIRTH_HEADING: process.env.REACT_APP_DATE_OF_BIRTH_HEADING || '', - HEAR_ABOUT_US_DETAILS: process.env.REACT_APP_HEAR_ABOUT_US_DETAILS || '', - HEAR_ABOUT_US_HEADING: process.env.REACT_APP_HEAR_ABOUT_US_HEADING || '', - HEAR_ABOUT_US_OPTIONS: (process.env.REACT_APP_HEAR_ABOUT_US_OPTIONS || '').split('.').map(i => i.trim()).filter(Boolean), - HIDE_ADDRESS: parseBooleanEnvVar("REACT_APP_HIDE_ADDRESS"), - HIDE_HOME_ADDRESS_COPY: parseBooleanEnvVar("REACT_APP_HIDE_HOME_ADDRESS_COPY"), - HIDE_ZERO_PRICE_DISPLAY: parseBooleanEnvVar("REACT_APP_HIDE_ZERO_PRICE_DISPLAY"), - HOME_ADDRESS_COPY: process.env.REACT_APP_HOME_ADDRESS_COPY || '', - JOIN_FORM_SIDEBAR_HEADING: process.env.REACT_APP_JOIN_FORM_SIDEBAR_HEADING || 'Join Us', - JOINING_VERB: process.env.REACT_APP_JOINING_VERB || 'Joining', - MEMBERSHIP_PLANS: JSON.parse(process.env.REACT_APP_MEMBERSHIP_PLANS || '[]') as object[], - MEMBERSHIP_STAGE_LABEL: process.env.REACT_APP_MEMBERSHIP_STAGE_LABEL || 'Your Membership', - MEMBERSHIP_TIERS_HEADING: process.env.REACT_APP_MEMBERSHIP_TIERS_HEADING || '', - MEMBERSHIP_TIERS_COPY: process.env.REACT_APP_MEMBERSHIP_TIERS_COPY || '', - CURRENCY_SELECTION_COPY: process.env.REACT_APP_CURRENCY_SELECTION_COPY || '', - ORGANISATION_NAME: process.env.REACT_APP_ORGANISATION_NAME || '', - ORGANISATION_BANK_NAME: process.env.REACT_APP_ORGANISATION_BANK_NAME || '', - ORGANISATION_EMAIL_ADDRESS: process.env.REACT_APP_ORGANISATION_EMAIL_ADDRESS || '', - PASSWORD_PURPOSE: process.env.REACT_APP_PASSWORD_PURPOSE || '', - POSTCODE_ADDRESS_PROVIDER: process.env.REACT_APP_POSTCODE_ADDRESS_PROVIDER || '', - PRIVACY_COPY: process.env.REACT_APP_PRIVACY_COPY || '', - SENTRY_DSN: process.env.REACT_APP_SENTRY_DSN || "", - STRIPE_DIRECT_DEBIT: parseBooleanEnvVar(process.env.REACT_APP_STRIPE_DIRECT_DEBIT || ''), - STRIPE_DIRECT_DEBIT_ONLY: parseBooleanEnvVar("REACT_APP_STRIPE_DIRECT_DEBIT_ONLY"), - STRIPE_PUBLISHABLE_KEY: process.env.REACT_STRIPE_PUBLISHABLE_KEY || '', - SUBSCRIPTION_DAY_OF_MONTH_COPY: process.env.REACT_APP_SUBSCRIPTION_DAY_OF_MONTH_COPY || '', - SUCCESS_REDIRECT: '/', - IS_UPDATE_FLOW: parseBooleanEnvVar("REACT_APP_IS_UPDATE_FLOW"), - INCLUDE_SKIP_PAYMENT_BUTTON: parseBooleanEnvVar("REACT_APP_INCLUDE_SKIP_PAYMENT_BUTTON"), - REQUIRE_ADDRESS: parseBooleanEnvVar("REACT_APP_REQUIRE_ADDRESS"), - REQUIRE_EMAIL_CONSENT: parseBooleanEnvVar("REACT_APP_REQUIRE_EMAIL_CONSENT"), - REQUIRE_PHONE_CONSENT: parseBooleanEnvVar("REACT_APP_REQUIRE_PHONE_CONSENT"), - REQUIRE_PHONE_NUMBER: parseBooleanEnvVar("REACT_APP_REQUIRE_PHONE_NUMBER"), - USE_CHARGEBEE: parseBooleanEnvVar("REACT_APP_USE_CHARGEBEE"), - USE_CHARGEBEE_HOSTED_PAGES: parseBooleanEnvVar("REACT_APP_USE_CHARGEBEE_HOSTED_PAGES"), - USE_GOCARDLESS: parseBooleanEnvVar("REACT_APP_USE_GOCARDLESS"), - USE_GOCARDLESS_API: parseBooleanEnvVar("REACT_APP_USE_GOCARDLESS_API"), - USE_MAILCHIMP: parseBooleanEnvVar("REACT_APP_USE_MAILCHIMP"), - USE_POSTCODE_LOOKUP: parseBooleanEnvVar("REACT_APP_USE_POSTCODE_LOOKUP"), - USE_STRIPE: parseBooleanEnvVar("REACT_APP_USE_STRIPE"), - USE_TEST_DATA: parseBooleanEnvVar("REACT_APP_USE_TEST_DATA"), - WEBHOOK_UUID: process.env.WEBHOOK_UUID || '', - WP_REST_API: '', -} + ABOUT_YOU_COPY: process.env.REACT_APP_ABOUT_YOU_COPY || "", + ABOUT_YOU_HEADING: process.env.REACT_APP_ABOUT_YOU_HEADING || "", + ASK_FOR_ADDITIONAL_DONATION: parseBooleanEnvVar( + "REACT_APP_ASK_FOR_ADDITIONAL_DONATION" + ), + DONATION_SUPPORTER_MODE: parseBooleanEnvVar( + "REACT_APP_DONATION_SUPPORTER_MODE" + ), + ENABLE_INTERNATIONAL_PHONE_NUMBERS: parseBooleanEnvVar( + "REACT_APP_ENABLE_INTERNATIONAL_PHONE_NUMBERS" + ), + CHARGEBEE_API_PUBLISHABLE_KEY: + process.env.REACT_APP_CHARGEBEE_API_PUBLISHABLE_KEY || "", + CHARGEBEE_SITE_NAME: process.env.REACT_APP_CHARGEBEE_SITE_NAME || "", + COLLECT_COUNTY: parseBooleanEnvVar("REACT_APP_COLLECT_COUNTY"), + COLLECT_DATE_OF_BIRTH: parseBooleanEnvVar("REACT_APP_COLLECT_DATE_OF_BIRTH"), + DISABLE_TEST_DATA: parseBooleanEnvVar("REACT_APP_DISABLE_TEST_DATA"), + REQUIRE_AGE_18_OR_OVER: parseBooleanEnvVar( + "REACT_APP_REQUIRE_AGE_18_OR_OVER" + ), + MINIMUM_AGE: parseInt(process.env.REACT_APP_MINIMUM_AGE || "18", 10), + COLLECT_PHONE_AND_EMAIL_CONTACT_CONSENT: parseBooleanEnvVar( + "REACT_APP_COLLECT_PHONE_AND_EMAIL_CONTACT_CONSENT" + ), + CONSENT_CHECKED_BY_DEFAULT: parseBooleanEnvVar( + "REACT_APP_CONSENT_CHECKED_BY_DEFAULT" + ), + COLLECT_HEAR_ABOUT_US: parseBooleanEnvVar("REACT_APP_COLLECT_HEAR_ABOUT_US"), + CONTACT_CONSENT_COPY: process.env.REACT_ENV_CONTACT_CONSENT_COPY || "", + CONTACT_DETAILS_COPY: process.env.REACT_APP_CONTACT_DETAILS_COPY || "", + CONTACT_DETAILS_HEADING: process.env.REACT_APP_CONTACT_DETAILS_HEADING || "", + CREATE_AUTH0_ACCOUNT: parseBooleanEnvVar("REACT_APP_CREATE_AUTH0_ACCOUNT"), + CUSTOM_FIELDS: JSON.parse( + process.env.REACT_APP_CUSTOM_FIELDS || "[]" + ) as object[], + CUSTOM_FIELDS_HEADING: process.env.REACT_APP_CUSTOM_FIELDS_HEADING || "", + DATE_OF_BIRTH_COPY: process.env.REACT_APP_DATE_OF_BIRTH_COPY || "", + DATE_OF_BIRTH_HEADING: process.env.REACT_APP_DATE_OF_BIRTH_HEADING || "", + HEAR_ABOUT_US_DETAILS: process.env.REACT_APP_HEAR_ABOUT_US_DETAILS || "", + HEAR_ABOUT_US_HEADING: process.env.REACT_APP_HEAR_ABOUT_US_HEADING || "", + HEAR_ABOUT_US_OPTIONS: (process.env.REACT_APP_HEAR_ABOUT_US_OPTIONS || "") + .split(".") + .map((i) => i.trim()) + .filter(Boolean), + HIDE_ADDRESS: parseBooleanEnvVar("REACT_APP_HIDE_ADDRESS"), + HIDE_HOME_ADDRESS_COPY: parseBooleanEnvVar( + "REACT_APP_HIDE_HOME_ADDRESS_COPY" + ), + HIDE_ZERO_PRICE_DISPLAY: parseBooleanEnvVar( + "REACT_APP_HIDE_ZERO_PRICE_DISPLAY" + ), + HOME_ADDRESS_COPY: process.env.REACT_APP_HOME_ADDRESS_COPY || "", + JOIN_FORM_SIDEBAR_HEADING: + process.env.REACT_APP_JOIN_FORM_SIDEBAR_HEADING || "Join Us", + JOINING_VERB: process.env.REACT_APP_JOINING_VERB || "Joining", + MEMBERSHIP_PLANS: JSON.parse( + process.env.REACT_APP_MEMBERSHIP_PLANS || "[]" + ) as object[], + MEMBERSHIP_STAGE_LABEL: + process.env.REACT_APP_MEMBERSHIP_STAGE_LABEL || "Your Membership", + MEMBERSHIP_TIERS_HEADING: + process.env.REACT_APP_MEMBERSHIP_TIERS_HEADING || "", + MEMBERSHIP_TIERS_COPY: process.env.REACT_APP_MEMBERSHIP_TIERS_COPY || "", + CURRENCY_SELECTION_COPY: process.env.REACT_APP_CURRENCY_SELECTION_COPY || "", + ORGANISATION_NAME: process.env.REACT_APP_ORGANISATION_NAME || "", + ORGANISATION_BANK_NAME: process.env.REACT_APP_ORGANISATION_BANK_NAME || "", + ORGANISATION_EMAIL_ADDRESS: + process.env.REACT_APP_ORGANISATION_EMAIL_ADDRESS || "", + PASSWORD_PURPOSE: process.env.REACT_APP_PASSWORD_PURPOSE || "", + POSTCODE_ADDRESS_PROVIDER: + process.env.REACT_APP_POSTCODE_ADDRESS_PROVIDER || "", + PRIVACY_COPY: process.env.REACT_APP_PRIVACY_COPY || "", + SENTRY_DSN: process.env.REACT_APP_SENTRY_DSN || "", + STRIPE_DIRECT_DEBIT: parseBooleanEnvVar( + process.env.REACT_APP_STRIPE_DIRECT_DEBIT || "" + ), + STRIPE_DIRECT_DEBIT_ONLY: parseBooleanEnvVar( + "REACT_APP_STRIPE_DIRECT_DEBIT_ONLY" + ), + STRIPE_PUBLISHABLE_KEY: process.env.REACT_STRIPE_PUBLISHABLE_KEY || "", + SUBSCRIPTION_DAY_OF_MONTH_COPY: + process.env.REACT_APP_SUBSCRIPTION_DAY_OF_MONTH_COPY || "", + SUCCESS_REDIRECT: "/", + IS_UPDATE_FLOW: parseBooleanEnvVar("REACT_APP_IS_UPDATE_FLOW"), + INCLUDE_SKIP_PAYMENT_BUTTON: parseBooleanEnvVar( + "REACT_APP_INCLUDE_SKIP_PAYMENT_BUTTON" + ), + REQUIRE_ADDRESS: parseBooleanEnvVar("REACT_APP_REQUIRE_ADDRESS"), + REQUIRE_EMAIL_CONSENT: parseBooleanEnvVar("REACT_APP_REQUIRE_EMAIL_CONSENT"), + REQUIRE_PHONE_CONSENT: parseBooleanEnvVar("REACT_APP_REQUIRE_PHONE_CONSENT"), + REQUIRE_PHONE_NUMBER: parseBooleanEnvVar("REACT_APP_REQUIRE_PHONE_NUMBER"), + USE_CHARGEBEE: parseBooleanEnvVar("REACT_APP_USE_CHARGEBEE"), + USE_CHARGEBEE_HOSTED_PAGES: parseBooleanEnvVar( + "REACT_APP_USE_CHARGEBEE_HOSTED_PAGES" + ), + USE_GOCARDLESS: parseBooleanEnvVar("REACT_APP_USE_GOCARDLESS"), + USE_GOCARDLESS_API: parseBooleanEnvVar("REACT_APP_USE_GOCARDLESS_API"), + USE_MAILCHIMP: parseBooleanEnvVar("REACT_APP_USE_MAILCHIMP"), + USE_POSTCODE_LOOKUP: parseBooleanEnvVar("REACT_APP_USE_POSTCODE_LOOKUP"), + USE_STRIPE: parseBooleanEnvVar("REACT_APP_USE_STRIPE"), + USE_TEST_DATA: parseBooleanEnvVar("REACT_APP_USE_TEST_DATA"), + WEBHOOK_UUID: process.env.WEBHOOK_UUID || "", + WP_REST_API: "" +}; -export const get = (envVar: keyof StaticEnv): object[]|boolean|number|string|string[] => { - return window.process.env[envVar] || staticEnv[envVar] || '' -} +export const get = ( + envVar: keyof StaticEnv +): object[] | boolean | number | string | string[] => { + return window.process.env[envVar] || staticEnv[envVar] || ""; +}; export const getStr = (envVar: keyof StaticEnv): string => { - const val = get(envVar) - if (!val) { - return '' - } - return String(val) -} + const val = get(envVar); + if (!val) { + return ""; + } + return String(val); +}; export type PaymentMethod = "creditCard" | "directDebit"; export type PaymentProvider = "chargebee" | "gocardless" | "stripe"; export const getPaymentProviders = () => { - const paymentProviders: Partial> = {}; + const paymentProviders: Partial> = + {}; - if (get("USE_GOCARDLESS")) { - paymentProviders.gocardless = ["directDebit"]; - } + if (get("USE_GOCARDLESS")) { + paymentProviders.gocardless = ["directDebit"]; + } - if (get("USE_STRIPE")) { - const stripeMethods: PaymentMethod[] = []; - // Ensure creditCard comes first to match the default state of the Stripe component - if (!get("STRIPE_DIRECT_DEBIT_ONLY")) { - stripeMethods.push("creditCard"); - } - if (get("STRIPE_DIRECT_DEBIT") || get("STRIPE_DIRECT_DEBIT_ONLY")) { - stripeMethods.push("directDebit"); - } - if (stripeMethods.length) { - paymentProviders.stripe = stripeMethods; - } + if (get("USE_STRIPE")) { + const stripeMethods: PaymentMethod[] = []; + // Ensure creditCard comes first to match the default state of the Stripe component + if (!get("STRIPE_DIRECT_DEBIT_ONLY")) { + stripeMethods.push("creditCard"); } - - if (get("USE_CHARGEBEE")) { - paymentProviders.chargebee = ["creditCard"]; - // TODO: Add ChargeBee direct debit settings + if (get("STRIPE_DIRECT_DEBIT") || get("STRIPE_DIRECT_DEBIT_ONLY")) { + stripeMethods.push("directDebit"); + } + if (stripeMethods.length) { + paymentProviders.stripe = stripeMethods; } - return paymentProviders; + } + + if (get("USE_CHARGEBEE")) { + paymentProviders.chargebee = ["creditCard"]; + // TODO: Add ChargeBee direct debit settings + } + return paymentProviders; }; /** @@ -199,45 +238,45 @@ export const resolveStripePaymentMethodTypes = ( currency: string ): string[] => { if (isOneOffDonation) { - return ['card']; + return ["card"]; } - if (get('STRIPE_DIRECT_DEBIT_ONLY')) { - return ['bacs_debit']; + if (get("STRIPE_DIRECT_DEBIT_ONLY")) { + return ["bacs_debit"]; } - const types = ['card']; - if (currency === 'gbp' && get('STRIPE_DIRECT_DEBIT')) { - types.push('bacs_debit'); + const types = ["card"]; + if (currency === "gbp" && get("STRIPE_DIRECT_DEBIT")) { + types.push("bacs_debit"); } return types; }; export const getPaymentMethods = () => { - const paymentProviders = getPaymentProviders(); - const paymentMethods: Partial> = {}; - for (const provider of Object.keys(paymentProviders)) { - for (const method of paymentProviders[provider as PaymentProvider] || []) { - paymentMethods[method] = true; - } + const paymentProviders = getPaymentProviders(); + const paymentMethods: Partial> = {}; + for (const provider of Object.keys(paymentProviders)) { + for (const method of paymentProviders[provider as PaymentProvider] || []) { + paymentMethods[method] = true; } - return Object.keys(paymentMethods) as PaymentMethod[]; -} + } + return Object.keys(paymentMethods) as PaymentMethod[]; +}; const initFromHtml = (): void => { - const element = document.getElementById("env"); + const element = document.getElementById("env"); - let env = {}; + let env = {}; - const envJson = element?.textContent || '{}' + const envJson = element?.textContent || "{}"; - try { - env = JSON.parse(envJson); - } catch (error) { - console.error("Could not load environment"); - } + try { + env = JSON.parse(envJson); + } catch (error) { + console.error("Could not load environment"); + } - window.process = Object.assign(window.process || {}, { - env - }); -} + window.process = Object.assign(window.process || {}, { + env + }); +}; -initFromHtml() +initFromHtml(); diff --git a/packages/join-flow/src/index.tsx b/packages/join-flow/src/index.tsx index 7843f865..63c08da4 100644 --- a/packages/join-flow/src/index.tsx +++ b/packages/join-flow/src/index.tsx @@ -20,27 +20,29 @@ const init = () => { return; } - - const sentryDsn = getEnvStr("SENTRY_DSN") + const sentryDsn = getEnvStr("SENTRY_DSN"); Sentry.init({ dsn: sentryDsn, release: "1.4.44" }); - if (getEnv('USE_CHARGEBEE')) { + if (getEnv("USE_CHARGEBEE")) { if (!window.Chargebee) { console.error( "Chargebee library is not loaded in surrounding page. Chargebee React components will not function as a result. The WordPress Join Form block will include this library." ); - return + return; } - if (!getEnv('CHARGEBEE_SITE_NAME') || !getEnv('CHARGEBEE_API_PUBLISHABLE_KEY')) { + if ( + !getEnv("CHARGEBEE_SITE_NAME") || + !getEnv("CHARGEBEE_API_PUBLISHABLE_KEY") + ) { console.error("Chargebee credentials are missing."); - return + return; } window.Chargebee.init({ - site: getEnv('CHARGEBEE_SITE_NAME'), - publishableKey: getEnv('CHARGEBEE_API_PUBLISHABLE_KEY'), + site: getEnv("CHARGEBEE_SITE_NAME"), + publishableKey: getEnv("CHARGEBEE_API_PUBLISHABLE_KEY") }); } @@ -50,6 +52,6 @@ const init = () => { , joinFormElement ); -} +}; -init() +init(); diff --git a/packages/join-flow/src/pages/confirm.page.tsx b/packages/join-flow/src/pages/confirm.page.tsx index 82b7900d..ccb7febe 100644 --- a/packages/join-flow/src/pages/confirm.page.tsx +++ b/packages/join-flow/src/pages/confirm.page.tsx @@ -7,26 +7,29 @@ import { FormSchema, getPaymentFrequency, getPaymentPlan } from "../schema"; import { usePostResource } from "../services/rest-resource.service"; import { upperFirst } from "lodash-es"; -import { get as getEnv, getStr as getEnvStr } from '../env'; +import { get as getEnv, getStr as getEnvStr } from "../env"; import { useCurrentRouter } from "../services/router.service"; export const ConfirmationPage: StagerComponent = ({ data, onCompleted }) => { - const organisationName = getEnv('ORGANISATION_NAME'); - const organisationBankName = getEnv('ORGANISATION_BANK_NAME'); - const organisationEmailAddress = getEnv('ORGANISATION_EMAIL_ADDRESS'); + const organisationName = getEnv("ORGANISATION_NAME"); + const organisationBankName = getEnv("ORGANISATION_BANK_NAME"); + const organisationEmailAddress = getEnv("ORGANISATION_EMAIL_ADDRESS"); const organisationMailToLink = `mailto:${organisationEmailAddress}`; - const chargebeeSiteName = getEnv('CHARGEBEE_SITE_NAME'); - const joiningVerb = getEnvStr('JOINING_VERB'); + const chargebeeSiteName = getEnv("CHARGEBEE_SITE_NAME"); + const joiningVerb = getEnvStr("JOINING_VERB"); const form = useForm(); const router = useCurrentRouter(); - const join = usePostResource>("/join"); + const join = + usePostResource>("/join"); - const autoSubmit = Boolean(data.gcBillingRequestId || data.stripePaymentIntentId || data.cbHostedPageId); + const autoSubmit = Boolean( + data.gcBillingRequestId || data.stripePaymentIntentId || data.cbHostedPageId + ); // Default "requestInFlight" to true if autoSubmit is set, as the request is sent // immediately through useEffect() below @@ -40,7 +43,9 @@ export const ConfirmationPage: StagerComponent = ({
Please wait
-
{joiningVerb} {organisationName}
+
+ {joiningVerb} {organisationName} +
); @@ -50,23 +55,28 @@ export const ConfirmationPage: StagerComponent = ({ if (data.paymentMethod === "directDebit" && data.membership) { const plan = getPaymentPlan(data.membership); const isAmountZero = plan && Number(plan.amount) === 0; - const isCustomAmountAboveZero = (plan && plan.allowCustomAmount) ? Number(data.customMembershipAmount) > 0 : false; + const isCustomAmountAboveZero = + plan && plan.allowCustomAmount + ? Number(data.customMembershipAmount) > 0 + : false; const hasDonation = data.donationAmount && Number(data.donationAmount) > 0; - + // Only show payment details if there's something to pay (membership, custom amount, or donation) if (!isAmountZero || isCustomAmountAboveZero || hasDonation) { - const frequency = getPaymentFrequency(data.membership) + const frequency = getPaymentFrequency(data.membership); directDebitDetailsMessage = (

- You are paying for your membership of {organisationName} by Direct Debit. + You are paying for your membership of {organisationName} by Direct + Debit.

- This will be charged {frequency ? frequency + ' ' : ''}from your + This will be charged {frequency ? frequency + " " : ""}from your bank account.

- On your bank statement the charge will appear as "{organisationBankName}". + On your bank statement the charge will appear as " + {organisationBankName}".

You can contact the membership team of {organisationName} at{" "} @@ -79,7 +89,7 @@ export const ConfirmationPage: StagerComponent = ({ const onSubmit = async () => { setRequestInFlight(true); - join({ ...data, stage: 'confirm' }).then( + join({ ...data, stage: "confirm" }).then( () => { onCompleted(data); }, @@ -110,7 +120,7 @@ export const ConfirmationPage: StagerComponent = ({

) : null} @@ -303,7 +334,12 @@ export const DetailsPage: StagerComponent = ({ {Boolean(getEnv("ENABLE_INTERNATIONAL_PHONE_NUMBERS")) ? ( - + {phoneCountries.map((c) => ( - + ) : ( - + )} @@ -330,10 +376,18 @@ export const DetailsPage: StagerComponent = ({ className="text-secondary" dangerouslySetInnerHTML={{ __html: contactConsentCopy }} > - + - + @@ -435,7 +489,12 @@ const CustomField: React.FC<{ field: any; form: any }> = ({ field, form }) => { ) : field.field_type === "select" ? ( - + {parseCustomFieldOptions(field.options).map( @@ -472,11 +531,13 @@ const CustomField: React.FC<{ field: any; form: any }> = ({ field, form }) => { ) : field.field_type === "month_year" ? ( ) : ( - - + + )} {field.instructions && ( @@ -489,7 +550,10 @@ const CustomField: React.FC<{ field: any; form: any }> = ({ field, form }) => { ); }; -const MonthYearField: React.FC<{ field: any; form: any }> = ({ field, form }) => { +const MonthYearField: React.FC<{ field: any; form: any }> = ({ + field, + form +}) => { const monthName = `${field.id}Month`; const yearName = `${field.id}Year`; const month = form.watch(monthName); diff --git a/packages/join-flow/src/pages/donation.page.test.tsx b/packages/join-flow/src/pages/donation.page.test.tsx index 9ba8e514..4d763941 100644 --- a/packages/join-flow/src/pages/donation.page.test.tsx +++ b/packages/join-flow/src/pages/donation.page.test.tsx @@ -1,35 +1,57 @@ -import React from 'react'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import '@testing-library/jest-dom'; -import { DonationPage } from './donation.page'; +import React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { DonationPage } from "./donation.page"; // Mock the env module -jest.mock('../env', () => ({ - get: jest.fn(), +jest.mock("../env", () => ({ + get: jest.fn() })); // Mock the summary component to keep tests simple -jest.mock('../components/summary', () => ({ - Summary: () => null, +jest.mock("../components/summary", () => ({ + Summary: () => null })); -import { get as getEnv } from '../env'; +import { get as getEnv } from "../env"; const mockGetEnv = getEnv as jest.Mock; const mockOnCompleted = jest.fn(); const baseData = { - membership: 'standard', + membership: "standard" } as any; const MOCK_PLANS = [ - { value: 'plan-a', label: 'Plan A', amount: '5', currency: 'GBP', frequency: 'monthly' }, - { value: 'plan-b', label: 'Plan B', amount: '10', currency: 'GBP', frequency: 'monthly' }, - { value: 'plan-c', label: 'Plan C', amount: '20', currency: 'GBP', frequency: 'monthly' }, + { + value: "plan-a", + label: "Plan A", + amount: "5", + currency: "GBP", + frequency: "monthly" + }, + { + value: "plan-b", + label: "Plan B", + amount: "10", + currency: "GBP", + frequency: "monthly" + }, + { + value: "plan-c", + label: "Plan C", + amount: "20", + currency: "GBP", + frequency: "monthly" + } ]; function renderDonationPage(data = baseData) { return render( - + ); } @@ -38,8 +60,8 @@ beforeEach(() => { mockOnCompleted.mockClear(); }); -describe('DonationPage — standard mode (DONATION_SUPPORTER_MODE off)', () => { - test('renders recurring checkbox when supporter mode is off', () => { +describe("DonationPage — standard mode (DONATION_SUPPORTER_MODE off)", () => { + test("renders recurring checkbox when supporter mode is off", () => { mockGetEnv.mockReturnValue(false); renderDonationPage(); @@ -48,45 +70,47 @@ describe('DonationPage — standard mode (DONATION_SUPPORTER_MODE off)', () => { ).toBeInTheDocument(); }); - test('does not render Monthly/One-off toggle when supporter mode is off', () => { + test("does not render Monthly/One-off toggle when supporter mode is off", () => { mockGetEnv.mockReturnValue(false); renderDonationPage(); - expect(screen.queryByText('Monthly')).not.toBeInTheDocument(); - expect(screen.queryByText('One-off')).not.toBeInTheDocument(); + expect(screen.queryByText("Monthly")).not.toBeInTheDocument(); + expect(screen.queryByText("One-off")).not.toBeInTheDocument(); }); }); -describe('DonationPage — supporter mode (DONATION_SUPPORTER_MODE on)', () => { +describe("DonationPage — supporter mode (DONATION_SUPPORTER_MODE on)", () => { beforeEach(() => { mockGetEnv.mockImplementation((key: string) => { - if (key === 'DONATION_SUPPORTER_MODE') return true; - if (key === 'MEMBERSHIP_PLANS') return MOCK_PLANS; - if (key === 'USE_STRIPE') return true; + if (key === "DONATION_SUPPORTER_MODE") return true; + if (key === "MEMBERSHIP_PLANS") return MOCK_PLANS; + if (key === "USE_STRIPE") return true; return false; }); }); - test('shows error when no membership plans are configured', () => { + test("shows error when no membership plans are configured", () => { mockGetEnv.mockImplementation((key: string) => { - if (key === 'DONATION_SUPPORTER_MODE') return true; - if (key === 'MEMBERSHIP_PLANS') return []; + if (key === "DONATION_SUPPORTER_MODE") return true; + if (key === "MEMBERSHIP_PLANS") return []; return false; }); renderDonationPage(); - expect(screen.getByText(/No donation amounts configured/i)).toBeInTheDocument(); - expect(screen.queryByText('Monthly')).not.toBeInTheDocument(); + expect( + screen.getByText(/No donation amounts configured/i) + ).toBeInTheDocument(); + expect(screen.queryByText("Monthly")).not.toBeInTheDocument(); }); - test('renders Monthly and One-off toggle buttons', () => { + test("renders Monthly and One-off toggle buttons", () => { renderDonationPage(); - expect(screen.getByText('Monthly')).toBeInTheDocument(); - expect(screen.getByText('One-off')).toBeInTheDocument(); + expect(screen.getByText("Monthly")).toBeInTheDocument(); + expect(screen.getByText("One-off")).toBeInTheDocument(); }); - test('does not render recurring checkbox', () => { + test("does not render recurring checkbox", () => { renderDonationPage(); expect( @@ -94,74 +118,74 @@ describe('DonationPage — supporter mode (DONATION_SUPPORTER_MODE on)', () => { ).not.toBeInTheDocument(); }); - test('Monthly is active by default and shows plan tiers', () => { + test("Monthly is active by default and shows plan tiers", () => { renderDonationPage(); - expect(screen.getByText('Monthly')).toHaveClass('btn-dark'); + expect(screen.getByText("Monthly")).toHaveClass("btn-dark"); // Tiers from MOCK_PLANS amounts - expect(screen.getByText('£5')).toBeInTheDocument(); - expect(screen.getByText('£10')).toBeInTheDocument(); - expect(screen.getByText('£20')).toBeInTheDocument(); + expect(screen.getByText("£5")).toBeInTheDocument(); + expect(screen.getByText("£10")).toBeInTheDocument(); + expect(screen.getByText("£20")).toBeInTheDocument(); }); - test('same tiers are shown after switching to One-off', () => { + test("same tiers are shown after switching to One-off", () => { renderDonationPage(); - fireEvent.click(screen.getByText('One-off')); + fireEvent.click(screen.getByText("One-off")); // Same tiers — amounts do not change with the toggle - expect(screen.getByText('£5')).toBeInTheDocument(); - expect(screen.getByText('£10')).toBeInTheDocument(); - expect(screen.getByText('£20')).toBeInTheDocument(); + expect(screen.getByText("£5")).toBeInTheDocument(); + expect(screen.getByText("£10")).toBeInTheDocument(); + expect(screen.getByText("£20")).toBeInTheDocument(); }); - test('skip button is not present in supporter mode', () => { + test("skip button is not present in supporter mode", () => { renderDonationPage(); - expect(screen.queryByText('skip for now')).not.toBeInTheDocument(); + expect(screen.queryByText("skip for now")).not.toBeInTheDocument(); }); - test('CTA label reflects monthly selection', () => { + test("CTA label reflects monthly selection", () => { renderDonationPage(); // Default: first plan tier £5, monthly expect(screen.getByText(/Donate £5\/month/i)).toBeInTheDocument(); }); - test('CTA label reflects one-off selection', () => { + test("CTA label reflects one-off selection", () => { renderDonationPage(); - fireEvent.click(screen.getByText('One-off')); + fireEvent.click(screen.getByText("One-off")); // Same selected tier (£5), now one-off expect(screen.getByText(/Donate £5 now/i)).toBeInTheDocument(); }); - test('submitting monthly calls onCompleted with recurDonation as native boolean true', async () => { + test("submitting monthly calls onCompleted with recurDonation as native boolean true", async () => { renderDonationPage(); fireEvent.click(screen.getByText(/Donate £5\/month/i)); await waitFor(() => expect(mockOnCompleted).toHaveBeenCalled()); const submitted = mockOnCompleted.mock.calls[0][0]; - expect(typeof submitted.recurDonation).toBe('boolean'); + expect(typeof submitted.recurDonation).toBe("boolean"); expect(submitted.recurDonation).toBe(true); }); - test('submitting one-off calls onCompleted with recurDonation as native boolean false', async () => { + test("submitting one-off calls onCompleted with recurDonation as native boolean false", async () => { renderDonationPage(); - fireEvent.click(screen.getByText('One-off')); + fireEvent.click(screen.getByText("One-off")); fireEvent.click(screen.getByText(/Donate £5 now/i)); await waitFor(() => expect(mockOnCompleted).toHaveBeenCalled()); const submitted = mockOnCompleted.mock.calls[0][0]; - expect(typeof submitted.recurDonation).toBe('boolean'); + expect(typeof submitted.recurDonation).toBe("boolean"); expect(submitted.recurDonation).toBe(false); }); - test('submitting calls onCompleted with donationAmount 0 (plan price is the donation)', async () => { + test("submitting calls onCompleted with donationAmount 0 (plan price is the donation)", async () => { renderDonationPage(); fireEvent.click(screen.getByText(/Donate £5\/month/i)); @@ -171,44 +195,44 @@ describe('DonationPage — supporter mode (DONATION_SUPPORTER_MODE on)', () => { expect(submitted.donationAmount).toBe(0); }); - test('submitting with default tier calls onCompleted with membership matching that tier', async () => { + test("submitting with default tier calls onCompleted with membership matching that tier", async () => { renderDonationPage(); fireEvent.click(screen.getByText(/Donate £5\/month/i)); await waitFor(() => expect(mockOnCompleted).toHaveBeenCalled()); const submitted = mockOnCompleted.mock.calls[0][0]; - expect(submitted.membership).toBe('plan-a'); + expect(submitted.membership).toBe("plan-a"); }); - test('selecting a different tier and submitting calls onCompleted with matching membership', async () => { + test("selecting a different tier and submitting calls onCompleted with matching membership", async () => { renderDonationPage(); - fireEvent.click(screen.getByText('£10')); + fireEvent.click(screen.getByText("£10")); fireEvent.click(screen.getByText(/Donate £10\/month/i)); await waitFor(() => expect(mockOnCompleted).toHaveBeenCalled()); const submitted = mockOnCompleted.mock.calls[0][0]; - expect(submitted.membership).toBe('plan-b'); + expect(submitted.membership).toBe("plan-b"); expect(submitted.donationAmount).toBe(0); }); }); -describe('DonationPage — supporter mode, custom amounts', () => { +describe("DonationPage — supporter mode, custom amounts", () => { beforeEach(() => { mockGetEnv.mockImplementation((key: string) => { - if (key === 'DONATION_SUPPORTER_MODE') return true; - if (key === 'MEMBERSHIP_PLANS') return MOCK_PLANS; - if (key === 'USE_STRIPE') return true; + if (key === "DONATION_SUPPORTER_MODE") return true; + if (key === "MEMBERSHIP_PLANS") return MOCK_PLANS; + if (key === "USE_STRIPE") return true; return false; }); }); - test('entering a custom amount and submitting monthly sets customMembershipAmount to that amount', async () => { + test("entering a custom amount and submitting monthly sets customMembershipAmount to that amount", async () => { renderDonationPage(); fireEvent.change(screen.getByLabelText(/Or enter another amount/i), { - target: { value: '69' }, + target: { value: "69" } }); fireEvent.click(screen.getByText(/Donate/i)); @@ -217,11 +241,11 @@ describe('DonationPage — supporter mode, custom amounts', () => { expect(submitted.customMembershipAmount).toBe(69); }); - test('entering a custom amount monthly sets donationAmount to 0 (plan price is the donation)', async () => { + test("entering a custom amount monthly sets donationAmount to 0 (plan price is the donation)", async () => { renderDonationPage(); fireEvent.change(screen.getByLabelText(/Or enter another amount/i), { - target: { value: '69' }, + target: { value: "69" } }); fireEvent.click(screen.getByText(/Donate/i)); @@ -230,11 +254,11 @@ describe('DonationPage — supporter mode, custom amounts', () => { expect(submitted.donationAmount).toBe(0); }); - test('entering a custom amount monthly sets recurDonation to true', async () => { + test("entering a custom amount monthly sets recurDonation to true", async () => { renderDonationPage(); fireEvent.change(screen.getByLabelText(/Or enter another amount/i), { - target: { value: '69' }, + target: { value: "69" } }); fireEvent.click(screen.getByText(/Donate/i)); @@ -243,12 +267,12 @@ describe('DonationPage — supporter mode, custom amounts', () => { expect(submitted.recurDonation).toBe(true); }); - test('entering a custom amount one-off sets donationAmount to that amount', async () => { + test("entering a custom amount one-off sets donationAmount to that amount", async () => { renderDonationPage(); - fireEvent.click(screen.getByText('One-off')); + fireEvent.click(screen.getByText("One-off")); fireEvent.change(screen.getByLabelText(/Or enter another amount/i), { - target: { value: '100' }, + target: { value: "100" } }); fireEvent.click(screen.getByText(/Donate/i)); @@ -257,71 +281,73 @@ describe('DonationPage — supporter mode, custom amounts', () => { expect(submitted.donationAmount).toBe(100); }); - test('entering a custom amount one-off forces paymentMethod to creditCard', async () => { + test("entering a custom amount one-off forces paymentMethod to creditCard", async () => { renderDonationPage(); - fireEvent.click(screen.getByText('One-off')); + fireEvent.click(screen.getByText("One-off")); fireEvent.change(screen.getByLabelText(/Or enter another amount/i), { - target: { value: '100' }, + target: { value: "100" } }); fireEvent.click(screen.getByText(/Donate/i)); await waitFor(() => expect(mockOnCompleted).toHaveBeenCalled()); const submitted = mockOnCompleted.mock.calls[0][0]; - expect(submitted.paymentMethod).toBe('creditCard'); + expect(submitted.paymentMethod).toBe("creditCard"); }); - test('selecting a tier that matches a plan does NOT set customMembershipAmount', async () => { + test("selecting a tier that matches a plan does NOT set customMembershipAmount", async () => { renderDonationPage(); // £10 matches plan-b exactly — no custom amount should be forwarded - fireEvent.click(screen.getByText('£10')); + fireEvent.click(screen.getByText("£10")); fireEvent.click(screen.getByText(/Donate £10\/month/i)); await waitFor(() => expect(mockOnCompleted).toHaveBeenCalled()); const submitted = mockOnCompleted.mock.calls[0][0]; - expect(submitted.membership).toBe('plan-b'); + expect(submitted.membership).toBe("plan-b"); expect(submitted.customMembershipAmount).toBeUndefined(); }); - test('custom amount uses fallback plan as membership when no tier matches', async () => { + test("custom amount uses fallback plan as membership when no tier matches", async () => { renderDonationPage(); fireEvent.change(screen.getByLabelText(/Or enter another amount/i), { - target: { value: '999' }, + target: { value: "999" } }); fireEvent.click(screen.getByText(/Donate/i)); await waitFor(() => expect(mockOnCompleted).toHaveBeenCalled()); const submitted = mockOnCompleted.mock.calls[0][0]; // No plan has amount 999, so fallback to first plan - expect(submitted.membership).toBe('plan-a'); + expect(submitted.membership).toBe("plan-a"); expect(submitted.customMembershipAmount).toBe(999); }); }); -describe('DonationPage — supporter mode, GoCardless only (USE_STRIPE off)', () => { +describe("DonationPage — supporter mode, GoCardless only (USE_STRIPE off)", () => { beforeEach(() => { mockGetEnv.mockImplementation((key: string) => { - if (key === 'DONATION_SUPPORTER_MODE') return true; - if (key === 'MEMBERSHIP_PLANS') return MOCK_PLANS; - if (key === 'USE_STRIPE') return false; + if (key === "DONATION_SUPPORTER_MODE") return true; + if (key === "MEMBERSHIP_PLANS") return MOCK_PLANS; + if (key === "USE_STRIPE") return false; return false; }); }); - test('One-off button is disabled', () => { + test("One-off button is disabled", () => { renderDonationPage(); - expect(screen.getByText('One-off').closest('button')).toBeDisabled(); + expect(screen.getByText("One-off").closest("button")).toBeDisabled(); }); - test('shows explanation that one-off donations are not available', () => { + test("shows explanation that one-off donations are not available", () => { renderDonationPage(); - expect(screen.getByText(/one-off donations are not available/i)).toBeInTheDocument(); + expect( + screen.getByText(/one-off donations are not available/i) + ).toBeInTheDocument(); }); - test('Monthly button remains enabled', () => { + test("Monthly button remains enabled", () => { renderDonationPage(); - expect(screen.getByText('Monthly').closest('button')).not.toBeDisabled(); + expect(screen.getByText("Monthly").closest("button")).not.toBeDisabled(); }); }); diff --git a/packages/join-flow/src/pages/donation.page.tsx b/packages/join-flow/src/pages/donation.page.tsx index 4f5114a6..94139a4a 100644 --- a/packages/join-flow/src/pages/donation.page.tsx +++ b/packages/join-flow/src/pages/donation.page.tsx @@ -31,15 +31,20 @@ export const DonationPage: StagerComponent = ({ : [5, 10, 15, 20]; const supporterMode = Boolean(getEnv("DONATION_SUPPORTER_MODE")); - const oneOffAvailable = Boolean(getEnv("USE_STRIPE")) && !Boolean(getEnv("STRIPE_DIRECT_DEBIT_ONLY")); + const oneOffAvailable = + Boolean(getEnv("USE_STRIPE")) && + !Boolean(getEnv("STRIPE_DIRECT_DEBIT_ONLY")); const membershipPlans = (getEnv("MEMBERSHIP_PLANS") as any[]) || []; - const supporterTiers: number[] = membershipPlans.map((p) => Number(p.amount)).filter((n) => n > 0); + const supporterTiers: number[] = membershipPlans + .map((p) => Number(p.amount)) + .filter((n) => n > 0); const supporterCurrency: string = membershipPlans[0]?.currency || "GBP"; const defaultSupporterTier = supporterTiers[0] ?? 0; const [isMonthly, setIsMonthly] = useState(true); - const [selectedTier, setSelectedTier] = useState(defaultSupporterTier); + const [selectedTier, setSelectedTier] = + useState(defaultSupporterTier); // otherDonationAmount is a transient field for the "other" input; it is // folded into donationAmount on submit and never leaves this page. @@ -56,15 +61,21 @@ export const DonationPage: StagerComponent = ({ const handleSubmit = form.handleSubmit((formData) => { if (supporterMode) { - const amount = (formData.otherDonationAmount != null && formData.otherDonationAmount !== "") - ? Number(formData.otherDonationAmount) - : selectedTier; + const amount = + formData.otherDonationAmount != null && + formData.otherDonationAmount !== "" + ? Number(formData.otherDonationAmount) + : selectedTier; delete formData.otherDonationAmount; // Find the plan whose amount matches the selected tier. // The plan price IS the donation — no separate donationAmount item should be created. - const matchingPlan = membershipPlans.find((p: any) => Number(p.amount) === amount); - const fallbackPlan = membershipPlans.find((p: any) => p.allowCustomAmount) ?? membershipPlans[0]; + const matchingPlan = membershipPlans.find( + (p: any) => Number(p.amount) === amount + ); + const fallbackPlan = + membershipPlans.find((p: any) => p.allowCustomAmount) ?? + membershipPlans[0]; const resolvedPlan = matchingPlan ?? fallbackPlan; onCompleted({ @@ -76,11 +87,14 @@ export const DonationPage: StagerComponent = ({ donationAmount: isMonthly ? 0 : amount, recurDonation: isMonthly, // One-off payments require card — override any stale directDebit from session - ...(!isMonthly ? { paymentMethod: "creditCard" } : {}), + ...(!isMonthly ? { paymentMethod: "creditCard" } : {}) }); return; } - if (formData.otherDonationAmount !== "" && formData.otherDonationAmount != null) { + if ( + formData.otherDonationAmount !== "" && + formData.otherDonationAmount != null + ) { formData.donationAmount = Number(formData.otherDonationAmount); delete formData.otherDonationAmount; } @@ -92,7 +106,8 @@ export const DonationPage: StagerComponent = ({
No donation amounts configured.

- Add membership plans to this block in the WordPress editor. Their amounts will be used as the donation tiers shown to supporters. + Add membership plans to this block in the WordPress editor. Their + amounts will be used as the donation tiers shown to supporters.

); @@ -100,12 +115,18 @@ export const DonationPage: StagerComponent = ({ if (supporterMode) { const currencySymbol = currencyCodeToSymbol(supporterCurrency); - const activeAmount = (otherDonationAmount != null && otherDonationAmount !== "") - ? Number(otherDonationAmount) - : selectedTier; - const ctaLabel = activeAmount > 0 - ? (isMonthly ? `Donate ${currencySymbol}${activeAmount}/month` : `Donate ${currencySymbol}${activeAmount} now`) - : (isMonthly ? "Donate monthly" : "Donate now"); + const activeAmount = + otherDonationAmount != null && otherDonationAmount !== "" + ? Number(otherDonationAmount) + : selectedTier; + const ctaLabel = + activeAmount > 0 + ? isMonthly + ? `Donate ${currencySymbol}${activeAmount}/month` + : `Donate ${currencySymbol}${activeAmount} now` + : isMonthly + ? "Donate monthly" + : "Donate now"; return ( @@ -141,7 +162,8 @@ export const DonationPage: StagerComponent = ({ {!oneOffAvailable && (

- One-off donations are not available with Direct Debit. To make a one-off donation, please contact us. + One-off donations are not available with Direct Debit. To make a + one-off donation, please contact us.

)} @@ -153,7 +175,9 @@ export const DonationPage: StagerComponent = ({ className="mr-2 mb-2" variant={ (otherDonationAmount == null || otherDonationAmount === "") && - selectedTier === tier ? "dark" : "outline-dark" + selectedTier === tier + ? "dark" + : "outline-dark" } onClick={() => { setSelectedTier(tier); @@ -161,7 +185,8 @@ export const DonationPage: StagerComponent = ({ form.setValue("otherDonationAmount", null); }} > - {currencySymbol}{tier} + {currencySymbol} + {tier} ))} @@ -182,10 +207,7 @@ export const DonationPage: StagerComponent = ({ } return ( - +
@@ -195,13 +217,14 @@ export const DonationPage: StagerComponent = ({

Can you chip in?

- We rely on our members' generosity to build our movement, - particularly as we look ahead to the next General Election and - the work that needs to be done to gain more MPs. + We rely on our members' generosity to build our movement, particularly + as we look ahead to the next General Election and the work that needs + to be done to gain more MPs.

- Many of our members top up their membership, which forms a vital part of our income. - We'd be very grateful if you would consider doing the same. + Many of our members top up their membership, which forms a vital part + of our income. We'd be very grateful if you would consider doing the + same.

diff --git a/packages/join-flow/src/pages/payment-details.page.tsx b/packages/join-flow/src/pages/payment-details.page.tsx index b9ace106..5b63c89e 100644 --- a/packages/join-flow/src/pages/payment-details.page.tsx +++ b/packages/join-flow/src/pages/payment-details.page.tsx @@ -20,9 +20,21 @@ import { Summary } from "../components/summary"; import { sortedCountries } from "../constants"; import { useCSSStyle } from "../hooks/util"; import ddLogo from "../images/dd_logo_landscape.png"; -import { PaymentMethodDDSchema, FormSchema, formatPhoneE164, validate } from "../schema"; +import { + PaymentMethodDDSchema, + FormSchema, + formatPhoneE164, + validate +} from "../schema"; -import { get as getEnv, getStr as getEnvStr, getPaymentProviders, resolveStripePaymentMethodTypes, PaymentMethod, PaymentProvider } from "../env"; +import { + get as getEnv, + getStr as getEnvStr, + getPaymentProviders, + resolveStripePaymentMethodTypes, + PaymentMethod, + PaymentProvider +} from "../env"; import { loadStripe } from "@stripe/stripe-js"; import type { StripeError } from "@stripe/stripe-js"; import { usePostResource } from "../services/rest-resource.service"; @@ -31,7 +43,7 @@ import { SAVED_STATE_KEY } from "../services/router.service"; const STRIPE_CODE_MESSAGES: Partial> = { payment_intent_authentication_failure: "We were unable to authenticate your payment. Please try a different payment method.", - payment_intent_timeout: "Payment timed out. Please try again.", + payment_intent_timeout: "Payment timed out. Please try again." }; const TECHNICAL_ERROR_TYPES = new Set([ @@ -40,7 +52,7 @@ const TECHNICAL_ERROR_TYPES = new Set([ "authentication_error", "rate_limit_error", "idempotency_error", - "stripe_invalid_response_error", + "stripe_invalid_response_error" ]); const GENERIC_PAYMENT_ERROR = @@ -74,15 +86,33 @@ export const PaymentDetailsPage: StagerComponent = ({ } if (paymentProvider === "stripe") { - return ; + return ( + + ); } if (paymentProvider === "gocardless") { - return ; + return ( + + ); } if (paymentProvider === "chargebee") { - return ; + return ( + + ); } return

Error: no payment providers available. Please contact us.

; @@ -299,23 +329,26 @@ const ChargebeePaymentPage: StagerComponent = ({ const convertCurrencyFromMajorToMinorUnits = (amount: number) => amount * 100; -const StripePaymentPage: StagerComponent = ({ - data, - setData -}) => { +const StripePaymentPage: StagerComponent = ({ data, setData }) => { const stripePromise = loadStripe(getEnv("STRIPE_PUBLISHABLE_KEY") as string); const plan = (getEnv("MEMBERSHIP_PLANS") as any[]).find( (plan) => plan.value === data.membership ); - const isOneOffDonation = Boolean(getEnv("DONATION_SUPPORTER_MODE")) && !data.recurDonation; + const isOneOffDonation = + Boolean(getEnv("DONATION_SUPPORTER_MODE")) && !data.recurDonation; // For one-off donations the amount comes from donationAmount (set by donation.page). // For subscriptions the amount is only used by Stripe Elements for display; the // actual charge is determined server-side from the plan / customMembershipAmount. const amountForElements = isOneOffDonation ? convertCurrencyFromMajorToMinorUnits(data.donationAmount as number) - : (plan.amount ? convertCurrencyFromMajorToMinorUnits(plan.amount) : 100); + : plan.amount + ? convertCurrencyFromMajorToMinorUnits(plan.amount) + : 100; const currency = plan.currency.toLowerCase() || "gbp"; - const paymentMethodTypes = resolveStripePaymentMethodTypes(isOneOffDonation, currency); + const paymentMethodTypes = resolveStripePaymentMethodTypes( + isOneOffDonation, + currency + ); return ( = ({ mode: isOneOffDonation ? "payment" : "subscription", amount: amountForElements, currency, - paymentMethodTypes, + paymentMethodTypes }} > - + ); }; @@ -368,12 +406,16 @@ const StripeForm = ({ // Use ref for paymentMethodOrder so that it is fixed when the component is first rendered // Otherwise the Stripe form switches the order whenever the payment method is changed - const paymentMethodOrder = useRef(data.paymentMethod === "directDebit" ? ["bacs_debit", "card"] : ["card", "bacs_debit"]); + const paymentMethodOrder = useRef( + data.paymentMethod === "directDebit" + ? ["bacs_debit", "card"] + : ["card", "bacs_debit"] + ); const handleError = (error: { message: string }) => { setLoading(false); setErrorMessage(error.message); - Sentry.captureMessage(error.message, 'error') + Sentry.captureMessage(error.message, "error"); }; const handleSubmit = async (event: React.FormEvent) => { @@ -448,7 +490,7 @@ const StripeForm = ({ if (error) { Sentry.captureMessage("Stripe confirmPayment error", { level: "error", - extra: { stripeError: error }, + extra: { stripeError: error } }); setLoading(false); setErrorMessage(formatStripeError(error)); @@ -470,9 +512,10 @@ const StripeForm = ({ // This allows users to verify their details are correct when setting up Direct Debit const defaultValues = { billingDetails: { - name: data.firstName && data.lastName - ? `${data.firstName} ${data.lastName}` - : undefined, + name: + data.firstName && data.lastName + ? `${data.firstName} ${data.lastName}` + : undefined, email: data.email || undefined, phone: formatPhoneE164(data.phoneNumber, data.phoneCountry), address: { @@ -480,7 +523,7 @@ const StripeForm = ({ line2: data.addressLine2 || undefined, city: data.addressCity || undefined, postal_code: data.addressPostcode || undefined, - country: data.addressCountry || undefined, + country: data.addressCountry || undefined } } }; @@ -491,10 +534,22 @@ const StripeForm = ({
- { - const stripePaymentMethod = e.value.type; - setData({ ...data, paymentMethod: stripePaymentMethod === "bacs_debit" ? "directDebit" : "creditCard" }); - }}/> + { + const stripePaymentMethod = e.value.type; + setData({ + ...data, + paymentMethod: + stripePaymentMethod === "bacs_debit" + ? "directDebit" + : "creditCard" + }); + }} + /> {errorMessage && (
@@ -505,12 +560,27 @@ const StripeForm = ({ {showErrorDetails && ( -
+                  
                     {errorDetails}
                   
)} diff --git a/packages/join-flow/src/pages/payment-method.page.tsx b/packages/join-flow/src/pages/payment-method.page.tsx index 1089c5df..c8629520 100644 --- a/packages/join-flow/src/pages/payment-method.page.tsx +++ b/packages/join-flow/src/pages/payment-method.page.tsx @@ -5,7 +5,10 @@ import { StagerComponent } from "../components/stager"; import { Summary } from "../components/summary"; import { FormSchema } from "../schema"; -export const PaymentPage: StagerComponent = ({ data, onCompleted }) => { +export const PaymentPage: StagerComponent = ({ + data, + onCompleted +}) => { const form = useForm({ defaultValues: data as FormSchema }); diff --git a/packages/join-flow/src/pages/plan.page.test.tsx b/packages/join-flow/src/pages/plan.page.test.tsx index 3daf0fb0..31c03233 100644 --- a/packages/join-flow/src/pages/plan.page.test.tsx +++ b/packages/join-flow/src/pages/plan.page.test.tsx @@ -1,104 +1,184 @@ -import React from 'react'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import '@testing-library/jest-dom'; -import { PlanPage } from './plan.page'; +import React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { PlanPage } from "./plan.page"; -jest.mock('../env', () => ({ +jest.mock("../env", () => ({ get: jest.fn(), - getStr: jest.fn(() => ''), + getStr: jest.fn(() => "") })); -jest.mock('../components/summary', () => ({ - Summary: () => null, +jest.mock("../components/summary", () => ({ + Summary: () => null })); -import { get as getEnv } from '../env'; +import { get as getEnv } from "../env"; const mockGetEnv = getEnv as jest.Mock; const mockOnCompleted = jest.fn(); const MOCK_PLANS = [ - { value: 'higher', label: 'Higher income', description: '', amount: '20', currency: 'GBP', frequency: 'monthly', allowCustomAmount: false }, - { value: 'lower', label: 'Lower income', description: '', amount: '5', currency: 'GBP', frequency: 'monthly', allowCustomAmount: false }, - { value: 'other', label: 'Other', description: '', amount: '1', currency: 'GBP', frequency: 'monthly', allowCustomAmount: true }, + { + value: "higher", + label: "Higher income", + description: "", + amount: "20", + currency: "GBP", + frequency: "monthly", + allowCustomAmount: false + }, + { + value: "lower", + label: "Lower income", + description: "", + amount: "5", + currency: "GBP", + frequency: "monthly", + allowCustomAmount: false + }, + { + value: "other", + label: "Other", + description: "", + amount: "1", + currency: "GBP", + frequency: "monthly", + allowCustomAmount: true + } ]; beforeEach(() => { mockGetEnv.mockImplementation((key: string) => { - if (key === 'MEMBERSHIP_PLANS') return MOCK_PLANS; + if (key === "MEMBERSHIP_PLANS") return MOCK_PLANS; return false; }); mockOnCompleted.mockClear(); }); -describe('PlanPage — custom amount', () => { - test('typing a custom amount selects that tier instead of the default', async () => { - render(); +describe("PlanPage — custom amount", () => { + test("typing a custom amount selects that tier instead of the default", async () => { + render( + + ); - const customInput = document.getElementById('other-amount') as HTMLInputElement; + const customInput = document.getElementById( + "other-amount" + ) as HTMLInputElement; expect(customInput).toBeInTheDocument(); - fireEvent.change(customInput, { target: { value: '7' } }); + fireEvent.change(customInput, { target: { value: "7" } }); - fireEvent.submit(screen.getByRole('button', { name: /continue/i }).closest('form')!); + fireEvent.submit( + screen.getByRole("button", { name: /continue/i }).closest("form")! + ); await waitFor(() => expect(mockOnCompleted).toHaveBeenCalled()); const submitted = mockOnCompleted.mock.calls[0][0]; - expect(submitted.membership).toBe('other'); + expect(submitted.membership).toBe("other"); expect(Number(submitted.customMembershipAmount)).toBe(7); }); - test('the continue button spells out the amount about to be paid', async () => { - render(); + test("the continue button spells out the amount about to be paid", async () => { + render( + + ); - expect(screen.getByRole('button', { name: 'Continue and pay £20 monthly' })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Continue and pay £20 monthly" }) + ).toBeInTheDocument(); - const customInput = document.getElementById('other-amount') as HTMLInputElement; - fireEvent.change(customInput, { target: { value: '7' } }); + const customInput = document.getElementById( + "other-amount" + ) as HTMLInputElement; + fireEvent.change(customInput, { target: { value: "7" } }); await waitFor(() => - expect(screen.getByRole('button', { name: 'Continue and pay £7 monthly' })).toBeInTheDocument() + expect( + screen.getByRole("button", { name: "Continue and pay £7 monthly" }) + ).toBeInTheDocument() ); }); - test('submitting without touching the custom amount keeps the default tier', async () => { - render(); + test("submitting without touching the custom amount keeps the default tier", async () => { + render( + + ); - fireEvent.submit(screen.getByRole('button', { name: /continue/i }).closest('form')!); + fireEvent.submit( + screen.getByRole("button", { name: /continue/i }).closest("form")! + ); await waitFor(() => expect(mockOnCompleted).toHaveBeenCalled()); - expect(mockOnCompleted.mock.calls[0][0].membership).toBe('higher'); + expect(mockOnCompleted.mock.calls[0][0].membership).toBe("higher"); }); }); -describe('PlanPage — redirecting plan', () => { +describe("PlanPage — redirecting plan", () => { const PLANS_WITH_REDIRECT = [ ...MOCK_PLANS, { - value: 'student', label: 'Student', description: '', amount: '3', currency: 'GBP', frequency: 'monthly', - allowCustomAmount: false, redirectUrl: 'https://example.org/students', - }, + value: "student", + label: "Student", + description: "", + amount: "3", + currency: "GBP", + frequency: "monthly", + allowCustomAmount: false, + redirectUrl: "https://example.org/students" + } ]; beforeEach(() => { mockGetEnv.mockImplementation((key: string) => { - if (key === 'MEMBERSHIP_PLANS') return PLANS_WITH_REDIRECT; + if (key === "MEMBERSHIP_PLANS") return PLANS_WITH_REDIRECT; return false; }); }); - test('the continue button does not promise a payment that will not be taken', () => { - render(); + test("the continue button does not promise a payment that will not be taken", () => { + render( + + ); - expect(screen.getByRole('button', { name: 'Continue' })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Continue" }) + ).toBeInTheDocument(); }); - test('switching from a paying plan to the redirecting plan drops the amount from the button', async () => { - render(); - expect(screen.getByRole('button', { name: 'Continue and pay £20 monthly' })).toBeInTheDocument(); + test("switching from a paying plan to the redirecting plan drops the amount from the button", async () => { + render( + + ); + expect( + screen.getByRole("button", { name: "Continue and pay £20 monthly" }) + ).toBeInTheDocument(); - fireEvent.click(document.getElementById('membership-Student')!); + fireEvent.click(document.getElementById("membership-Student")!); - await waitFor(() => expect(screen.getByRole('button', { name: 'Continue' })).toBeInTheDocument()); + await waitFor(() => + expect( + screen.getByRole("button", { name: "Continue" }) + ).toBeInTheDocument() + ); }); }); diff --git a/packages/join-flow/src/schema.test.ts b/packages/join-flow/src/schema.test.ts index db8271f2..87567586 100644 --- a/packages/join-flow/src/schema.test.ts +++ b/packages/join-flow/src/schema.test.ts @@ -4,16 +4,16 @@ * * Red-Green-Reverse TDD: tests are written before the function exists. */ -import { getTestDataIfEnabled, renderDonationSummary } from './schema'; -import { FormSchema } from './schema'; +import { getTestDataIfEnabled, renderDonationSummary } from "./schema"; +import { FormSchema } from "./schema"; const PLAN_STANDARD = { - value: 'standard', - label: 'Twenty a month', + value: "standard", + label: "Twenty a month", amount: 20, - currency: 'GBP', - frequency: 'monthly', - allowCustomAmount: false, + currency: "GBP", + frequency: "monthly", + allowCustomAmount: false }; function withEnv(overrides: Record, fn: () => void) { @@ -34,27 +34,27 @@ const withPlan = (fn: () => void) => // Amount comes from the plan, not donationAmount // --------------------------------------------------------------------------- -describe('renderDonationSummary — supporter mode monthly', () => { - it('shows plan amount when no custom amount', () => { +describe("renderDonationSummary — supporter mode monthly", () => { + it("shows plan amount when no custom amount", () => { withPlan(() => { const data: FormSchema = { donationSupporterMode: true, recurDonation: true, - membership: 'standard', + membership: "standard" }; - expect(renderDonationSummary(data)).toBe('£20 a month donation'); + expect(renderDonationSummary(data)).toBe("£20 a month donation"); }); }); - it('shows customMembershipAmount when provided', () => { + it("shows customMembershipAmount when provided", () => { withPlan(() => { const data: FormSchema = { donationSupporterMode: true, recurDonation: true, - membership: 'standard', - customMembershipAmount: 35, + membership: "standard", + customMembershipAmount: 35 }; - expect(renderDonationSummary(data)).toBe('£35 a month donation'); + expect(renderDonationSummary(data)).toBe("£35 a month donation"); }); }); @@ -63,9 +63,9 @@ describe('renderDonationSummary — supporter mode monthly', () => { const data: FormSchema = { donationSupporterMode: true, recurDonation: true, - membership: 'standard', + membership: "standard" }; - expect(renderDonationSummary(data)).not.toBe('None right now'); + expect(renderDonationSummary(data)).not.toBe("None right now"); }); }); }); @@ -75,15 +75,15 @@ describe('renderDonationSummary — supporter mode monthly', () => { // Amount comes from donationAmount // --------------------------------------------------------------------------- -describe('renderDonationSummary — supporter mode one-off', () => { - it('shows donationAmount as one time donation', () => { +describe("renderDonationSummary — supporter mode one-off", () => { + it("shows donationAmount as one time donation", () => { withPlan(() => { const data: FormSchema = { donationSupporterMode: true, recurDonation: false, - donationAmount: 50, + donationAmount: 50 }; - expect(renderDonationSummary(data)).toBe('£50 one time donation'); + expect(renderDonationSummary(data)).toBe("£50 one time donation"); }); }); @@ -92,9 +92,9 @@ describe('renderDonationSummary — supporter mode one-off', () => { const data: FormSchema = { donationSupporterMode: true, recurDonation: false, - donationAmount: 0, + donationAmount: 0 }; - expect(renderDonationSummary(data)).toBe('None right now'); + expect(renderDonationSummary(data)).toBe("None right now"); }); }); @@ -102,9 +102,9 @@ describe('renderDonationSummary — supporter mode one-off', () => { withPlan(() => { const data: FormSchema = { donationSupporterMode: true, - recurDonation: false, + recurDonation: false }; - expect(renderDonationSummary(data)).toBe('None right now'); + expect(renderDonationSummary(data)).toBe("None right now"); }); }); }); @@ -113,44 +113,44 @@ describe('renderDonationSummary — supporter mode one-off', () => { // Standard mode — additional donation // --------------------------------------------------------------------------- -describe('renderDonationSummary — standard mode', () => { - it('shows donationAmount as one time donation', () => { +describe("renderDonationSummary — standard mode", () => { + it("shows donationAmount as one time donation", () => { withPlan(() => { const data: FormSchema = { donationAmount: 10, recurDonation: false }; - expect(renderDonationSummary(data)).toBe('£10 one time donation'); + expect(renderDonationSummary(data)).toBe("£10 one time donation"); }); }); - it('shows donationAmount as monthly donation when recurDonation is true', () => { + it("shows donationAmount as monthly donation when recurDonation is true", () => { withPlan(() => { const data: FormSchema = { donationAmount: 10, recurDonation: true }; - expect(renderDonationSummary(data)).toBe('£10 a month donation'); + expect(renderDonationSummary(data)).toBe("£10 a month donation"); }); }); it('returns "None right now" when donationAmount is absent', () => { withPlan(() => { const data: FormSchema = {}; - expect(renderDonationSummary(data)).toBe('None right now'); + expect(renderDonationSummary(data)).toBe("None right now"); }); }); }); -describe('getTestDataIfEnabled', () => { - it('returns test data when USE_TEST_DATA is set', () => { +describe("getTestDataIfEnabled", () => { + it("returns test data when USE_TEST_DATA is set", () => { withEnv({ USE_TEST_DATA: true }, () => { - expect(getTestDataIfEnabled().email).toBe('someone@example.com'); + expect(getTestDataIfEnabled().email).toBe("someone@example.com"); expect(console).toHaveLogged(); }); }); - it('returns nothing when USE_TEST_DATA is not set', () => { + it("returns nothing when USE_TEST_DATA is not set", () => { withEnv({}, () => { expect(getTestDataIfEnabled()).toEqual({}); }); }); - it('DISABLE_TEST_DATA overrides USE_TEST_DATA', () => { + it("DISABLE_TEST_DATA overrides USE_TEST_DATA", () => { withEnv({ USE_TEST_DATA: true, DISABLE_TEST_DATA: true }, () => { expect(getTestDataIfEnabled()).toEqual({}); }); @@ -161,123 +161,137 @@ describe('getTestDataIfEnabled', () => { // Conditional custom fields — trigger matching across every field type // --------------------------------------------------------------------------- -import { getFieldCondition, matchesTrigger, parseTriggerValues } from './schema'; - -describe('parseTriggerValues', () => { - it('splits on commas and trims whitespace', () => { - expect(parseTriggerValues(' red , blue,,green ')).toEqual(['red', 'blue', 'green']); +import { + getFieldCondition, + matchesTrigger, + parseTriggerValues +} from "./schema"; + +describe("parseTriggerValues", () => { + it("splits on commas and trims whitespace", () => { + expect(parseTriggerValues(" red , blue,,green ")).toEqual([ + "red", + "blue", + "green" + ]); }); - it('returns an empty list for blank input', () => { + it("returns an empty list for blank input", () => { expect(parseTriggerValues(undefined)).toEqual([]); - expect(parseTriggerValues('')).toEqual([]); + expect(parseTriggerValues("")).toEqual([]); }); }); -describe('matchesTrigger', () => { - it('matches select/radio option values', () => { - expect(matchesTrigger('red', ['red', 'blue'])).toBe(true); - expect(matchesTrigger('green', ['red', 'blue'])).toBe(false); - expect(matchesTrigger('', ['red', 'blue'])).toBe(false); +describe("matchesTrigger", () => { + it("matches select/radio option values", () => { + expect(matchesTrigger("red", ["red", "blue"])).toBe(true); + expect(matchesTrigger("green", ["red", "blue"])).toBe(false); + expect(matchesTrigger("", ["red", "blue"])).toBe(false); }); it('matches checkbox booleans against "true"/"false"', () => { - expect(matchesTrigger(true, ['true'])).toBe(true); - expect(matchesTrigger(false, ['true'])).toBe(false); - expect(matchesTrigger(false, ['false'])).toBe(true); - expect(matchesTrigger(undefined, ['false'])).toBe(false); + expect(matchesTrigger(true, ["true"])).toBe(true); + expect(matchesTrigger(false, ["true"])).toBe(false); + expect(matchesTrigger(false, ["false"])).toBe(true); + expect(matchesTrigger(undefined, ["false"])).toBe(false); }); - it('matches text values ignoring case and surrounding whitespace', () => { - expect(matchesTrigger(' Yes ', ['yes'])).toBe(true); - expect(matchesTrigger('yes', ['YES'])).toBe(true); - expect(matchesTrigger('no', ['yes'])).toBe(false); + it("matches text values ignoring case and surrounding whitespace", () => { + expect(matchesTrigger(" Yes ", ["yes"])).toBe(true); + expect(matchesTrigger("yes", ["YES"])).toBe(true); + expect(matchesTrigger("no", ["yes"])).toBe(false); }); - it('matches number values whether given as numbers or strings', () => { - expect(matchesTrigger(5, ['5'])).toBe(true); - expect(matchesTrigger('5', ['5'])).toBe(true); - expect(matchesTrigger(6, ['5'])).toBe(false); + it("matches number values whether given as numbers or strings", () => { + expect(matchesTrigger(5, ["5"])).toBe(true); + expect(matchesTrigger("5", ["5"])).toBe(true); + expect(matchesTrigger(6, ["5"])).toBe(false); }); - it('matches month/year values in MM/YYYY form', () => { - expect(matchesTrigger('01/2024', ['01/2024'])).toBe(true); - expect(matchesTrigger('02/2024', ['01/2024'])).toBe(false); + it("matches month/year values in MM/YYYY form", () => { + expect(matchesTrigger("01/2024", ["01/2024"])).toBe(true); + expect(matchesTrigger("02/2024", ["01/2024"])).toBe(false); }); - it('shows the field for any non-empty value when no trigger values are set', () => { - expect(matchesTrigger('anything', [])).toBe(true); + it("shows the field for any non-empty value when no trigger values are set", () => { + expect(matchesTrigger("anything", [])).toBe(true); expect(matchesTrigger(true, [])).toBe(true); expect(matchesTrigger(0, [])).toBe(true); - expect(matchesTrigger('', [])).toBe(false); - expect(matchesTrigger(' ', [])).toBe(false); + expect(matchesTrigger("", [])).toBe(false); + expect(matchesTrigger(" ", [])).toBe(false); expect(matchesTrigger(false, [])).toBe(false); expect(matchesTrigger(undefined, [])).toBe(false); }); }); -describe('getFieldCondition', () => { - it('returns null when no trigger field is configured', () => { - expect(getFieldCondition({ id: 'a' })).toBeNull(); - expect(getFieldCondition({ id: 'a', conditional_trigger_field: ' ' })).toBeNull(); +describe("getFieldCondition", () => { + it("returns null when no trigger field is configured", () => { + expect(getFieldCondition({ id: "a" })).toBeNull(); + expect( + getFieldCondition({ id: "a", conditional_trigger_field: " " }) + ).toBeNull(); }); it('returns null when "display conditionally" is explicitly off', () => { expect( getFieldCondition({ - id: 'a', + id: "a", display_conditionally: false, - conditional_trigger_field: 'b', - conditional_trigger_values: 'x', + conditional_trigger_field: "b", + conditional_trigger_values: "x" }) ).toBeNull(); }); - it('returns the parsed condition when enabled', () => { + it("returns the parsed condition when enabled", () => { expect( getFieldCondition({ - id: 'a', + id: "a", display_conditionally: true, - conditional_trigger_field: ' b ', - conditional_trigger_values: 'x, y', + conditional_trigger_field: " b ", + conditional_trigger_values: "x, y" }) - ).toEqual({ triggerField: 'b', triggerValues: ['x', 'y'] }); + ).toEqual({ triggerField: "b", triggerValues: ["x", "y"] }); }); it('treats a missing "display conditionally" flag as enabled', () => { - expect(getFieldCondition({ id: 'a', conditional_trigger_field: 'b' })).toEqual({ - triggerField: 'b', - triggerValues: [], + expect( + getFieldCondition({ id: "a", conditional_trigger_field: "b" }) + ).toEqual({ + triggerField: "b", + triggerValues: [] }); }); }); -import { getPlanRedirectUrl } from './schema'; +import { getPlanRedirectUrl } from "./schema"; -describe('getPlanRedirectUrl', () => { +describe("getPlanRedirectUrl", () => { const PLAN_REDIRECT = { ...PLAN_STANDARD, - value: 'student', - label: 'Student', - redirectUrl: 'https://example.org/students', + value: "student", + label: "Student", + redirectUrl: "https://example.org/students" }; const withPlans = (fn: () => void) => withEnv({ MEMBERSHIP_PLANS: [PLAN_STANDARD, PLAN_REDIRECT] }, fn); - it('returns the redirect URL for a plan marked to redirect', () => { + it("returns the redirect URL for a plan marked to redirect", () => { withPlans(() => { - expect(getPlanRedirectUrl('student')).toBe('https://example.org/students'); + expect(getPlanRedirectUrl("student")).toBe( + "https://example.org/students" + ); }); }); - it('returns null for a plan that takes payment as normal', () => { + it("returns null for a plan that takes payment as normal", () => { withPlans(() => { - expect(getPlanRedirectUrl('standard')).toBeNull(); + expect(getPlanRedirectUrl("standard")).toBeNull(); }); }); - it('returns null when no plan is selected', () => { + it("returns null when no plan is selected", () => { withPlans(() => { expect(getPlanRedirectUrl(undefined)).toBeNull(); }); diff --git a/packages/join-flow/src/schema.ts b/packages/join-flow/src/schema.ts index b3241393..aa64e6a8 100644 --- a/packages/join-flow/src/schema.ts +++ b/packages/join-flow/src/schema.ts @@ -56,7 +56,10 @@ const Prerequesites = object({ sessionToken: string().required() }).required(); -function isValidDayOfMonth(this: TestContext, value: number | null | undefined | object) { +function isValidDayOfMonth( + this: TestContext, + value: number | null | undefined | object +) { const correctDaysInMonth = getDaysInMonth( new Date(this.parent.dobYear, this.parent.dobMonth - 1) ); @@ -64,7 +67,10 @@ function isValidDayOfMonth(this: TestContext, value: number | null | undefined | return Number(value) <= correctDaysInMonth; } -function isInPast(this: TestContext, value: number | null | undefined | object) { +function isInPast( + this: TestContext, + value: number | null | undefined | object +) { return isPast( new Date( this.parent.dobYear, @@ -74,12 +80,15 @@ function isInPast(this: TestContext, value: number | null | undefined | object) ); } -function isAtLeast18(this: TestContext, value: number | null | undefined | object) { +function isAtLeast18( + this: TestContext, + value: number | null | undefined | object +) { const { dobYear, dobMonth, dobDay } = this.parent; if (!dobYear || !dobMonth || !dobDay) { return true; } - const minimumAge = Number(getEnv('MINIMUM_AGE')) || 18; + const minimumAge = Number(getEnv("MINIMUM_AGE")) || 18; const dob = new Date(dobYear, dobMonth - 1, dobDay); const minimumAgeBirthday = new Date( dob.getFullYear() + minimumAge, @@ -330,23 +339,29 @@ export const getPlanRedirectUrl = (name: string | undefined): string | null => { }; export const renderDonationSummary = (data: FormSchema): string => { - const { donationAmount, recurDonation, donationSupporterMode, membership, customMembershipAmount } = data; + const { + donationAmount, + recurDonation, + donationSupporterMode, + membership, + customMembershipAmount + } = data; if (donationSupporterMode && recurDonation) { // Monthly supporter mode: amount comes from the plan, not donationAmount const plan = getPaymentPlan(membership); const amount = customMembershipAmount || plan?.amount; if (amount) { - const currency = currencyCodeToSymbol(plan?.currency || 'GBP'); + const currency = currencyCodeToSymbol(plan?.currency || "GBP"); return `${currency}${amount} a month donation`; } } if (donationAmount && donationAmount > 0) { - return `£${donationAmount} ${recurDonation ? 'a month donation' : 'one time donation'}`; + return `£${donationAmount} ${recurDonation ? "a month donation" : "one time donation"}`; } - return 'None right now'; + return "None right now"; }; export const renderPaymentPlan = ({ @@ -381,7 +396,7 @@ export const renderPaymentMethod = ({ paymentMethod, membership, recurDonation, - donationSupporterMode, + donationSupporterMode }: FormSchema) => { let paymentFormat = "None"; @@ -507,4 +522,6 @@ export const getTestDataIfEnabled = (): FormSchema => { } }; -export const validate = memoize((schema: AnyObjectSchema) => yupResolver(schema)); +export const validate = memoize((schema: AnyObjectSchema) => + yupResolver(schema) +); diff --git a/packages/join-flow/src/services/address-lookup.service.ts b/packages/join-flow/src/services/address-lookup.service.ts index 6b1d61bc..ed186e25 100644 --- a/packages/join-flow/src/services/address-lookup.service.ts +++ b/packages/join-flow/src/services/address-lookup.service.ts @@ -2,7 +2,7 @@ import { useState } from "react"; import { compact, uniqueId } from "lodash-es"; import { UseFormMethods } from "react-hook-form"; -import { get as getEnv } from '../env'; +import { get as getEnv } from "../env"; /** * Represents an Ideal Postcodes result, which has @@ -26,12 +26,12 @@ class Address { */ constructor() { this.id = uniqueId("idealpostcodes"); - this.address = ''; + this.address = ""; } toString() { if (this.address) { - return this.address + return this.address; } return compact([ @@ -49,24 +49,27 @@ export const useAddressLookup = (form: UseFormMethods) => { const [address, setAddressValue] = useState
(); const [loading, setLoading] = useState(false); const [message, setMessage] = useState(null); - const [messageType, setMessageType] = useState<'error' | 'info' | null>(null); + const [messageType, setMessageType] = useState<"error" | "info" | null>(null); const setPostcode = async (postcode: string) => { const endpoint = "join/v1/postcode"; - const baseUrl = (getEnv('WP_REST_API') as string).replace(/\/$/, ''); // trim trailing slash - + const baseUrl = (getEnv("WP_REST_API") as string).replace(/\/$/, ""); // trim trailing slash + // Clear previous messages setMessage(null); setMessageType(null); - + try { - const res = await fetch(`${baseUrl}/${endpoint}?postcode=${encodeURIComponent(postcode)}`, { - method: "GET", - headers: { - "content-type": "application/json", - accept: "application/json" + const res = await fetch( + `${baseUrl}/${endpoint}?postcode=${encodeURIComponent(postcode)}`, + { + method: "GET", + headers: { + "content-type": "application/json", + accept: "application/json" + } } - }); + ); if (!res.ok) { throw Error(await res.text()); @@ -75,45 +78,50 @@ export const useAddressLookup = (form: UseFormMethods) => { const response = await res.json(); // Handle bad postcode (validation failed) - if (response.status === 'bad_postcode') { + if (response.status === "bad_postcode") { setMessage(response.message); - setMessageType('error'); + setMessageType("error"); setOptions([]); setAddressValue(undefined); return; } // Handle successful lookup - if (response.status === 'ok') { + if (response.status === "ok") { setAddressValue(undefined); - setOptions(response.data.map((addr: any) => Object.assign(new Address(), addr))); - + setOptions( + response.data.map((addr: any) => Object.assign(new Address(), addr)) + ); + // Handle enrichment message (positive info like local branch) if (response.message) { setMessage(response.message); - setMessageType('info'); + setMessageType("info"); } return; } - throw new Error('Postcode address lookup failed: ' + response.status); + throw new Error("Postcode address lookup failed: " + response.status); } catch (error: any) { - console.error(error.message) + console.error(error.message); setOptions([]); } }; const fetchAddress = async (id: string) => { const endpoint = "join/v1/address"; - const baseUrl = (getEnv('WP_REST_API') as string).replace(/\/$/, ''); // trim trailing slash + const baseUrl = (getEnv("WP_REST_API") as string).replace(/\/$/, ""); // trim trailing slash try { - const res = await fetch(`${baseUrl}/${endpoint}?id=${encodeURIComponent(id)}`, { - method: "GET", - headers: { - "content-type": "application/json", - accept: "application/json" + const res = await fetch( + `${baseUrl}/${endpoint}?id=${encodeURIComponent(id)}`, + { + method: "GET", + headers: { + "content-type": "application/json", + accept: "application/json" + } } - }); + ); if (!res.ok) { throw Error(await res.text()); @@ -121,16 +129,16 @@ export const useAddressLookup = (form: UseFormMethods) => { const response = await res.json(); - if (response.status !== 'ok') { - throw new Error('Postcode address lookup failed: ' + response.status) + if (response.status !== "ok") { + throw new Error("Postcode address lookup failed: " + response.status); } - return response.data + return response.data; } catch (error: any) { - console.error(error.message) + console.error(error.message); } - return {} - } + return {}; + }; const setAddress = async (id: string) => { const hit = options?.find((x) => x.id === id); @@ -161,9 +169,9 @@ export const useAddressLookup = (form: UseFormMethods) => { // Check which provider is configured to determine if we need a second API call: // - Ideal Postcodes returns complete structured data immediately // - getAddress.io returns only id/address in autocomplete and requires a second call - const provider = getEnv('POSTCODE_ADDRESS_PROVIDER') as string; - const isGetAddressIo = provider === 'get_address_io'; - + const provider = getEnv("POSTCODE_ADDRESS_PROVIDER") as string; + const isGetAddressIo = provider === "get_address_io"; + if (isGetAddressIo) { // getAddress.io: need to fetch full address details setLoading(true); @@ -182,7 +190,7 @@ export const useAddressLookup = (form: UseFormMethods) => { setFormValue("addressPostcode", address.postcode); setFormValue("addressCountry", "GB"); } - setLoading(false) + setLoading(false); }); }; diff --git a/packages/join-flow/src/services/rest-resource.service.ts b/packages/join-flow/src/services/rest-resource.service.ts index 3d83b0e9..6ad473bc 100644 --- a/packages/join-flow/src/services/rest-resource.service.ts +++ b/packages/join-flow/src/services/rest-resource.service.ts @@ -1,5 +1,5 @@ -import { get as getEnv, getStr as getEnvStr } from '../env'; -import { formatPhoneE164 } from '../schema'; +import { get as getEnv, getStr as getEnvStr } from "../env"; +import { formatPhoneE164 } from "../schema"; export const usePostResource = (resource: string) => { return async (data: Params): Promise => { @@ -18,7 +18,7 @@ export const usePostResource = (resource: string) => { : {}) }; - const baseUrl = getEnvStr('WP_REST_API').replace(/\/$/, ''); // trim trailing slash + const baseUrl = getEnvStr("WP_REST_API").replace(/\/$/, ""); // trim trailing slash const res = await fetch(`${baseUrl}/${endpoint}`, { method: "POST", headers: { @@ -30,7 +30,10 @@ export const usePostResource = (resource: string) => { if (!res.ok) { const body = await res.text(); - const err = new Error(body) as Error & { status: number; resource: string }; + const err = new Error(body) as Error & { + status: number; + resource: string; + }; err.status = res.status; err.resource = resource; throw err; diff --git a/packages/join-flow/src/services/router.service.test.ts b/packages/join-flow/src/services/router.service.test.ts index 518dae89..444943c0 100644 --- a/packages/join-flow/src/services/router.service.test.ts +++ b/packages/join-flow/src/services/router.service.test.ts @@ -1,25 +1,29 @@ -import { redirectToPage, SAVED_STATE_KEY } from './router.service'; +import { redirectToPage, SAVED_STATE_KEY } from "./router.service"; -describe('redirectToPage', () => { +describe("redirectToPage", () => { // jsdom only implements same-document navigation, so target a fragment of // the current page to observe the browser actually moving. - const target = () => `${window.location.origin}${window.location.pathname}#students`; + const target = () => + `${window.location.origin}${window.location.pathname}#students`; beforeEach(() => { - window.location.hash = ''; - sessionStorage.setItem(SAVED_STATE_KEY, JSON.stringify({ membership: 'student' })); + window.location.hash = ""; + sessionStorage.setItem( + SAVED_STATE_KEY, + JSON.stringify({ membership: "student" }) + ); }); afterEach(() => { sessionStorage.clear(); }); - it('sends the browser to the given page', () => { + it("sends the browser to the given page", () => { redirectToPage(target()); - expect(window.location.hash).toBe('#students'); + expect(window.location.hash).toBe("#students"); }); - it('clears the saved form state so returning starts afresh', () => { + it("clears the saved form state so returning starts afresh", () => { redirectToPage(target()); expect(sessionStorage.getItem(SAVED_STATE_KEY)).toBeNull(); }); diff --git a/packages/join-flow/src/services/router.service.ts b/packages/join-flow/src/services/router.service.ts index f5a6538d..3c339bd0 100644 --- a/packages/join-flow/src/services/router.service.ts +++ b/packages/join-flow/src/services/router.service.ts @@ -11,12 +11,12 @@ import { FormSchema } from "../schema"; export interface PageState { stage: - | "enter-details" - | "plan" - | "donation" - | "payment-method" - | "payment-details" - | "confirm"; + | "enter-details" + | "plan" + | "donation" + | "payment-method" + | "payment-details" + | "confirm"; } export interface StateRouter { @@ -64,8 +64,8 @@ export const useStateRouter = ( ); const reset = () => { - window.history.pushState(null, "") - } + window.history.pushState(null, ""); + }; return { state, @@ -106,16 +106,16 @@ export const stripUrlParams = () => { * more URL-ish. */ const addQueryParameter = (url: string, data: any, key: string) => { - const name = snakeCase(key) + const name = snakeCase(key); if (data[key]) { - if (url.includes('?')) { - url += `&${name}=` + data[key] + if (url.includes("?")) { + url += `&${name}=` + data[key]; } else { - url += `?${name}=` + data[key] + url += `?${name}=` + data[key]; } } - return url -} + return url; +}; export const SAVED_STATE_KEY = "ck_join_state_flow"; @@ -125,13 +125,13 @@ export const redirectToPage = (url: string) => { return new Promise(() => { sessionStorage.removeItem(SAVED_STATE_KEY); window.location.href = url; - }) -} + }); +}; export const redirectToSuccess = (data: FormSchema) => { - let redirectTo = getEnv('SUCCESS_REDIRECT') as string || "/" - redirectTo = addQueryParameter(redirectTo, data, 'firstName') - redirectTo = addQueryParameter(redirectTo, data, 'email') - redirectTo = addQueryParameter(redirectTo, data, 'phoneNumber') + let redirectTo = (getEnv("SUCCESS_REDIRECT") as string) || "/"; + redirectTo = addQueryParameter(redirectTo, data, "firstName"); + redirectTo = addQueryParameter(redirectTo, data, "email"); + redirectTo = addQueryParameter(redirectTo, data, "phoneNumber"); return redirectToPage(redirectTo); -} +}; diff --git a/packages/join-flow/webpack/common.js b/packages/join-flow/webpack/common.js index 9c02d170..0a6836d9 100644 --- a/packages/join-flow/webpack/common.js +++ b/packages/join-flow/webpack/common.js @@ -7,18 +7,18 @@ const webpack = require("webpack"); // below, so release builds (scripts/package.sh) set JOIN_FLOW_IGNORE_DOTENV // to keep a developer's local .env out of the shipped bundle. if (!process.env.JOIN_FLOW_IGNORE_DOTENV) { - require("dotenv").config({ path: resolve(__dirname, '../.env') }); + require("dotenv").config({ path: resolve(__dirname, "../.env") }); } module.exports = { resolve: { extensions: [".ts", ".tsx", ".js", ".jsx", ".ts", ".tsx"], - fallback: { "querystring": require.resolve("querystring-es3") } + fallback: { querystring: require.resolve("querystring-es3") } }, context: resolve(__dirname, "../src"), output: { filename: "bundle.js", - path: resolve(__dirname, "../../join-block/build/join-flow"), + path: resolve(__dirname, "../../join-block/build/join-flow") }, module: { rules: [ @@ -49,7 +49,7 @@ module.exports = { }, { test: /\.(jpe?g|png|gif|svg|woff2)$/i, - type: 'asset/resource' + type: "asset/resource" } ] }, @@ -58,13 +58,13 @@ module.exports = { template: resolve(__dirname, "../public/index.html") }), new webpack.DefinePlugin({ - "process.env": Object.keys(process.env).reduce((env, key) => { - if (key.startsWith("REACT_APP_")) { - env[key] = JSON.stringify(process.env[key]) - } - return env - }, {}) - }) + "process.env": Object.keys(process.env).reduce((env, key) => { + if (key.startsWith("REACT_APP_")) { + env[key] = JSON.stringify(process.env[key]); + } + return env; + }, {}) + }) ], performance: { hints: "warning" diff --git a/packages/join-flow/webpack/dev.js b/packages/join-flow/webpack/dev.js index fa6feee0..0f2903ac 100644 --- a/packages/join-flow/webpack/dev.js +++ b/packages/join-flow/webpack/dev.js @@ -18,7 +18,8 @@ module.exports = merge(commonConfig, { headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS", - "Access-Control-Allow-Headers": "X-Requested-With, content-type, Authorization" + "Access-Control-Allow-Headers": + "X-Requested-With, content-type, Authorization" }, // The standalone harness has no WordPress backend, so stub the REST // endpoints the form posts to (mirrors mockRestEndpoints in join-e2e). @@ -26,7 +27,10 @@ module.exports = merge(commonConfig, { setupMiddlewares: (middlewares, devServer) => { devServer.app.use(require("express").json()); devServer.app.post("/join/v1/:resource", (req, res) => { - console.log(`[mock] POST /join/v1/${req.params.resource}`, JSON.stringify(req.body)); + console.log( + `[mock] POST /join/v1/${req.params.resource}`, + JSON.stringify(req.body) + ); res.json({}); }); return middlewares; diff --git a/readme.md b/readme.md index a8911ec9..46a47370 100644 --- a/readme.md +++ b/readme.md @@ -436,6 +436,14 @@ cd packages/join-flow npm test ``` +### Frontend formatting + +CI fails if `packages/join-flow` isn't formatted with Prettier. To fix formatting, from the repository root: + +```bash +npm run prettier +``` + ### Backend unit tests ```bash