From b4337aaabad4796733419d475e5752bd7a5f7f3b Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Wed, 18 Mar 2026 16:38:17 +0000 Subject: [PATCH 01/48] Add DONATION_SUPPORTER_MODE block setting and env var --- packages/join-block/src/Blocks.php | 6 ++++++ packages/join-flow/src/env.ts | 2 ++ 2 files changed, 8 insertions(+) diff --git a/packages/join-block/src/Blocks.php b/packages/join-block/src/Blocks.php index 6b3404f..be87630 100644 --- a/packages/join-block/src/Blocks.php +++ b/packages/join-block/src/Blocks.php @@ -179,6 +179,11 @@ private static function registerJoinFormBlock() ->set_help_text('Check to completely hide the address section from the form.'), Field::make('checkbox', 'require_phone_number')->set_default_value(true), Field::make('checkbox', 'ask_for_additional_donation'), + Field::make('checkbox', 'donation_supporter_mode') + ->set_help_text( + 'Enable Supporter Mode: shows donation frequency and amount first, ' . + 'before personal details and payment. Skips the membership plan step.' + ), Field::make('checkbox', 'hide_home_address_copy') ->set_help_text('Check to hide the copy that explains why the address is collected.'), Field::make('checkbox', 'include_skip_payment_button') @@ -473,6 +478,7 @@ function ($o) { 'ABOUT_YOU_COPY' => wpautop(Settings::get("ABOUT_YOU_COPY")), 'ABOUT_YOU_HEADING' => Settings::get("ABOUT_YOU_HEADING"), "ASK_FOR_ADDITIONAL_DONATION" => $fields['ask_for_additional_donation'] ?? false, + "DONATION_SUPPORTER_MODE" => $fields['donation_supporter_mode'] ?? false, 'CHARGEBEE_SITE_NAME' => Settings::get('CHARGEBEE_SITE_NAME'), "CHARGEBEE_API_PUBLISHABLE_KEY" => Settings::get('CHARGEBEE_API_PUBLISHABLE_KEY'), "COLLECT_COUNTY" => Settings::get("COLLECT_COUNTY"), diff --git a/packages/join-flow/src/env.ts b/packages/join-flow/src/env.ts index 0be903f..90f4f26 100644 --- a/packages/join-flow/src/env.ts +++ b/packages/join-flow/src/env.ts @@ -2,6 +2,7 @@ interface StaticEnv { ABOUT_YOU_COPY: string; ABOUT_YOU_HEADING: string; ASK_FOR_ADDITIONAL_DONATION: boolean; + DONATION_SUPPORTER_MODE: boolean; CHARGEBEE_API_PUBLISHABLE_KEY: string; CHARGEBEE_SITE_NAME: string; COLLECT_COUNTY: boolean; @@ -71,6 +72,7 @@ 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"), 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"), From 93c09a9a82b4ab5418b78e1ab58e8ee363ac6a8c Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Wed, 18 Mar 2026 16:39:03 +0000 Subject: [PATCH 02/48] Supporter mode: reorder stages and update routing in app.tsx --- packages/join-flow/src/app.tsx | 35 ++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/join-flow/src/app.tsx b/packages/join-flow/src/app.tsx index eaa03c8..400b1e5 100644 --- a/packages/join-flow/src/app.tsx +++ b/packages/join-flow/src/app.tsx @@ -71,6 +71,17 @@ const App = () => { if (getEnv("IS_UPDATE_FLOW")) { stages = stages.filter((s) => s.id !== "enter-details"); } + + 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 } + ]; + } + const [data, setData] = useState(getInitialState); const [blockingMessage, setBlockingMessage] = useState(null); @@ -126,24 +137,39 @@ const App = () => { let nextStage = router.state.stage; - if (router.state.stage === "enter-details") { + if (getEnv("DONATION_SUPPORTER_MODE")) { + if (router.state.stage === "donation") { + 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.'); + return; + } + setBlockingMessage(null); + } + // payment-method, payment-details, confirm: fall through to shared logic below + } 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" }); - + // Check if the form progression is blocked if (response?.status === 'blocked') { setBlockingMessage(response.message || 'Unable to proceed with this submission.'); return; // Stop progression } - + // Clear any previous blocking message setBlockingMessage(null); } else if (router.state.stage === "plan") { nextStage = "donation"; } else if (router.state.stage === "donation") { nextStage = "payment-method"; - } else if (router.state.stage === "payment-method") { + } + + if (router.state.stage === "payment-method") { // Check if this is a zero-price membership before setting next stage // This handles the case where the user is ON the payment-method page // (e.g., when there are multiple payment methods to choose from) @@ -395,6 +421,7 @@ const getInitialState = (): FormSchema => { sessionToken: uuid.v4(), ...getTestDataIfEnabled(), ...getDefaultState(), + ...(getEnv("DONATION_SUPPORTER_MODE") ? { recurDonation: true } : {}), ...getSavedState(), ...getProvidedStateFromQueryParams(), isUpdateFlow: getEnv("IS_UPDATE_FLOW"), From a09bf42d65fa29c0c38bc0fac12b0fb5d3515d51 Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Wed, 18 Mar 2026 16:39:47 +0000 Subject: [PATCH 03/48] Supporter mode: add frequency/tier UI to donation page --- .../join-flow/src/pages/donation.page.tsx | 129 ++++++++++++++++-- 1 file changed, 115 insertions(+), 14 deletions(-) diff --git a/packages/join-flow/src/pages/donation.page.tsx b/packages/join-flow/src/pages/donation.page.tsx index 3f46ced..f0c5386 100644 --- a/packages/join-flow/src/pages/donation.page.tsx +++ b/packages/join-flow/src/pages/donation.page.tsx @@ -5,6 +5,10 @@ import { ContinueButton, FormItem } from "../components/atoms"; import { StagerComponent } from "../components/stager"; import { Summary } from "../components/summary"; import { FormSchema } from "../schema"; +import { get as getEnv } from "../env"; + +const SUPPORTER_MONTHLY_TIERS = [3, 5, 10, 20]; +const SUPPORTER_ONEOFF_TIERS = [10, 25, 50, 100]; const membershipToDonationTiers = (membership: string): Array => { switch (membership) { @@ -29,28 +33,125 @@ export const DonationPage: StagerComponent = ({ ? membershipToDonationTiers(data.membership) : [5, 10, 15, 20]; + const supporterMode = Boolean(getEnv("DONATION_SUPPORTER_MODE")); + const form = useForm({ defaultValues: { - donationAmount: donationTiers[1], + donationAmount: supporterMode ? SUPPORTER_MONTHLY_TIERS[1] : donationTiers[1], + recurDonation: supporterMode ? true : false, ...data } }); const selectedDonationAmount = form.watch("donationAmount"); + const recurDonation = form.watch("recurDonation"); + const otherDonationAmount = form.watch("otherDonationAmount"); + + const handleSubmit = form.handleSubmit((formData) => { + if (formData.otherDonationAmount !== "" && formData.otherDonationAmount != null) { + formData.donationAmount = formData.otherDonationAmount; + delete formData.otherDonationAmount; + } + onCompleted(formData); + }); + + if (supporterMode) { + const activeTiers = recurDonation ? SUPPORTER_MONTHLY_TIERS : SUPPORTER_ONEOFF_TIERS; + const activeAmount = otherDonationAmount != null && otherDonationAmount !== "" + ? Number(otherDonationAmount) + : Number(selectedDonationAmount); + const ctaLabel = activeAmount > 0 + ? (recurDonation ? `Donate £${activeAmount}/month` : `Donate £${activeAmount} now`) + : (recurDonation ? "Donate monthly" : "Donate now"); + + return ( +
+
+ +

Support us

+
+
+ +
+
+ + +
+ +
+ {activeTiers.map((tier) => ( + + ))} +
+ + + + +
+ + + +
+ +
+ + ); + } return (
{ - // From the perspective of the form schema, keep things clean with only having one variable for donation amount - // So remove the otherDonationAmount if we have it and copy it across. - if (data.otherDonationAmount !== "") { - data.donationAmount = data.otherDonationAmount; - delete data.otherDonationAmount; - } - - onCompleted(data); - })} + onSubmit={handleSubmit} >
@@ -61,12 +162,12 @@ 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 + 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. + 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.

From 8819a389b018cc1f3438ca97f69f2eedc3be0efc Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Wed, 18 Mar 2026 16:40:21 +0000 Subject: [PATCH 04/48] Add donation support to Stripe subscription creation --- packages/join-block/join.php | 16 ++++- .../join-block/src/Services/StripeService.php | 72 ++++++++++++++++--- 2 files changed, 76 insertions(+), 12 deletions(-) diff --git a/packages/join-block/join.php b/packages/join-block/join.php index bb799f1..24622bc 100644 --- a/packages/join-block/join.php +++ b/packages/join-block/join.php @@ -496,7 +496,13 @@ StripeService::initialise(); [$customer, $newCustomer] = StripeService::upsertCustomer($email); - $subscription = StripeService::createSubscription($customer, $plan, $data["customMembershipAmount"] ?? null); + $subscription = StripeService::createSubscription( + $customer, + $plan, + $data["customMembershipAmount"] ?? null, + $data["donationAmount"] ?? null, + $data["recurDonation"] ?? false + ); return $subscription; } catch (\Exception $e) { @@ -538,7 +544,13 @@ StripeService::initialise(); [$customer, $newCustomer] = StripeService::upsertCustomer($email); - $subscription = StripeService::createSubscription($customer, $plan); + $subscription = StripeService::createSubscription( + $customer, + $plan, + $data["customMembershipAmount"] ?? null, + $data["donationAmount"] ?? null, + $data["recurDonation"] ?? false + ); $confirmedPaymentIntent = StripeService::confirmSubscriptionPaymentIntent($subscription, $data['confirmationTokenId']); diff --git a/packages/join-block/src/Services/StripeService.php b/packages/join-block/src/Services/StripeService.php index d22a6d6..8407120 100644 --- a/packages/join-block/src/Services/StripeService.php +++ b/packages/join-block/src/Services/StripeService.php @@ -69,7 +69,7 @@ public static function upsertCustomer($email) return [$customer, $newCustomer]; } - public static function createSubscription($customer, $plan, $customAmount = null) + public static function createSubscription($customer, $plan, $customAmount = null, $donationAmount = null, $recurDonation = false) { $priceId = $plan["stripe_price_id"]; $customAmount = (float) $customAmount; @@ -78,22 +78,74 @@ public static function createSubscription($customer, $plan, $customAmount = null $product = self::getOrCreateProductForMembershipTier($plan); $priceId = self::getOrCreatePriceForProduct($product, $customAmount, $plan['currency'], self::convertFrequencyToStripeInterval($plan['frequency'])); } - $subscription = Subscription::create([ - 'customer' => $customer->id, - 'items' => [ - [ - 'price' => $priceId, - ], - ], + + $items = [['price' => $priceId]]; + $addInvoiceItems = []; + + $donationAmount = (float) $donationAmount; + if ($donationAmount > 0) { + $donationProduct = self::getOrCreateDonationProduct(); + if ($recurDonation) { + $interval = self::convertFrequencyToStripeInterval($plan['frequency']); + $donationPriceId = self::getOrCreatePriceForProduct( + $donationProduct, $donationAmount, $plan['currency'], $interval + ); + $items[] = ['price' => $donationPriceId]; + } else { + $addInvoiceItems[] = [ + 'price_data' => [ + 'currency' => strtolower($plan['currency']), + 'product' => $donationProduct->id, + 'unit_amount' => (int) round($donationAmount * 100), + ] + ]; + } + } + + $subscriptionPayload = [ + 'customer' => $customer->id, + 'items' => $items, 'payment_behavior' => 'default_incomplete', 'collection_method' => 'charge_automatically', 'payment_settings' => ['save_default_payment_method' => 'on_subscription', 'payment_method_types' => ['card', 'bacs_debit']], - 'expand' => ['latest_invoice.payment_intent'], - ]); + 'expand' => ['latest_invoice.payment_intent'], + ]; + + if (!empty($addInvoiceItems)) { + $subscriptionPayload['add_invoice_items'] = $addInvoiceItems; + } + + $subscription = Subscription::create($subscriptionPayload); return $subscription; } + public static function getOrCreateDonationProduct() + { + global $joinBlockLog; + + try { + $existingProducts = \Stripe\Product::search([ + 'query' => "active:'true' AND metadata['type']:'supporter_donation'", + ]); + + if (count($existingProducts->data) > 0) { + return $existingProducts->data[0]; + } + + $joinBlockLog->info("Creating Stripe product for supporter donations"); + + return \Stripe\Product::create([ + 'name' => 'Supporter Donation', + 'type' => 'service', + 'metadata' => ['type' => 'supporter_donation'], + ]); + } catch (\Stripe\Exception\ApiErrorException $e) { + $joinBlockLog->error("Error creating/retrieving donation product: " . $e->getMessage()); + throw $e; + } + } + public static function getSubscriptionsForCSVOutput() { global $joinBlockLog; From 79cce9e1a85429dc7fe1534c1c7b4133772b72a6 Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Wed, 18 Mar 2026 16:40:39 +0000 Subject: [PATCH 05/48] Add recurring donation support to GoCardless subscription amount --- packages/join-block/src/Services/GocardlessService.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/join-block/src/Services/GocardlessService.php b/packages/join-block/src/Services/GocardlessService.php index a9a489f..1bbe4a1 100644 --- a/packages/join-block/src/Services/GocardlessService.php +++ b/packages/join-block/src/Services/GocardlessService.php @@ -104,6 +104,13 @@ public static function createCustomerSubscription($data) } $amountInPence = round(((float) $data['membershipPlan']['amount']) * 100); + // Add recurring donation to subscription amount. + // One-off donations are not supported for Direct Debit and are silently ignored. + $donationAmount = (float) ($data['donationAmount'] ?? 0); + if ($donationAmount > 0 && !empty($data['recurDonation'])) { + $amountInPence += round($donationAmount * 100); + } + $subscriptions = $client->subscriptions()->list([ "params" => ["mandate" => $mandate->id] ]); From 92c58c753da349d236e8d6d71adddefcd2823583 Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Wed, 18 Mar 2026 16:50:32 +0000 Subject: [PATCH 06/48] Supporter mode: add frequency/tier UI to donation page --- .../join-flow/src/pages/donation.page.tsx | 38 ++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/packages/join-flow/src/pages/donation.page.tsx b/packages/join-flow/src/pages/donation.page.tsx index f0c5386..3947e32 100644 --- a/packages/join-flow/src/pages/donation.page.tsx +++ b/packages/join-flow/src/pages/donation.page.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState } from "react"; import { Button, Form } from "react-bootstrap"; import { useForm } from "react-hook-form"; import { ContinueButton, FormItem } from "../components/atoms"; @@ -35,6 +35,10 @@ export const DonationPage: StagerComponent = ({ const supporterMode = Boolean(getEnv("DONATION_SUPPORTER_MODE")); + // Local state for supporter mode to drive reliable re-renders + const [isMonthly, setIsMonthly] = useState(true); + const [selectedTier, setSelectedTier] = useState(SUPPORTER_MONTHLY_TIERS[1]); + const form = useForm({ defaultValues: { donationAmount: supporterMode ? SUPPORTER_MONTHLY_TIERS[1] : donationTiers[1], @@ -44,7 +48,6 @@ export const DonationPage: StagerComponent = ({ }); const selectedDonationAmount = form.watch("donationAmount"); - const recurDonation = form.watch("recurDonation"); const otherDonationAmount = form.watch("otherDonationAmount"); const handleSubmit = form.handleSubmit((formData) => { @@ -56,16 +59,20 @@ export const DonationPage: StagerComponent = ({ }); if (supporterMode) { - const activeTiers = recurDonation ? SUPPORTER_MONTHLY_TIERS : SUPPORTER_ONEOFF_TIERS; - const activeAmount = otherDonationAmount != null && otherDonationAmount !== "" + const activeTiers = isMonthly ? SUPPORTER_MONTHLY_TIERS : SUPPORTER_ONEOFF_TIERS; + const activeAmount = (otherDonationAmount != null && otherDonationAmount !== "") ? Number(otherDonationAmount) - : Number(selectedDonationAmount); + : selectedTier; const ctaLabel = activeAmount > 0 - ? (recurDonation ? `Donate £${activeAmount}/month` : `Donate £${activeAmount} now`) - : (recurDonation ? "Donate monthly" : "Donate now"); + ? (isMonthly ? `Donate £${activeAmount}/month` : `Donate £${activeAmount} now`) + : (isMonthly ? "Donate monthly" : "Donate now"); return ( + {/* Hidden registered inputs so values appear in handleSubmit data */} + + +

Support us

@@ -76,8 +83,10 @@ export const DonationPage: StagerComponent = ({
-
); } From d3745b05e130742eb6263f2e765969add98aabde Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Wed, 18 Mar 2026 17:11:03 +0000 Subject: [PATCH 09/48] Supporter mode: derive donation tiers from membership plans, same tiers for monthly and one-off --- .../src/pages/donation.page.test.tsx | 41 ++++++++++--------- .../join-flow/src/pages/donation.page.tsx | 28 ++++++------- 2 files changed, 34 insertions(+), 35 deletions(-) diff --git a/packages/join-flow/src/pages/donation.page.test.tsx b/packages/join-flow/src/pages/donation.page.test.tsx index 76f0bfe..3af7c99 100644 --- a/packages/join-flow/src/pages/donation.page.test.tsx +++ b/packages/join-flow/src/pages/donation.page.test.tsx @@ -21,6 +21,12 @@ const baseData = { 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' }, +]; + function renderDonationPage(data = baseData) { return render( @@ -53,9 +59,11 @@ describe('DonationPage — standard mode (DONATION_SUPPORTER_MODE off)', () => { describe('DonationPage — supporter mode (DONATION_SUPPORTER_MODE on)', () => { beforeEach(() => { - mockGetEnv.mockImplementation((key: string) => - key === 'DONATION_SUPPORTER_MODE' ? true : false - ); + mockGetEnv.mockImplementation((key: string) => { + if (key === 'DONATION_SUPPORTER_MODE') return true; + if (key === 'MEMBERSHIP_PLANS') return MOCK_PLANS; + return false; + }); }); test('renders Monthly and One-off toggle buttons', () => { @@ -73,31 +81,26 @@ describe('DonationPage — supporter mode (DONATION_SUPPORTER_MODE on)', () => { ).not.toBeInTheDocument(); }); - test('Monthly is active by default and shows monthly tiers', () => { + test('Monthly is active by default and shows plan tiers', () => { renderDonationPage(); - const monthlyBtn = screen.getByText('Monthly'); - expect(monthlyBtn).toHaveClass('btn-dark'); + expect(screen.getByText('Monthly')).toHaveClass('btn-dark'); - // Monthly tiers: 3, 5, 10, 20 - expect(screen.getByText('£3')).toBeInTheDocument(); + // Tiers from MOCK_PLANS amounts expect(screen.getByText('£5')).toBeInTheDocument(); expect(screen.getByText('£10')).toBeInTheDocument(); expect(screen.getByText('£20')).toBeInTheDocument(); }); - test('clicking One-off switches to one-off tiers', () => { + test('same tiers are shown after switching to One-off', () => { renderDonationPage(); fireEvent.click(screen.getByText('One-off')); - // One-off tiers: 10, 25, 50, 100 - expect(screen.getByText('£25')).toBeInTheDocument(); - expect(screen.getByText('£50')).toBeInTheDocument(); - expect(screen.getByText('£100')).toBeInTheDocument(); - - // Monthly-only tier £3 should no longer be present - expect(screen.queryByText('£3')).not.toBeInTheDocument(); + // Same tiers — amounts do not change with the toggle + expect(screen.getByText('£5')).toBeInTheDocument(); + expect(screen.getByText('£10')).toBeInTheDocument(); + expect(screen.getByText('£20')).toBeInTheDocument(); }); test('skip button is not present in supporter mode', () => { @@ -109,7 +112,7 @@ describe('DonationPage — supporter mode (DONATION_SUPPORTER_MODE on)', () => { test('CTA label reflects monthly selection', () => { renderDonationPage(); - // Default: Monthly £5 (index 1 of [3,5,10,20]) + // Default: first plan tier £5, monthly expect(screen.getByText(/Donate £5\/month/i)).toBeInTheDocument(); }); @@ -118,7 +121,7 @@ describe('DonationPage — supporter mode (DONATION_SUPPORTER_MODE on)', () => { fireEvent.click(screen.getByText('One-off')); - // Default after switching: £25 (index 1 of [10,25,50,100]) - expect(screen.getByText(/Donate £25 now/i)).toBeInTheDocument(); + // Same selected tier (£5), now one-off + expect(screen.getByText(/Donate £5 now/i)).toBeInTheDocument(); }); }); diff --git a/packages/join-flow/src/pages/donation.page.tsx b/packages/join-flow/src/pages/donation.page.tsx index e75058e..c399b80 100644 --- a/packages/join-flow/src/pages/donation.page.tsx +++ b/packages/join-flow/src/pages/donation.page.tsx @@ -4,12 +4,9 @@ import { useForm } from "react-hook-form"; import { ContinueButton, FormItem } from "../components/atoms"; import { StagerComponent } from "../components/stager"; import { Summary } from "../components/summary"; -import { FormSchema } from "../schema"; +import { FormSchema, currencyCodeToSymbol } from "../schema"; import { get as getEnv } from "../env"; -const SUPPORTER_MONTHLY_TIERS = [3, 5, 10, 20]; -const SUPPORTER_ONEOFF_TIERS = [10, 25, 50, 100]; - const membershipToDonationTiers = (membership: string): Array => { switch (membership) { case "suggested": @@ -35,13 +32,18 @@ export const DonationPage: StagerComponent = ({ const supporterMode = Boolean(getEnv("DONATION_SUPPORTER_MODE")); + const membershipPlans = (getEnv("MEMBERSHIP_PLANS") as any[]) || []; + 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; + // Local state for supporter mode to drive reliable re-renders const [isMonthly, setIsMonthly] = useState(true); - const [selectedTier, setSelectedTier] = useState(SUPPORTER_MONTHLY_TIERS[1]); + const [selectedTier, setSelectedTier] = useState(defaultSupporterTier); const form = useForm({ defaultValues: { - donationAmount: supporterMode ? SUPPORTER_MONTHLY_TIERS[1] : donationTiers[1], + donationAmount: supporterMode ? defaultSupporterTier : donationTiers[1], recurDonation: supporterMode ? true : false, ...data } @@ -59,12 +61,12 @@ export const DonationPage: StagerComponent = ({ }); if (supporterMode) { - const activeTiers = isMonthly ? SUPPORTER_MONTHLY_TIERS : SUPPORTER_ONEOFF_TIERS; + const currencySymbol = currencyCodeToSymbol(supporterCurrency); const activeAmount = (otherDonationAmount != null && otherDonationAmount !== "") ? Number(otherDonationAmount) : selectedTier; const ctaLabel = activeAmount > 0 - ? (isMonthly ? `Donate £${activeAmount}/month` : `Donate £${activeAmount} now`) + ? (isMonthly ? `Donate ${currencySymbol}${activeAmount}/month` : `Donate ${currencySymbol}${activeAmount} now`) : (isMonthly ? "Donate monthly" : "Donate now"); return ( @@ -86,10 +88,7 @@ export const DonationPage: StagerComponent = ({ variant={isMonthly ? "dark" : "outline-dark"} onClick={() => { setIsMonthly(true); - setSelectedTier(SUPPORTER_MONTHLY_TIERS[1]); form.setValue("recurDonation", true); - form.setValue("otherDonationAmount", null); - form.setValue("donationAmount", SUPPORTER_MONTHLY_TIERS[1]); }} > Monthly @@ -99,10 +98,7 @@ export const DonationPage: StagerComponent = ({ variant={!isMonthly ? "dark" : "outline-dark"} onClick={() => { setIsMonthly(false); - setSelectedTier(SUPPORTER_ONEOFF_TIERS[1]); form.setValue("recurDonation", false); - form.setValue("otherDonationAmount", null); - form.setValue("donationAmount", SUPPORTER_ONEOFF_TIERS[1]); }} > One-off @@ -110,7 +106,7 @@ export const DonationPage: StagerComponent = ({
- {activeTiers.map((tier) => ( + {supporterTiers.map((tier) => ( ))}
From f2427ed551253c3e41b61c378e1f6fb91e14e982 Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Wed, 18 Mar 2026 17:13:43 +0000 Subject: [PATCH 10/48] Show configuration error in supporter mode when no membership plans are set --- packages/join-flow/src/pages/donation.page.test.tsx | 12 ++++++++++++ packages/join-flow/src/pages/donation.page.tsx | 11 +++++++++++ 2 files changed, 23 insertions(+) diff --git a/packages/join-flow/src/pages/donation.page.test.tsx b/packages/join-flow/src/pages/donation.page.test.tsx index 3af7c99..ab27758 100644 --- a/packages/join-flow/src/pages/donation.page.test.tsx +++ b/packages/join-flow/src/pages/donation.page.test.tsx @@ -66,6 +66,18 @@ describe('DonationPage — supporter mode (DONATION_SUPPORTER_MODE on)', () => { }); }); + 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 []; + return false; + }); + renderDonationPage(); + + expect(screen.getByText(/No donation amounts configured/i)).toBeInTheDocument(); + expect(screen.queryByText('Monthly')).not.toBeInTheDocument(); + }); + test('renders Monthly and One-off toggle buttons', () => { renderDonationPage(); diff --git a/packages/join-flow/src/pages/donation.page.tsx b/packages/join-flow/src/pages/donation.page.tsx index c399b80..736df3a 100644 --- a/packages/join-flow/src/pages/donation.page.tsx +++ b/packages/join-flow/src/pages/donation.page.tsx @@ -60,6 +60,17 @@ export const DonationPage: StagerComponent = ({ onCompleted(formData); }); + if (supporterMode && supporterTiers.length === 0) { + return ( +
+ 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. +

+
+ ); + } + if (supporterMode) { const currencySymbol = currencyCodeToSymbol(supporterCurrency); const activeAmount = (otherDonationAmount != null && otherDonationAmount !== "") From 69b72bb99c6c7ac337fb102b4a786618bae3bacc Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Wed, 18 Mar 2026 17:16:21 +0000 Subject: [PATCH 11/48] Suppress global membership plan fallback in supporter mode --- packages/join-block/src/Blocks.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/join-block/src/Blocks.php b/packages/join-block/src/Blocks.php index df62cb0..2792124 100644 --- a/packages/join-block/src/Blocks.php +++ b/packages/join-block/src/Blocks.php @@ -332,7 +332,8 @@ private static function echoEnvironment($fields, $block_mode) } $membership_plans = $fields['custom_membership_plans'] ?? []; - if (!$membership_plans) { + $is_supporter_mode = !empty($fields['donation_supporter_mode']); + if (!$membership_plans && !$is_supporter_mode) { $membership_plans = Settings::get("MEMBERSHIP_PLANS") ?? []; } From 5937afe610bc478aca918279cd3e3233e9b51584 Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Wed, 18 Mar 2026 17:18:03 +0000 Subject: [PATCH 12/48] Remove em dash from supporter mode error message --- packages/join-flow/src/pages/donation.page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/join-flow/src/pages/donation.page.tsx b/packages/join-flow/src/pages/donation.page.tsx index 736df3a..3c1c8ca 100644 --- a/packages/join-flow/src/pages/donation.page.tsx +++ b/packages/join-flow/src/pages/donation.page.tsx @@ -65,7 +65,7 @@ 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.

); From 8a440e75b88098d7d0dce6cd89e14d2eec15b6d2 Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Mon, 23 Mar 2026 15:31:35 +0000 Subject: [PATCH 13/48] Add failing tests for recurDonation and donationAmount type correctness in supporter mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests verify that onCompleted receives native boolean for recurDonation and native number for donationAmount — not the strings produced by the hidden input approach. --- .../src/pages/donation.page.test.tsx | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/join-flow/src/pages/donation.page.test.tsx b/packages/join-flow/src/pages/donation.page.test.tsx index ab27758..3aef577 100644 --- a/packages/join-flow/src/pages/donation.page.test.tsx +++ b/packages/join-flow/src/pages/donation.page.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; import { DonationPage } from './donation.page'; @@ -136,4 +136,38 @@ describe('DonationPage — supporter mode (DONATION_SUPPORTER_MODE on)', () => { // 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 () => { + 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(submitted.recurDonation).toBe(true); + }); + + test('submitting one-off calls onCompleted with recurDonation as native boolean false', async () => { + renderDonationPage(); + + 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(submitted.recurDonation).toBe(false); + }); + + test('submitting calls onCompleted with donationAmount as a number', 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.donationAmount).toBe('number'); + expect(submitted.donationAmount).toBe(5); + }); }); From 841cc77a81edba274adb81879fe62e629e9a18cd Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Mon, 23 Mar 2026 15:33:22 +0000 Subject: [PATCH 14/48] Fix recurDonation and donationAmount submitted as strings in supporter mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hidden inputs registered via ref produced string values ("true"/"false", "5") rather than native booleans and numbers. Removed the hidden inputs; handleSubmit now derives recurDonation (boolean) from isMonthly state and donationAmount (number) from selectedTier/otherDonationAmount directly — matching the wire format used everywhere else in the codebase. --- packages/join-flow/src/pages/donation.page.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/join-flow/src/pages/donation.page.tsx b/packages/join-flow/src/pages/donation.page.tsx index 3c1c8ca..8046733 100644 --- a/packages/join-flow/src/pages/donation.page.tsx +++ b/packages/join-flow/src/pages/donation.page.tsx @@ -53,6 +53,14 @@ export const DonationPage: StagerComponent = ({ const otherDonationAmount = form.watch("otherDonationAmount"); const handleSubmit = form.handleSubmit((formData) => { + if (supporterMode) { + const amount = (formData.otherDonationAmount != null && formData.otherDonationAmount !== "") + ? Number(formData.otherDonationAmount) + : selectedTier; + delete formData.otherDonationAmount; + onCompleted({ ...formData, donationAmount: amount, recurDonation: isMonthly }); + return; + } if (formData.otherDonationAmount !== "" && formData.otherDonationAmount != null) { formData.donationAmount = formData.otherDonationAmount; delete formData.otherDonationAmount; @@ -82,10 +90,6 @@ export const DonationPage: StagerComponent = ({ return (
- {/* Hidden registered inputs so values appear in handleSubmit data */} - - -

Support us

From 9018643a90d6dcf3426b9ce3555b5ae1e7518561 Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Mon, 23 Mar 2026 20:11:13 +0000 Subject: [PATCH 15/48] Add failing tests for one-off toggle disabled state when Stripe is unavailable --- .../src/pages/donation.page.test.tsx | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/join-flow/src/pages/donation.page.test.tsx b/packages/join-flow/src/pages/donation.page.test.tsx index 3aef577..3c22356 100644 --- a/packages/join-flow/src/pages/donation.page.test.tsx +++ b/packages/join-flow/src/pages/donation.page.test.tsx @@ -62,6 +62,7 @@ describe('DonationPage — supporter mode (DONATION_SUPPORTER_MODE on)', () => { mockGetEnv.mockImplementation((key: string) => { if (key === 'DONATION_SUPPORTER_MODE') return true; if (key === 'MEMBERSHIP_PLANS') return MOCK_PLANS; + if (key === 'USE_STRIPE') return true; return false; }); }); @@ -171,3 +172,29 @@ describe('DonationPage — supporter mode (DONATION_SUPPORTER_MODE on)', () => { expect(submitted.donationAmount).toBe(5); }); }); + +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; + return false; + }); + }); + + test('One-off button is disabled', () => { + renderDonationPage(); + expect(screen.getByText('One-off').closest('button')).toBeDisabled(); + }); + + test('shows explanation that one-off donations are not available', () => { + renderDonationPage(); + expect(screen.getByText(/one-off donations are not available/i)).toBeInTheDocument(); + }); + + test('Monthly button remains enabled', () => { + renderDonationPage(); + expect(screen.getByText('Monthly').closest('button')).not.toBeDisabled(); + }); +}); From 13461ff34e9bb60bab5376b0647a57f2fce33c28 Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Mon, 23 Mar 2026 20:11:16 +0000 Subject: [PATCH 16/48] Disable one-off donation toggle when Stripe is not available Direct Debit (GoCardless) does not support one-off donations. When USE_STRIPE is false, the One-off button is disabled and an inline note explains the limitation. --- packages/join-flow/src/pages/donation.page.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/join-flow/src/pages/donation.page.tsx b/packages/join-flow/src/pages/donation.page.tsx index 8046733..c3acc73 100644 --- a/packages/join-flow/src/pages/donation.page.tsx +++ b/packages/join-flow/src/pages/donation.page.tsx @@ -31,6 +31,7 @@ export const DonationPage: StagerComponent = ({ : [5, 10, 15, 20]; const supporterMode = Boolean(getEnv("DONATION_SUPPORTER_MODE")); + const oneOffAvailable = Boolean(getEnv("USE_STRIPE")); const membershipPlans = (getEnv("MEMBERSHIP_PLANS") as any[]) || []; const supporterTiers: number[] = membershipPlans.map((p) => Number(p.amount)).filter((n) => n > 0); @@ -111,6 +112,7 @@ export const DonationPage: StagerComponent = ({
+ {!oneOffAvailable && ( +

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

+ )}
{supporterTiers.map((tier) => ( From 0301a88f4861b8d417692b9e2a257f117e8f8934 Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Mon, 23 Mar 2026 20:11:26 +0000 Subject: [PATCH 17/48] Document one-off donation and plan requirements in supporter mode help text --- packages/join-block/src/Blocks.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/join-block/src/Blocks.php b/packages/join-block/src/Blocks.php index 2792124..89eb504 100644 --- a/packages/join-block/src/Blocks.php +++ b/packages/join-block/src/Blocks.php @@ -180,7 +180,9 @@ private static function registerJoinFormBlock() Field::make('checkbox', 'donation_supporter_mode') ->set_help_text( 'Enable Supporter Mode: shows donation frequency and amount first, ' . - 'before personal details and payment. Skips the membership plan step.' + 'before personal details and payment. Skips the membership plan step. ' . + 'Requires block-level membership plans to be configured (used as donation tiers). ' . + 'One-off donations require Stripe — they are not available with Direct Debit only.' ), Field::make('checkbox', 'hide_home_address_copy') ->set_help_text('Check to hide the copy that explains why the address is collected.'), From eb38d2578557f0ec39539e3eb74209820103f610 Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Mon, 23 Mar 2026 20:18:07 +0000 Subject: [PATCH 18/48] Bump version to 1.4.0 --- packages/join-block/join.php | 2 +- packages/join-block/readme.txt | 8 +++++++- packages/join-flow/src/index.tsx | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/join-block/join.php b/packages/join-block/join.php index 24622bc..cd318da 100644 --- a/packages/join-block/join.php +++ b/packages/join-block/join.php @@ -3,7 +3,7 @@ /** * Plugin Name: Common Knowledge Join Flow * Description: Common Knowledge join flow plugin. - * Version: 1.3.18 + * Version: 1.4.0 * Author: Common Knowledge * Text Domain: common-knowledge-join-flow * License: GPLv2 or later diff --git a/packages/join-block/readme.txt b/packages/join-block/readme.txt index 72388d6..b8d7ec4 100644 --- a/packages/join-block/readme.txt +++ b/packages/join-block/readme.txt @@ -4,7 +4,7 @@ Tags: membership, subscription, join Contributors: commonknowledgecoop Requires at least: 5.4 Tested up to: 6.8 -Stable tag: 1.3.18 +Stable tag: 1.4.0 Requires PHP: 8.1 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -107,6 +107,12 @@ Need help? Contact us at [hello@commonknowledge.coop](mailto:hello@commonknowled == Changelog == += 1.4.0 = +* Add Donation Supporter Mode: a new block setting that puts donation first, before personal details and payment, skipping the membership plan step entirely +* Supporter mode: donation frequency (monthly/one-off) and tier selection driven by block-level membership plans +* Supporter mode: one-off donations processed via Stripe invoice item; recurring donations added as a second subscription item +* Supporter mode: GoCardless recurring donations added to the DD subscription total; one-off toggle disabled with explanation when Stripe is unavailable +* Fix: donation amount and recurDonation submitted as native types (number/boolean) rather than strings = 1.3.18 = * Make Zetkin errors non-fatal so a Zetkin failure does not block a successful join. * Improve Zetkin 403 error message to indicate expired JWT credentials and remediation steps. diff --git a/packages/join-flow/src/index.tsx b/packages/join-flow/src/index.tsx index e850429..1ef613a 100644 --- a/packages/join-flow/src/index.tsx +++ b/packages/join-flow/src/index.tsx @@ -24,7 +24,7 @@ const init = () => { const sentryDsn = getEnvStr("SENTRY_DSN") Sentry.init({ dsn: sentryDsn, - release: "1.3.18" + release: "1.4.0" }); if (getEnv('USE_CHARGEBEE')) { From afee2e3f1aaf10731c6e82c3e602d21b4de46ece Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Mon, 23 Mar 2026 20:19:24 +0000 Subject: [PATCH 19/48] Correct changelog entry wording for 1.4.0 --- packages/join-block/readme.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/join-block/readme.txt b/packages/join-block/readme.txt index b8d7ec4..a0175e6 100644 --- a/packages/join-block/readme.txt +++ b/packages/join-block/readme.txt @@ -112,7 +112,7 @@ Need help? Contact us at [hello@commonknowledge.coop](mailto:hello@commonknowled * Supporter mode: donation frequency (monthly/one-off) and tier selection driven by block-level membership plans * Supporter mode: one-off donations processed via Stripe invoice item; recurring donations added as a second subscription item * Supporter mode: GoCardless recurring donations added to the DD subscription total; one-off toggle disabled with explanation when Stripe is unavailable -* Fix: donation amount and recurDonation submitted as native types (number/boolean) rather than strings +* Supporter mode: donation amount and frequency submitted as correct native types to the payment backend = 1.3.18 = * Make Zetkin errors non-fatal so a Zetkin failure does not block a successful join. * Improve Zetkin 403 error message to indicate expired JWT credentials and remediation steps. From e2a8690538b5f930cf0d126d1bd3de5f7d9590dd Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Mon, 23 Mar 2026 20:20:57 +0000 Subject: [PATCH 20/48] Expand Direct Debit abbreviation in changelog --- packages/join-block/readme.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/join-block/readme.txt b/packages/join-block/readme.txt index a0175e6..1ee6807 100644 --- a/packages/join-block/readme.txt +++ b/packages/join-block/readme.txt @@ -111,7 +111,7 @@ Need help? Contact us at [hello@commonknowledge.coop](mailto:hello@commonknowled * Add Donation Supporter Mode: a new block setting that puts donation first, before personal details and payment, skipping the membership plan step entirely * Supporter mode: donation frequency (monthly/one-off) and tier selection driven by block-level membership plans * Supporter mode: one-off donations processed via Stripe invoice item; recurring donations added as a second subscription item -* Supporter mode: GoCardless recurring donations added to the DD subscription total; one-off toggle disabled with explanation when Stripe is unavailable +* Supporter mode: GoCardless recurring donations added to the Direct Debit subscription total; one-off toggle disabled with explanation when Stripe is unavailable * Supporter mode: donation amount and frequency submitted as correct native types to the payment backend = 1.3.18 = * Make Zetkin errors non-fatal so a Zetkin failure does not block a successful join. From f7083c12504a87c4dc4fb3f6e03979243de760ae Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Mon, 23 Mar 2026 20:35:56 +0000 Subject: [PATCH 21/48] Address PR review comments: remove stale comments --- packages/join-block/src/Services/GocardlessService.php | 1 - packages/join-flow/src/pages/donation.page.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/join-block/src/Services/GocardlessService.php b/packages/join-block/src/Services/GocardlessService.php index 1bbe4a1..87a10b3 100644 --- a/packages/join-block/src/Services/GocardlessService.php +++ b/packages/join-block/src/Services/GocardlessService.php @@ -105,7 +105,6 @@ public static function createCustomerSubscription($data) $amountInPence = round(((float) $data['membershipPlan']['amount']) * 100); // Add recurring donation to subscription amount. - // One-off donations are not supported for Direct Debit and are silently ignored. $donationAmount = (float) ($data['donationAmount'] ?? 0); if ($donationAmount > 0 && !empty($data['recurDonation'])) { $amountInPence += round($donationAmount * 100); diff --git a/packages/join-flow/src/pages/donation.page.tsx b/packages/join-flow/src/pages/donation.page.tsx index c3acc73..8e594fe 100644 --- a/packages/join-flow/src/pages/donation.page.tsx +++ b/packages/join-flow/src/pages/donation.page.tsx @@ -38,7 +38,6 @@ export const DonationPage: StagerComponent = ({ const supporterCurrency: string = membershipPlans[0]?.currency || "GBP"; const defaultSupporterTier = supporterTiers[0] ?? 0; - // Local state for supporter mode to drive reliable re-renders const [isMonthly, setIsMonthly] = useState(true); const [selectedTier, setSelectedTier] = useState(defaultSupporterTier); From 57f5f9294a72b9164f2f030a2344f56f56cebd58 Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Mon, 23 Mar 2026 21:34:50 +0000 Subject: [PATCH 22/48] Fix double subscription item in supporter mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In supporter mode, donationAmount was passed non-zero to createSubscription, which added a separate Supporter Donation item on top of the membership plan item. The plan price IS the donation — no separate item should be created. Also fixes membership not updating when the user selects a tier that differs from the default: now resolves to the plan whose amount matches the selected tier. --- packages/join-block/src/Blocks.php | 2 +- .../src/pages/donation.page.test.tsx | 27 ++++++++++++++++--- .../join-flow/src/pages/donation.page.tsx | 15 ++++++++++- 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/packages/join-block/src/Blocks.php b/packages/join-block/src/Blocks.php index 89eb504..52f96a0 100644 --- a/packages/join-block/src/Blocks.php +++ b/packages/join-block/src/Blocks.php @@ -182,7 +182,7 @@ private static function registerJoinFormBlock() 'Enable Supporter Mode: shows donation frequency and amount first, ' . 'before personal details and payment. Skips the membership plan step. ' . 'Requires block-level membership plans to be configured (used as donation tiers). ' . - 'One-off donations require Stripe — they are not available with Direct Debit only.' + 'One-off donations require Stripe. They are not available with Direct Debit only.' ), Field::make('checkbox', 'hide_home_address_copy') ->set_help_text('Check to hide the copy that explains why the address is collected.'), diff --git a/packages/join-flow/src/pages/donation.page.test.tsx b/packages/join-flow/src/pages/donation.page.test.tsx index 3c22356..1feb38c 100644 --- a/packages/join-flow/src/pages/donation.page.test.tsx +++ b/packages/join-flow/src/pages/donation.page.test.tsx @@ -161,15 +161,36 @@ describe('DonationPage — supporter mode (DONATION_SUPPORTER_MODE on)', () => { expect(submitted.recurDonation).toBe(false); }); - test('submitting calls onCompleted with donationAmount as a number', async () => { + test('submitting calls onCompleted with donationAmount 0 (plan price is the donation)', 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.donationAmount).toBe('number'); - expect(submitted.donationAmount).toBe(5); + expect(submitted.donationAmount).toBe(0); + }); + + 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'); + }); + + test('selecting a different tier and submitting calls onCompleted with matching membership', async () => { + renderDonationPage(); + + 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.donationAmount).toBe(0); }); }); diff --git a/packages/join-flow/src/pages/donation.page.tsx b/packages/join-flow/src/pages/donation.page.tsx index 8e594fe..ea3aad9 100644 --- a/packages/join-flow/src/pages/donation.page.tsx +++ b/packages/join-flow/src/pages/donation.page.tsx @@ -58,7 +58,20 @@ export const DonationPage: StagerComponent = ({ ? Number(formData.otherDonationAmount) : selectedTier; delete formData.otherDonationAmount; - onCompleted({ ...formData, donationAmount: amount, recurDonation: isMonthly }); + + // 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 resolvedPlan = matchingPlan ?? fallbackPlan; + + onCompleted({ + ...formData, + membership: resolvedPlan?.value ?? formData.membership, + ...(!matchingPlan && resolvedPlan?.allowCustomAmount ? { customMembershipAmount: amount } : {}), + donationAmount: 0, + recurDonation: isMonthly, + }); return; } if (formData.otherDonationAmount !== "" && formData.otherDonationAmount != null) { From 042722ee453eec75984ce86cd248171a9ca2a872 Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Mon, 23 Mar 2026 21:42:03 +0000 Subject: [PATCH 23/48] Use 'Donation:' product prefix in Stripe when in supporter mode Threads donationSupporterMode flag from frontend initial state through to StripeService::getOrCreateProductForMembershipTier, which uses 'Donation:' as the product name prefix instead of 'Membership:' when in supporter mode. --- packages/join-block/join.php | 6 ++++-- packages/join-block/src/Services/StripeService.php | 13 +++++++------ packages/join-flow/src/app.tsx | 2 +- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/join-block/join.php b/packages/join-block/join.php index 31de437..e9a5c4d 100644 --- a/packages/join-block/join.php +++ b/packages/join-block/join.php @@ -504,7 +504,8 @@ $plan, $data["customMembershipAmount"] ?? null, $data["donationAmount"] ?? null, - $data["recurDonation"] ?? false + $data["recurDonation"] ?? false, + !empty($data["donationSupporterMode"]) ); return $subscription; @@ -552,7 +553,8 @@ $plan, $data["customMembershipAmount"] ?? null, $data["donationAmount"] ?? null, - $data["recurDonation"] ?? false + $data["recurDonation"] ?? false, + !empty($data["donationSupporterMode"]) ); $confirmedPaymentIntent = StripeService::confirmSubscriptionPaymentIntent($subscription, $data['confirmationTokenId']); diff --git a/packages/join-block/src/Services/StripeService.php b/packages/join-block/src/Services/StripeService.php index 3322d10..40ede00 100644 --- a/packages/join-block/src/Services/StripeService.php +++ b/packages/join-block/src/Services/StripeService.php @@ -70,13 +70,13 @@ public static function upsertCustomer($email) return [$customer, $newCustomer]; } - public static function createSubscription($customer, $plan, $customAmount = null, $donationAmount = null, $recurDonation = false) + public static function createSubscription($customer, $plan, $customAmount = null, $donationAmount = null, $recurDonation = false, $isSupporterMode = false) { $priceId = $plan["stripe_price_id"]; $customAmount = (float) $customAmount; $minAmount = (float) $plan["amount"]; if ($plan["allow_custom_amount"] && $customAmount && $customAmount > $minAmount) { - $product = self::getOrCreateProductForMembershipTier($plan); + $product = self::getOrCreateProductForMembershipTier($plan, $isSupporterMode); $priceId = self::getOrCreatePriceForProduct($product, $customAmount, $plan['currency'], self::convertFrequencyToStripeInterval($plan['frequency'])); } @@ -328,13 +328,14 @@ public static function createMembershipPlanIfItDoesNotExist($membershipPlan) return [$newOrExistingProduct, $newOrExistingPrice]; } - public static function getOrCreateProductForMembershipTier($membershipPlan) + public static function getOrCreateProductForMembershipTier($membershipPlan, $isSupporterMode = false) { global $joinBlockLog; $tierID = Settings::getMembershipPlanId($membershipPlan); $tierDescription = $membershipPlan['description']; + $productPrefix = $isSupporterMode ? 'Donation' : 'Membership'; try { $joinBlockLog->info("Searching for existing Stripe product for membership tier '{$tierID}'"); @@ -351,10 +352,10 @@ public static function getOrCreateProductForMembershipTier($membershipPlan) $needsUpdate = false; $updateData = []; - if ($existingProduct->name !== "Membership: {$membershipPlan['label']}") { + if ($existingProduct->name !== "{$productPrefix}: {$membershipPlan['label']}") { $joinBlockLog->info("Name changed, updating existing product for membership tier '{$tierID}'"); - $updateData['name'] = "Membership: {$membershipPlan['label']}"; + $updateData['name'] = "{$productPrefix}: {$membershipPlan['label']}"; $needsUpdate = true; } @@ -377,7 +378,7 @@ public static function getOrCreateProductForMembershipTier($membershipPlan) $joinBlockLog->info("No existing product found for membership tier '{$tierID}', creating new product"); $stripeProduct = [ - 'name' => "Membership: {$membershipPlan['label']}", + 'name' => "{$productPrefix}: {$membershipPlan['label']}", 'type' => 'service', 'metadata' => ['membership_plan' => $tierID], ]; diff --git a/packages/join-flow/src/app.tsx b/packages/join-flow/src/app.tsx index 250159e..2487e6b 100644 --- a/packages/join-flow/src/app.tsx +++ b/packages/join-flow/src/app.tsx @@ -399,7 +399,7 @@ const getInitialState = (): FormSchema => { sessionToken: uuid.v4(), ...getTestDataIfEnabled(), ...getDefaultState(), - ...(getEnv("DONATION_SUPPORTER_MODE") ? { recurDonation: true } : {}), + ...(getEnv("DONATION_SUPPORTER_MODE") ? { recurDonation: true, donationSupporterMode: true } : {}), ...getSavedState(), ...getProvidedStateFromQueryParams(), isUpdateFlow: getEnv("IS_UPDATE_FLOW"), From 0d54becd01f7f200e8d6828f39647c1143f828fd Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Mon, 23 Mar 2026 21:48:28 +0000 Subject: [PATCH 24/48] Make Mailchimp errors non-fatal A Mailchimp failure should not block a successful join, matching the same behaviour applied to Zetkin in 1.3.18. The member record can be retro-added to Mailchimp once the underlying issue is resolved. --- packages/join-block/src/Services/JoinService.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/join-block/src/Services/JoinService.php b/packages/join-block/src/Services/JoinService.php index a835102..4c7fd9c 100644 --- a/packages/join-block/src/Services/JoinService.php +++ b/packages/join-block/src/Services/JoinService.php @@ -230,8 +230,9 @@ private static function tryHandleJoin($data) MailchimpService::signup($data); $joinBlockLog->info("Completed Mailchimp signup request for $email"); } catch (\Exception $exception) { + // A Mailchimp failure should not block a successful join. + // The member record can be retro-added to Mailchimp once the underlying issue is resolved. $joinBlockLog->error("Mailchimp error for email $email: " . $exception->getMessage()); - throw $exception; } } @@ -398,7 +399,6 @@ public static function toggleMemberLapsed($email, $lapsed = true, $paymentDate = $joinBlockLog->info("$done member $email as lapsed in Mailchimp"); } catch (\Exception $exception) { $joinBlockLog->error("Mailchimp error for email $email: " . $exception->getMessage()); - throw $exception; } } From a9e0fb7050c13a28eed8fda27a7ef22284c0e456 Mon Sep 17 00:00:00 2001 From: Alex Worrad-Andrews Date: Mon, 23 Mar 2026 21:51:45 +0000 Subject: [PATCH 25/48] Rename existing Stripe products to 'Donation:' prefix on first supporter mode subscription Products are created during plan save without supporter mode context, so they get 'Membership:' prefix. On first subscription creation in supporter mode, trigger getOrCreateProductForMembershipTier to detect the name mismatch and rename the product to 'Donation: