Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Show correct UI on plans page when there are inactive/multiple plans #4400

Merged
merged 25 commits into from
Apr 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
4f802b5
display plans properly if existing subscription is not in active status
LMNTL Apr 4, 2023
1ce0a28
memoize filterPrices
LMNTL Apr 4, 2023
406b2fd
fix stray hardcoded reference to plan name
LMNTL Apr 4, 2023
665cb4b
Merge branch 'feature/add-subscription-tiers' into feature/billing-st…
LMNTL Apr 5, 2023
554de2a
Merge branch 'feature/add-subscription-tiers' of github.com:kobotoolb…
srartese Apr 6, 2023
9564b86
clean up handling of button disabled state
LMNTL Apr 6, 2023
f96fcb6
use toasts instead of alert()
LMNTL Apr 6, 2023
26ea732
remove unnecessary/misleading null check
LMNTL Apr 6, 2023
7856980
sort Products by lowest price (ascending)
LMNTL Apr 6, 2023
deab387
clarify unit_amount comment
LMNTL Apr 6, 2023
7ee6293
updated viewset description
LMNTL Apr 6, 2023
54a1200
fix Upgrade not becoming uninteractable, make buttons only
LMNTL Apr 6, 2023
199f6f9
attempt to resolve browser state issues, fix typo
LMNTL Apr 6, 2023
e18c6e8
update useEffect dependencies
LMNTL Apr 6, 2023
ee88038
remove useLocation and try pageshow event
LMNTL Apr 6, 2023
d69caf1
re-fetch API data on back/forward
LMNTL Apr 7, 2023
ac0963e
Merge branch 'feature/add-subscription-tiers' into feature/billing-st…
LMNTL Apr 7, 2023
03b7097
fix typo
LMNTL Apr 7, 2023
985f84e
update comment
LMNTL Apr 7, 2023
83a167f
change submission total based on annual/monthly filter
LMNTL Apr 10, 2023
4abdba5
remove payment_method_types so Stripe relies on the payment method se…
LMNTL Apr 10, 2023
065c7c3
set the customer name, organization name in stripe
LMNTL Apr 10, 2023
a10bda1
merge in feature/add-subscription-tiers
LMNTL Apr 11, 2023
c192f8b
merge in feature/add-subscription-tiers
LMNTL Apr 11, 2023
7916fed
fix some linting errors
LMNTL Apr 11, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
247 changes: 153 additions & 94 deletions jsapp/js/account/plan.component.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import React, {useEffect, useReducer, useRef, useState} from 'react';
import React, {
useCallback,
useEffect,
useMemo,
useReducer,
useRef,
useState,
} from 'react';
import {useSearchParams} from 'react-router-dom';
import styles from './plan.module.scss';
import type {
Expand Down Expand Up @@ -46,6 +53,12 @@ const initialState = {
featureTypes: ['support', 'advanced', 'addons'],
};

/*
Stripe Subscription statuses that are shown as active in the UI.
Subscriptions with a status in this array will show an option to 'Manage'.
*/
const activeSubscriptionStatuses = ['active', 'past_due', 'trialing'];

function planReducer(state: PlanState, action: DataUpdates) {
switch (action.type) {
case 'initialProd':
Expand Down Expand Up @@ -74,31 +87,61 @@ function planReducer(state: PlanState, action: DataUpdates) {
export default function Plan() {
const [state, dispatch] = useReducer(planReducer, initialState);
const [expandComparison, setExpandComparison] = useState(false);
const [buttonsDisabled, setButtonDisabled] = useState(false);
const [searchParams, _setSearchParams] = useSearchParams();
const [areButtonsDisabled, setAreButtonsDisabled] = useState(true);
const [shouldRevalidate, setShouldRevalidate] = useState(false);
const [searchParams] = useSearchParams();
const didMount = useRef(false);
const hasActiveSubscription = useMemo(
() =>
state.subscribedProduct.some((subscription: BaseSubscription) =>
activeSubscriptionStatuses.includes(subscription.status)
),
[state.subscribedProduct]
);

useEffect(() => {
getProducts().then((data) => {
dispatch({
type: 'initialProd',
prodData: data.results,
});
});
const promises = [];
promises.push(
getProducts().then((data) => {
dispatch({
type: 'initialProd',
prodData: data.results,
});
})
);

getOrganization().then((data) => {
dispatch({
type: 'initialOrg',
prodData: data.results[0],
});
});
promises.push(
getOrganization().then((data) => {
dispatch({
type: 'initialOrg',
prodData: data.results[0],
});
})
);

getSubscription().then((data) => {
dispatch({
type: 'initialSub',
prodData: data.results,
});
});
promises.push(
getSubscription().then((data) => {
dispatch({
type: 'initialSub',
prodData: data.results,
});
})
);

Promise.all(promises).then(() => setAreButtonsDisabled(false));
}, [searchParams, shouldRevalidate]);

// Re-fetch data from API and re-enable buttons if displaying from back/forward cache
useEffect(() => {
const handlePersisted = (event: PageTransitionEvent) => {
if (event.persisted) {
setShouldRevalidate(!shouldRevalidate);
}
};
window.addEventListener('pageshow', handlePersisted);
return () => {
window.removeEventListener('pageshow', handlePersisted);
};
}, []);

useEffect(() => {
Expand All @@ -111,9 +154,8 @@ export default function Plan() {
const priceId = searchParams.get('checkout');
if (priceId) {
const isSubscriptionUpdated = state.subscribedProduct.find(
(subscription: BaseSubscription) => {
return subscription.items.find((item) => item.price.id === priceId);
}
(subscription: BaseSubscription) =>
subscription.items.find((item) => item.price.id === priceId)
);
if (isSubscriptionUpdated) {
notify.success(
Expand All @@ -132,66 +174,90 @@ export default function Plan() {
}, [state.subscribedProduct]);

// Filter prices based on plan interval
const filterPrices = (): Price[] => {
if (state.products.length > 0) {
const filterAmount = state.products.map((product: Product) => {
const filteredPrices = product.prices.filter((price: BasePrice) => {
const interval = price.human_readable_price.split('/')[1];
return interval === state.intervalFilter || price.unit_amount === 0;
});
return {
...product,
prices: filteredPrices.length ? filteredPrices[0] : null,
};
const filterPrices = useMemo((): Price[] => {
const filterAmount = state.products.map((product: Product) => {
const filteredPrices = product.prices.filter((price: BasePrice) => {
const interval = price.human_readable_price.split('/')[1];
return interval === state.intervalFilter;
});
return filterAmount.filter((product: Product) => product.prices);
}
return [];
};
return {
...product,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great clean up!

prices: filteredPrices.length ? filteredPrices[0] : null,
};
});
return filterAmount.filter((product: Product) => product.prices);
}, [state.products, state.intervalFilter]);

const isSubscribedProduct = (product: Price) => {
if (product.prices.unit_amount === 0 && !state.subscribedProduct?.length) {
return true;
}
return product.name === state.subscribedProduct?.name;
};
const getSubscriptionForProductId = useCallback(
(productId: String) =>
state.subscribedProduct.find(
(subscription: BaseSubscription) =>
subscription.items[0].price.product.id === productId
),
[state.subscribedProduct]
);

const isSubscribedProduct = useCallback(
(product: Price) => {
if (!product.prices.unit_amount && !hasActiveSubscription) {
return true;
}
const subscription = getSubscriptionForProductId(product.id);
return Boolean(subscription?.status === 'active');
},
[state.subscribedProduct]
);

const shouldShowManage = useCallback(
(product: Price) => {
const subscription = getSubscriptionForProductId(product.id);
if (!subscription) {
return false;
}
const hasManageableStatus = activeSubscriptionStatuses.includes(
subscription.status
);
return Boolean(state.organization?.uid) && hasManageableStatus;
},
[state.subscribedProduct]
);

const upgradePlan = (priceId: string) => {
if (!priceId || buttonsDisabled) {
if (!priceId || areButtonsDisabled) {
return;
}
setButtonDisabled(buttonsDisabled);
setAreButtonsDisabled(true);
postCheckout(priceId, state.organization?.uid)
.then((data) => {
if (!data.url) {
alert(t('There has been an issue, please try again later.'));
notify.error(t('There has been an issue, please try again later.'));
} else {
window.location.assign(data.url);
}
})
.finally(() => setButtonDisabled(!buttonsDisabled));
.catch(() => setAreButtonsDisabled(false));
};

const managePlan = () => {
if (!state.organization?.uid || buttonsDisabled) {
if (!state.organization?.uid || areButtonsDisabled) {
return;
}
setButtonDisabled(buttonsDisabled);
postCustomerPortal(state.organization?.uid)
setAreButtonsDisabled(true);
postCustomerPortal(state.organization.uid)
.then((data) => {
if (!data.url) {
alert(t('There has been an issue, please try again later.'));
notify.error(t('There has been an issue, please try again later.'));
} else {
window.location.assign(data.url);
}
})
.finally(() => setButtonDisabled(!buttonsDisabled));
.catch(() => setAreButtonsDisabled(false));
};

// Get feature items and matching icon boolean
const getListItem = (listType: string, plan: string) => {
const listItems: Array<{icon: boolean; item: string}> = [];
filterPrices().map((price) =>
filterPrices.map((price) =>
Object.keys(price.metadata).map((featureItem: string) => {
const numberItem = featureItem.lastIndexOf('_');
const currentResult = featureItem.substring(numberItem + 1);
Expand All @@ -204,7 +270,8 @@ export default function Plan() {
) {
const keyName = `feature_${listType}_${currentResult}`;
let iconBool = false;
const itemName: string = price.metadata[keyName];
const itemName: string =
price.prices.metadata?.[keyName] || price.metadata[keyName];
if (price.metadata[currentIcon] !== undefined) {
iconBool = JSON.parse(price.metadata[currentIcon]);
listItems.push({icon: iconBool, item: itemName});
Expand All @@ -218,7 +285,7 @@ export default function Plan() {
const hasMetaFeatures = () => {
let expandBool = false;
if (state.products.length >= 0) {
filterPrices().map((price) => {
filterPrices.map((price) => {
for (const featureItem in price.metadata) {
if (
featureItem.includes('feature_support_') ||
Expand Down Expand Up @@ -317,20 +384,12 @@ export default function Plan() {
</form>

<div className={styles.allPlans}>
{filterPrices().map((price: Price) => (
{filterPrices.map((price: Price) => (
<div className={styles.stripePlans} key={price.id}>
{isSubscribedProduct(price) && (
<div
className={styles.currentPlan}
style={{
display:
filterPrices().findIndex(isSubscribedProduct) >= 0
? ''
: 'none',
}}
>
{t('your plan')}
</div>
{shouldShowManage(price) || isSubscribedProduct(price) ? (
<div className={styles.currentPlan}>{t('your plan')}</div>
) : (
<div className={styles.otherPlanSpacing} />
)}
<div
className={classnames({
Expand All @@ -340,10 +399,9 @@ export default function Plan() {
>
<h1 className={styles.priceName}> {price.name} </h1>
<div className={styles.priceTitle}>
{typeof price.prices.human_readable_price === 'string' &&
(price.prices.human_readable_price.includes('$0.00')
? t('Free')
: price.prices.human_readable_price)}
{!price.prices?.unit_amount
? t('Free')
: price.prices.human_readable_price}
</div>

<ul>
Expand All @@ -362,39 +420,40 @@ export default function Plan() {
}
/>
</div>
{price.metadata[featureItem]}
{price.prices.metadata?.[featureItem] ||
price.metadata[featureItem]}
</li>
)
)}
</ul>

{!isSubscribedProduct(price) && (
<Button
type='full'
color='blue'
size='m'
label={t('Upgrade')}
onClick={() => upgradePlan(price.prices.id)}
aria-label={`upgrade to ${price.name}`}
aria-disabled={buttonsDisabled}
isDisabled={buttonsDisabled}
/>
)}
{isSubscribedProduct(price) &&
state.organization?.uid &&
price.name !== 'Community plan' && (
{!isSubscribedProduct(price) &&
!shouldShowManage(price) &&
price.prices.unit_amount !== 0 && (
<Button
type='full'
color='blue'
size='m'
label={t('Upgrade')}
onClick={() => upgradePlan(price.prices.id)}
aria-label={`upgrade to ${price.name}`}
aria-disabled={areButtonsDisabled}
isDisabled={areButtonsDisabled}
/>
)}
{(isSubscribedProduct(price) || shouldShowManage(price)) &&
price.prices.unit_amount !== 0 && (
<Button
type='full'
color='blue'
size='m'
label={t('Manage')}
onClick={managePlan}
aria-label={`manage your ${price.name} subscription`}
aria-disabled={buttonsDisabled}
isDisabled={buttonsDisabled}
aria-disabled={areButtonsDisabled}
isDisabled={areButtonsDisabled}
/>
)}
{price.name === 'Community plan' && (
{price.prices.unit_amount === 0 && (
<div className={styles.btnSpacePlaceholder} />
)}

Expand Down
6 changes: 5 additions & 1 deletion jsapp/js/account/stripe.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ export interface BasePrice {
unit_amount: number;
human_readable_price: string;
metadata: {[key: string]: string};
product: BaseProduct;
}

export interface BaseSubscription {
id: number;
price: Product;
status: string;
items: [{ price:BasePrice }];
}

Expand Down Expand Up @@ -56,7 +58,9 @@ export async function getProducts() {
}

export async function getSubscription() {
return fetchGet<PaginatedResponse<BaseSubscription>>(endpoints.SUBSCRIPTION_URL);
return fetchGet<PaginatedResponse<BaseSubscription>>(
endpoints.SUBSCRIPTION_URL
);
}

export async function getOrganization() {
Expand Down