From 4378014feb5f19e4e3a68e8e79532d0be080d596 Mon Sep 17 00:00:00 2001 From: Matt Stover Date: Fri, 8 Sep 2023 10:42:27 -0600 Subject: [PATCH 01/23] feat: implement optional async checkout muatations and status check --- .../Checkout/CheckoutDropInPaymentWrapper.vue | 73 ++++++++++++------- src/components/Checkout/KivaCreditPayment.vue | 40 +++++++--- .../query/checkout/checkoutStatus.graphql | 3 + src/plugins/checkout-utils-mixin.js | 2 +- src/util/checkoutUtils.js | 66 +++++++++++++++++ 5 files changed, 148 insertions(+), 36 deletions(-) diff --git a/src/components/Checkout/CheckoutDropInPaymentWrapper.vue b/src/components/Checkout/CheckoutDropInPaymentWrapper.vue index e7769b9782..9ecc2e5924 100644 --- a/src/components/Checkout/CheckoutDropInPaymentWrapper.vue +++ b/src/components/Checkout/CheckoutDropInPaymentWrapper.vue @@ -130,6 +130,7 @@ import * as Sentry from '@sentry/vue'; import checkoutUtils from '@/plugins/checkout-utils-mixin'; import braintreeDropInError from '@/plugins/braintree-dropin-error-mixin'; +import { pollForCheckoutStatus } from '@/util/checkoutUtils'; import braintreeDepositAndCheckout from '@/graphql/mutation/braintreeDepositAndCheckout.graphql'; import braintreeDepositAndCheckoutAsync from '@/graphql/mutation/braintreeDepositAndCheckoutAsync.graphql'; @@ -339,39 +340,61 @@ export default { }, }) .then(kivaBraintreeResponse => { + // extract transaction saga id or transaction id from response + const transactionResult = this.useAsyncCheckout + ? kivaBraintreeResponse?.data?.shop?.doNoncePaymentDepositAndCheckoutAsync + : kivaBraintreeResponse?.data?.shop?.doNoncePaymentDepositAndCheckout; + + if (this.useAsyncCheckout && typeof transactionResult !== 'object') { + pollForCheckoutStatus(this.apollo, transactionResult) + .then(checkoutStatusResponse => { + this.handleSuccessfulCheckout(checkoutStatusResponse?.receipt?.checkoutId); + }).catch(errorResponse => { + this.handleFailedCheckout([ + { + error: errorResponse.errorCode, + message: `${errorResponse?.errorMessage}, ${errorResponse?.status}`, + } + ]); + }); + + return kivaBraintreeResponse; + } + // Check for errors in transaction if (kivaBraintreeResponse.errors) { - this.$emit('updating-totals', false); - this.processBraintreeDropInError('basket', kivaBraintreeResponse); - // Payment method failed, unselect attempted payment method - this.$refs.braintreeDropInInterface.btDropinInstance.clearSelectedPaymentMethod(); - // Initialize a refresh of basket state - this.$emit('refreshtotals'); - // exit + this.handleFailedCheckout(kivaBraintreeResponse); return kivaBraintreeResponse; } - // Transaction is complete - const transactionId = _get( - kivaBraintreeResponse, - 'data.shop.doNoncePaymentDepositAndCheckout' - ); - // redirect to thanks with KIVA transaction id - if (transactionId) { - // fire BT Success event - this.$kvTrackEvent( - 'basket', - `${paymentType} Braintree DropIn Payment`, - 'Success', - transactionId, - transactionId - ); - // Complete transaction handles additional analytics + redirect - this.$emit('complete-transaction', transactionId); - } + this.handleSuccessfulCheckout(transactionResult, paymentType); + return kivaBraintreeResponse; }); }, + handleSuccessfulCheckout(transactionId, paymentType) { + // redirect to thanks with KIVA transaction id + if (transactionId) { + // fire BT Success event + this.$kvTrackEvent( + 'basket', + `${paymentType} Braintree DropIn Payment`, + 'Success', + transactionId, + transactionId + ); + // Complete transaction handles additional analytics + redirect + this.$emit('complete-transaction', transactionId); + } + }, + handleFailedCheckout(kivaBraintreeResponse) { + this.$emit('updating-totals', false); + this.processBraintreeDropInError('basket', kivaBraintreeResponse); + // Payment method failed, unselect attempted payment method + this.$refs.braintreeDropInInterface.btDropinInstance.clearSelectedPaymentMethod(); + // Initialize a refresh of basket state + this.$emit('refreshtotals'); + }, }, }; diff --git a/src/components/Checkout/KivaCreditPayment.vue b/src/components/Checkout/KivaCreditPayment.vue index 970c341293..b203736e63 100644 --- a/src/components/Checkout/KivaCreditPayment.vue +++ b/src/components/Checkout/KivaCreditPayment.vue @@ -13,6 +13,7 @@ diff --git a/src/graphql/query/checkout/checkoutStatus.graphql b/src/graphql/query/checkout/checkoutStatus.graphql index de84523104..211a767b4e 100644 --- a/src/graphql/query/checkout/checkoutStatus.graphql +++ b/src/graphql/query/checkout/checkoutStatus.graphql @@ -3,6 +3,9 @@ query checkoutStatus($transactionId: String!, $visitorId: String) { basketId errorCode errorMessage + receipt { + checkoutId + } requestedAt status transactionId diff --git a/src/plugins/checkout-utils-mixin.js b/src/plugins/checkout-utils-mixin.js index eb77382583..dc9d95b5b7 100644 --- a/src/plugins/checkout-utils-mixin.js +++ b/src/plugins/checkout-utils-mixin.js @@ -134,7 +134,7 @@ export default { } return new Promise((resolve, reject) => { this.apollo.mutate(mutObj).then(data => { - const transactionId = _get(data, 'data.shop.checkout'); + const transactionId = useAsync ? data?.data?.shop?.checkoutAsync : data?.data?.shop?.checkout; if (transactionId !== null) { // succesful transaction; resolve(transactionId); diff --git a/src/util/checkoutUtils.js b/src/util/checkoutUtils.js index 0ef0a498a2..182369ddd9 100644 --- a/src/util/checkoutUtils.js +++ b/src/util/checkoutUtils.js @@ -1,5 +1,6 @@ import numeral from 'numeral'; import myFTD from '@/graphql/query/myFTD.graphql'; +import checkoutStatus from '@/graphql/query/checkout/checkoutStatus.graphql'; import removeCreditByTypeMutation from '@/graphql/mutation/shopRemoveCreditByType.graphql'; /** Format Transaction Data for Analtyics events @@ -120,3 +121,68 @@ export function removeCredit(apollo, creditType) { } }); } + +/** + * Poll the checkoutStatus endpoint until the checkout is complete + * Note: We only operate on the COMPLETED or results with errors + * + * Possible status values: + * - BASKET_MANIFEST + * - BASKET_VALID + * - CHECKOUT_FAILED + * - CHECKOUT_RECORDED + * - CHECKOUT_ROLLED_BACK + * - COMPLETED + * - CREDIT_ADDED + * - DEPOSIT_RECORDED + * - FAILED + * - MANIFEST_FAILED + * - RECORD_CHECKOUT_FAILED + * - REQUEST_RECEIVED + * - RESERVATIONS_COMPLETED + * - STARTED + * - TRANSIENT_PAYMENT_METHOD_CHARGED + * + * @param {Object} apollo Apollo Client instance + * @param {Number} transactionId + * @param {Number} interval How often to poll + * @param {Number} timeout How long to allow polling to continue + * @returns {Promise} + */ +export async function pollForCheckoutStatus( + apollo = null, + transactionSagaId = 0, + interval = 1000, + timeout = 60000 +) { + if (!apollo) { + throw new Error('Apollo instance missing'); + } + + // establish endtime based on timeout + const endTime = Date.now() + timeout; + + const checkStatus = async () => { + // check for timeout + if (Date.now() > endTime) { + throw new Error('Polling timed out'); + } + // query checkoutStatus + const result = await apollo.query({ + query: checkoutStatus, + variables: { + transactionId: transactionSagaId + } + }); + // extract fields to check for a completed status or errors + const { status, errorCode, errorMessage } = result?.data?.checkoutStatus; + // Check for completed status, or errors and return if present + if (status === 'COMPLETED' || errorCode || errorMessage) { + return result?.data?.checkoutStatus; + } + // Check again + setTimeout(checkStatus, interval); + }; + + return checkStatus(); +} From 431c12189c5b0b1fd088ca8d5219d41b66ece687 Mon Sep 17 00:00:00 2001 From: Matt Stover Date: Wed, 11 Oct 2023 09:17:44 -0700 Subject: [PATCH 02/23] fix: initiate success handler only once for the appropriate type of checkout --- .../Checkout/CheckoutDropInPaymentWrapper.vue | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/components/Checkout/CheckoutDropInPaymentWrapper.vue b/src/components/Checkout/CheckoutDropInPaymentWrapper.vue index 9ecc2e5924..0909048757 100644 --- a/src/components/Checkout/CheckoutDropInPaymentWrapper.vue +++ b/src/components/Checkout/CheckoutDropInPaymentWrapper.vue @@ -345,6 +345,7 @@ export default { ? kivaBraintreeResponse?.data?.shop?.doNoncePaymentDepositAndCheckoutAsync : kivaBraintreeResponse?.data?.shop?.doNoncePaymentDepositAndCheckout; + // Handle async checkout polling process + response if (this.useAsyncCheckout && typeof transactionResult !== 'object') { pollForCheckoutStatus(this.apollo, transactionResult) .then(checkoutStatusResponse => { @@ -361,15 +362,17 @@ export default { return kivaBraintreeResponse; } - // Check for errors in transaction + // Handle transaction errors for either checkout path if (kivaBraintreeResponse.errors) { this.handleFailedCheckout(kivaBraintreeResponse); return kivaBraintreeResponse; } - this.handleSuccessfulCheckout(transactionResult, paymentType); - - return kivaBraintreeResponse; + // Handle success for sync checkout + if (!this.useAsyncCheckout) { + this.handleSuccessfulCheckout(transactionResult, paymentType); + return kivaBraintreeResponse; + } }); }, handleSuccessfulCheckout(transactionId, paymentType) { From 0a9424f387434240ff4074ec75dd19788f1a2cd2 Mon Sep 17 00:00:00 2001 From: Roger Gutierrez <94026278+roger-in-kiva@users.noreply.github.com> Date: Fri, 2 Feb 2024 11:48:46 -0600 Subject: [PATCH 03/23] feat: iwd activity feed added (#5152) * feat: activity avatar added for iwd activity feed * feat: activity card added for iwd page * feat: iwd activity feed added * fix: style lint fix * fix: pr comments tweaks --- .../stories/IwdActivityAvatar.stories.js | 43 ++++++++++ .storybook/stories/IwdActivityCard.stories.js | 35 ++++++++ .storybook/stories/IwdActivityFeed.stories.js | 86 +++++++++++++++++++ src/components/Iwd/ActivityAvatar.vue | 79 +++++++++++++++++ src/components/Iwd/ActivityCard.vue | 40 +++++++++ src/components/Iwd/ActivityFeed.vue | 51 +++++++++++ src/graphql/query/IwdActions.graphql | 20 +++++ src/pages/Lend/LoanChannelCategoryControl.vue | 3 + 8 files changed, 357 insertions(+) create mode 100644 .storybook/stories/IwdActivityAvatar.stories.js create mode 100644 .storybook/stories/IwdActivityCard.stories.js create mode 100644 .storybook/stories/IwdActivityFeed.stories.js create mode 100644 src/components/Iwd/ActivityAvatar.vue create mode 100644 src/components/Iwd/ActivityCard.vue create mode 100644 src/components/Iwd/ActivityFeed.vue create mode 100644 src/graphql/query/IwdActions.graphql diff --git a/.storybook/stories/IwdActivityAvatar.stories.js b/.storybook/stories/IwdActivityAvatar.stories.js new file mode 100644 index 0000000000..83082d5f43 --- /dev/null +++ b/.storybook/stories/IwdActivityAvatar.stories.js @@ -0,0 +1,43 @@ +import ActivityAvatar from '@/components/Iwd/ActivityAvatar'; + +export default { + title: 'IWD/ActivityAvatar', + component: ActivityAvatar, +}; + +const story = (args) => { + const template = (_args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { ActivityAvatar }, + template: ` +
+ +
+ `, + }); + template.args = args; + return template; +}; + +export const Default = story({ + lenderImageUrl: 'https://www.development.kiva.org/img/s100/26e15431f51b540f31cd9f011cc54f31.jpg', + lenderName: 'Roger', +}); + +export const NoImage = story({ + lenderImageUrl: '', + lenderName: 'Roger', +}); + +export const Anonymous = story({ + lenderImageUrl: 'https://www.development.kiva.org/img/s100/26e15431f51b540f31cd9f011cc54f31.jpg', + lenderName: 'Anonymous', +}); + +export const DefaultProfile = story({ + lenderImageUrl: 'https://www.development.kiva.org/img/s100/4d844ac2c0b77a8a522741b908ea5c32.jpg', + lenderName: 'Default Profile', +}); diff --git a/.storybook/stories/IwdActivityCard.stories.js b/.storybook/stories/IwdActivityCard.stories.js new file mode 100644 index 0000000000..284eda60bc --- /dev/null +++ b/.storybook/stories/IwdActivityCard.stories.js @@ -0,0 +1,35 @@ +import ActivityCard from '@/components/Iwd/ActivityCard'; + +export default { + title: 'IWD/ActivityCard', + component: ActivityCard, +}; + +const story = (args) => { + const template = (_args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { ActivityCard }, + template: ` +
+ +
+ `, + }); + template.args = args; + return template; +}; + +export const Default = story({ + activity: { + lender: { + name: 'Roger', + image: { + url: 'https://www.development.kiva.org/img/s100/26e15431f51b540f31cd9f011cc54f31.jpg', + }, + }, + shareAmount: '25.00', + } +}); + diff --git a/.storybook/stories/IwdActivityFeed.stories.js b/.storybook/stories/IwdActivityFeed.stories.js new file mode 100644 index 0000000000..aa27080159 --- /dev/null +++ b/.storybook/stories/IwdActivityFeed.stories.js @@ -0,0 +1,86 @@ +import ActivityFeed from '@/components/Iwd/ActivityFeed'; +import apolloStoryMixin from '../mixins/apollo-story-mixin'; + +const queryResult = { + data: { + lend: { + campaignActions: { + values: [ + { + lender: { + id: 723174, + name: "TonyB", + image: { + url: "https://www-0.development.kiva.org/img/s100/6b1a24092be3aaa22216874e644a4acf.jpg" + } + }, + shareAmount: "25.00" + }, + { + lender: { + id: 723174, + name: "Roger", + image: { + url: "" + } + }, + shareAmount: "25.00" + }, + { + lender: { + id: 723174, + name: "Anonymous", + image: { + url: "https://www-0.development.kiva.org/img/s100/6b1a24092be3aaa22216874e644a4acf.jpg" + } + }, + shareAmount: "25.00" + }, + { + lender: { + id: 723174, + name: "Default user", + image: { + url: "https://www-0.development.kiva.org/img/s100/4d844ac2c0b77a8a522741b908ea5c32.jpg" + } + }, + shareAmount: "25.00" + }, + { + lender: { + id: 723174, + name: "Jessica", + image: { + url: "https://www-0.development.kiva.org/img/s100/6b1a24092be3aaa22216874e644a4acf.jpg" + } + }, + shareAmount: "25.00" + }, + ] + } + } + } +}; + +export default { + title: 'IWD/ActivityFeed', + component: ActivityFeed, +}; + +const story = (args) => { + const template = (_args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { ActivityFeed }, + mixins: [apolloStoryMixin({ queryResult })], + template: ` +
+ +
+ `, + }); + template.args = args; + return template; +}; + +export const Default = story(); + diff --git a/src/components/Iwd/ActivityAvatar.vue b/src/components/Iwd/ActivityAvatar.vue new file mode 100644 index 0000000000..faf7165e82 --- /dev/null +++ b/src/components/Iwd/ActivityAvatar.vue @@ -0,0 +1,79 @@ + + + diff --git a/src/components/Iwd/ActivityCard.vue b/src/components/Iwd/ActivityCard.vue new file mode 100644 index 0000000000..9561ce9457 --- /dev/null +++ b/src/components/Iwd/ActivityCard.vue @@ -0,0 +1,40 @@ + + + diff --git a/src/components/Iwd/ActivityFeed.vue b/src/components/Iwd/ActivityFeed.vue new file mode 100644 index 0000000000..8a3c129f1c --- /dev/null +++ b/src/components/Iwd/ActivityFeed.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/src/graphql/query/IwdActions.graphql b/src/graphql/query/IwdActions.graphql new file mode 100644 index 0000000000..df74693991 --- /dev/null +++ b/src/graphql/query/IwdActions.graphql @@ -0,0 +1,20 @@ +query IwdActions { + lend { + campaignActions( + campaignKey: "international_womens_day", + filters: {gender: female} + ) { + totalCount + values { + lender { + id + name + image { + url + } + } + shareAmount + } + } + } +} \ No newline at end of file diff --git a/src/pages/Lend/LoanChannelCategoryControl.vue b/src/pages/Lend/LoanChannelCategoryControl.vue index 2ead4acdfb..6a1bbc6dd0 100644 --- a/src/pages/Lend/LoanChannelCategoryControl.vue +++ b/src/pages/Lend/LoanChannelCategoryControl.vue @@ -19,6 +19,7 @@