From dc8b65896c7e30050ee88f56ee4c861c631e6442 Mon Sep 17 00:00:00 2001 From: Jaime Infante Date: Mon, 14 Sep 2026 20:18:58 -0500 Subject: [PATCH 1/2] fix: mp-3212-keep the variant copy and layout when the tip is zeroed --- src/components/Checkout/DonationItem.vue | 8 ++-- .../Checkout/KivaCreditTipToggle.vue | 22 +++++++--- src/pages/Checkout/CheckoutPage.vue | 9 ++-- .../components/Checkout/DonationItem.spec.js | 8 ++-- .../specs/pages/Checkout/CheckoutPage.spec.js | 43 +++++++++++++++++++ 5 files changed, 72 insertions(+), 18 deletions(-) diff --git a/src/components/Checkout/DonationItem.vue b/src/components/Checkout/DonationItem.vue index d9d446f2307..0fec61d3f88 100644 --- a/src/components/Checkout/DonationItem.vue +++ b/src/components/Checkout/DonationItem.vue @@ -385,11 +385,9 @@ export default { return !this.isCampaignDonation && !this.orderTotalVariant; }, showTipFromBalanceVariant() { - // The compressed one-line layout exists to make room for the switch below it. With no tip - // there is no switch, so the row keeps the layout the repayments prompt was designed against - return this.tipFromBalanceEligible - && numeral(this.donation.price).value() > 0 - && this.canHostTipFromBalanceToggle; + // Ignores the tip amount: at zero the switch and its label go, but the copy and layout + // stay so the row does not flip back to the control mid-checkout + return this.tipFromBalanceEligible && this.canHostTipFromBalanceToggle; }, donationTitle() { return 'Donation to Kiva'; diff --git a/src/components/Checkout/KivaCreditTipToggle.vue b/src/components/Checkout/KivaCreditTipToggle.vue index bde14a70ec1..d8bf47df55d 100644 --- a/src/components/Checkout/KivaCreditTipToggle.vue +++ b/src/components/Checkout/KivaCreditTipToggle.vue @@ -36,26 +36,38 @@ const AUDIENCE_DEPOSIT_LIMIT = 1000; export const TIP_FROM_BALANCE_SEEDED_COOKIE = 'kvtipseeded'; /** - * Whether the lender is in the experiment audience, ignoring which arm they are in. The checkout - * page reads this too, so the variant treatment only appears where the switch itself can. + * Whether the lender gets the variant treatment, ignoring the tip. The checkout page renders + * the variant copy and layout on this alone, so zeroing the tip removes the switch without + * flipping the page back to the control mid-checkout. * * The balance has to exceed everything in the basket but the tip, not merely be positive: below * that the amount due is the raw shortfall either way, so the switch cannot change what is * charged. Unknown deposits read as ineligible until the basket query lands. * * @param {Object} state The basket state provided by the checkout page - * @returns {boolean} Whether the lender is in the experiment audience + * @returns {boolean} Whether the lender gets the variant treatment */ -export function meetsTipFromBalanceCriteria(state = {}) { +export function meetsTipFromBalanceTreatmentCriteria(state = {}) { return !!state.myId && state.balance > state.nonTipTotal && state.hasLoans - && state.tipAmount > 0 && !state.onTeam && typeof state.lifetimeDeposits === 'number' && state.lifetimeDeposits < AUDIENCE_DEPOSIT_LIMIT; } +/** + * Whether the lender is in the experiment audience, ignoring which arm they are in. On top of + * the treatment criteria this needs a tip for the balance to cover: with none there is no + * decision to make, so neither arm renders the switch or fires exposure. + * + * @param {Object} state The basket state provided by the checkout page + * @returns {boolean} Whether the lender is in the experiment audience + */ +export function meetsTipFromBalanceCriteria(state = {}) { + return meetsTipFromBalanceTreatmentCriteria(state) && state.tipAmount > 0; +} + export default { name: 'KivaCreditTipToggle', components: { diff --git a/src/pages/Checkout/CheckoutPage.vue b/src/pages/Checkout/CheckoutPage.vue index e935b083610..6dad0e3c207 100644 --- a/src/pages/Checkout/CheckoutPage.vue +++ b/src/pages/Checkout/CheckoutPage.vue @@ -366,7 +366,7 @@ import { CUSTOM_TIP_DEFAULT_EXP_KEY } from '#src/components/Checkout/DonationNud import { TIP_FROM_BALANCE_EXP_KEY, TIP_FROM_BALANCE_SEEDED_COOKIE, - meetsTipFromBalanceCriteria, + meetsTipFromBalanceTreatmentCriteria, } from '#src/components/Checkout/KivaCreditTipToggle'; import updateKivaCreditDonationPreference from '#src/graphql/mutation/updateKivaCreditDonationPreference.graphql'; import experimentAssignmentQuery from '#src/graphql/query/experimentAssignment.graphql'; @@ -977,10 +977,11 @@ export default { && this.applyKivaCreditToDonation === false); }, showTipFromBalanceVariant() { - // The variant treatment only appears where the switch can, so a lender without a - // balance, a loan or a tip sees the original checkout untouched + // Ignores the tip amount on purpose: a lender who zeroes the tip loses the switch but + // keeps the copy and layout, instead of watching the page flip back to the control + // mid-checkout. Exposure still requires a tip, through the toggle's own criteria return this.tipFromBalanceVersion === 'b' - && meetsTipFromBalanceCriteria(this.tipToggleBasketState); + && meetsTipFromBalanceTreatmentCriteria(this.tipToggleBasketState); }, isKivaCreditText() { return this.isKivaCreditReplacementExpEnabled ? 'Account balance' : 'Kiva Credit'; diff --git a/test/unit/specs/components/Checkout/DonationItem.spec.js b/test/unit/specs/components/Checkout/DonationItem.spec.js index 96a770e3056..78608610c68 100644 --- a/test/unit/specs/components/Checkout/DonationItem.spec.js +++ b/test/unit/specs/components/Checkout/DonationItem.spec.js @@ -150,13 +150,13 @@ describe('DonationItem showTipFromBalanceVariant', () => { })).toBe(false); }); - // The compressed layout only earns its place when there is a switch to make room for, and - // at a zero tip the row shares space with the donate-repayments prompt instead - it('stays off at a zero tip, where there is no switch to make room for', () => { + // A zeroed tip keeps the treatment: the switch and its label go, but the copy and layout + // staying put means the page never flips back to the control mid-checkout + it('keeps the variant styling at a zero tip, where only the switch goes', () => { expect(showVariant({ tipFromBalanceEligible: true, canHostTipFromBalanceToggle: true, tip: '0.00', - })).toBe(false); + })).toBe(true); }); }); diff --git a/test/unit/specs/pages/Checkout/CheckoutPage.spec.js b/test/unit/specs/pages/Checkout/CheckoutPage.spec.js index e3a54e54b03..5d3ec741419 100644 --- a/test/unit/specs/pages/Checkout/CheckoutPage.spec.js +++ b/test/unit/specs/pages/Checkout/CheckoutPage.spec.js @@ -620,3 +620,46 @@ describe('CheckoutPage tipToggleBasketState', () => { expect(meetsTipFromBalanceCriteria(tipToggleBasketState({ ...context, myBalance: '40.00' }))).toBe(true); }); }); + +describe('CheckoutPage showTipFromBalanceVariant', () => { + const showVariant = context => CheckoutPage.computed.showTipFromBalanceVariant.call(context); + + const basketState = (overrides = {}) => ({ + myId: 1234, + balance: 40, + hasLoans: true, + tipAmount: 5, + nonTipTotal: 25, + onTeam: false, + lifetimeDeposits: 0, + ...overrides, + }); + + it('shows the treatment for an eligible variant lender', () => { + expect(showVariant({ + tipFromBalanceVersion: 'b', + tipToggleBasketState: basketState(), + })).toBe(true); + }); + + it('keeps the treatment at a zero tip, so only the switch disappears', () => { + expect(showVariant({ + tipFromBalanceVersion: 'b', + tipToggleBasketState: basketState({ tipAmount: 0 }), + })).toBe(true); + }); + + it('never shows the treatment for control', () => { + expect(showVariant({ + tipFromBalanceVersion: 'a', + tipToggleBasketState: basketState(), + })).toBe(false); + }); + + it('never shows the treatment outside the audience, zero tip or not', () => { + expect(showVariant({ + tipFromBalanceVersion: 'b', + tipToggleBasketState: basketState({ tipAmount: 0, onTeam: true }), + })).toBe(false); + }); +}); From c8e395ef83f8860f91bed6b8f39f880e2db48784 Mon Sep 17 00:00:00 2001 From: Jaime Infante Date: Mon, 14 Sep 2026 20:19:10 -0500 Subject: [PATCH 2/2] fix: mp-3213-bump kv-components for the centered switch knob --- package-lock.json | 14 +++++++------- package.json | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index d35d6d7f727..bc8a41b18a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@graphql-tools/load": "^7.7.0", "@graphql-tools/url-loader": "^7.17.18", "@kiva/kv-analytics": "^1.4.0", - "@kiva/kv-components": "^10.3.0", + "@kiva/kv-components": "^10.4.1", "@kiva/kv-shop": "^3.8.28", "@kiva/kv-tokens": "^5.0.0", "@mdi/js": "^7.4.47", @@ -3716,9 +3716,9 @@ "integrity": "sha512-Cw+HFQTWg9xhz7LYSxI+hCbfAX358EX+0uiojk3X0h+NtqD/EOYGlsPJmTHkWa41YCIsN9rdGGzwLxjHgSYF1A==" }, "node_modules/@kiva/kv-components": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@kiva/kv-components/-/kv-components-10.3.0.tgz", - "integrity": "sha512-p5HAG4gKS9z8w4Z28ysZiA8QL/yEynU9KY2qipPwMRZ6mNfvF7F5ller4MJLBo750+PcdNU8dMemTcHHvNdmyQ==", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@kiva/kv-components/-/kv-components-10.4.1.tgz", + "integrity": "sha512-EccSFQWOcrdxr9QEW4qXYMuBGh7xM/PrZVmMEMJunduzbrN7HM8wY74GvVfMRPDM30PxeC2aJ/NtoL8BZFPJ6w==", "bundleDependencies": [ "aria-hidden", "embla-carousel", @@ -33233,9 +33233,9 @@ "integrity": "sha512-Cw+HFQTWg9xhz7LYSxI+hCbfAX358EX+0uiojk3X0h+NtqD/EOYGlsPJmTHkWa41YCIsN9rdGGzwLxjHgSYF1A==" }, "@kiva/kv-components": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@kiva/kv-components/-/kv-components-10.3.0.tgz", - "integrity": "sha512-p5HAG4gKS9z8w4Z28ysZiA8QL/yEynU9KY2qipPwMRZ6mNfvF7F5ller4MJLBo750+PcdNU8dMemTcHHvNdmyQ==", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@kiva/kv-components/-/kv-components-10.4.1.tgz", + "integrity": "sha512-EccSFQWOcrdxr9QEW4qXYMuBGh7xM/PrZVmMEMJunduzbrN7HM8wY74GvVfMRPDM30PxeC2aJ/NtoL8BZFPJ6w==", "requires": { "aria-hidden": "*", "embla-carousel": "*", diff --git a/package.json b/package.json index 05a68ad9a5f..b7ec914e9f7 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "@graphql-tools/load": "^7.7.0", "@graphql-tools/url-loader": "^7.17.18", "@kiva/kv-analytics": "^1.4.0", - "@kiva/kv-components": "^10.3.0", + "@kiva/kv-components": "^10.4.1", "@kiva/kv-shop": "^3.8.28", "@kiva/kv-tokens": "^5.0.0", "@mdi/js": "^7.4.47",