diff --git a/client/my-sites/add-ons/components/add-ons-card.tsx b/client/my-sites/add-ons/components/add-ons-card.tsx index 121454aaea0f7f..57942bc121e303 100644 --- a/client/my-sites/add-ons/components/add-ons-card.tsx +++ b/client/my-sites/add-ons/components/add-ons-card.tsx @@ -1,5 +1,9 @@ -import { PRODUCT_1GB_SPACE } from '@automattic/calypso-products'; import { Badge, Gridicon, Spinner } from '@automattic/components'; +import { + useAddOnPurchaseStatus, + useStorageAddOnAvailability, + StorageAddOnAvailability, +} from '@automattic/data-stores/src/add-ons'; import styled from '@emotion/styled'; import { Card, CardBody, CardFooter, CardHeader, Button } from '@wordpress/components'; import { Icon } from '@wordpress/icons'; @@ -17,16 +21,6 @@ export interface Props { text: string; handler: ( productSlug: string ) => void; }; - useAddOnAvailabilityStatus?: ( { - selectedSiteId, - addOnMeta, - }: { - selectedSiteId?: number | null | undefined; - addOnMeta: AddOnMeta; - } ) => { - available: boolean; - text?: string; - }; highlightFeatured: boolean; addOnMeta: AddOnMeta; } @@ -110,16 +104,11 @@ const Container = styled.div` } `; -const AddOnCard = ( { - addOnMeta, - actionPrimary, - actionSecondary, - useAddOnAvailabilityStatus, - highlightFeatured, -}: Props ) => { +const AddOnCard = ( { addOnMeta, actionPrimary, actionSecondary, highlightFeatured }: Props ) => { const translate = useTranslate(); const selectedSiteId = useSelector( getSelectedSiteId ); - const availabilityStatus = useAddOnAvailabilityStatus?.( { selectedSiteId, addOnMeta } ); + const purchaseStatus = useAddOnPurchaseStatus( { selectedSiteId, addOnMeta } ); + const storageAvailability = useStorageAddOnAvailability( { selectedSiteId, addOnMeta } ); const onActionPrimary = () => { actionPrimary?.handler( addOnMeta.productSlug, addOnMeta.quantity ); @@ -129,17 +118,14 @@ const AddOnCard = ( { }; const shouldRenderLoadingState = addOnMeta.isLoading; + const shouldRenderPrimaryAction = purchaseStatus?.available && ! shouldRenderLoadingState; + const shouldRenderSecondaryAction = ! purchaseStatus?.available && ! shouldRenderLoadingState; - // if product is space upgrade choose the action based on the purchased status - const shouldRenderPrimaryAction = - addOnMeta.productSlug === PRODUCT_1GB_SPACE - ? ! addOnMeta.purchased && ! shouldRenderLoadingState - : availabilityStatus?.available && ! shouldRenderLoadingState; - - const shouldRenderSecondaryAction = - addOnMeta.productSlug === PRODUCT_1GB_SPACE - ? addOnMeta.purchased && ! shouldRenderLoadingState - : ! availabilityStatus?.available && ! shouldRenderLoadingState; + // Return null if the add-on isn't already purchased and the amount of storage isn't available + // for purchase + if ( storageAvailability === StorageAddOnAvailability.Unavailable && purchaseStatus.available ) { + return null; + } return ( @@ -172,10 +158,10 @@ const AddOnCard = ( { { actionSecondary.text } ) } - { availabilityStatus?.text && ( + { purchaseStatus?.text && (
- { availabilityStatus.text } + { purchaseStatus.text }
) } diff --git a/client/my-sites/add-ons/components/add-ons-grid.tsx b/client/my-sites/add-ons/components/add-ons-grid.tsx index 6d2c6faf8b44f0..294beb5da47fa8 100644 --- a/client/my-sites/add-ons/components/add-ons-grid.tsx +++ b/client/my-sites/add-ons/components/add-ons-grid.tsx @@ -18,13 +18,7 @@ const Container = styled.div` } `; -const AddOnsGrid = ( { - addOns, - actionPrimary, - actionSecondary, - useAddOnAvailabilityStatus, - highlightFeatured, -}: Props ) => { +const AddOnsGrid = ( { addOns, actionPrimary, actionSecondary, highlightFeatured }: Props ) => { return ( { addOns.map( ( addOn ) => @@ -35,7 +29,6 @@ const AddOnsGrid = ( { } actionPrimary={ actionPrimary } actionSecondary={ actionSecondary } - useAddOnAvailabilityStatus={ useAddOnAvailabilityStatus } addOnMeta={ addOn } highlightFeatured={ highlightFeatured } /> diff --git a/client/my-sites/add-ons/main.tsx b/client/my-sites/add-ons/main.tsx index 3182a902c65649..75ca753a49259a 100644 --- a/client/my-sites/add-ons/main.tsx +++ b/client/my-sites/add-ons/main.tsx @@ -92,7 +92,6 @@ const AddOnsMain = () => { const translate = useTranslate(); const selectedSite = useSelector( getSelectedSite ) ?? null; const addOns = AddOns.useAddOns( { selectedSiteId: selectedSite?.ID } ); - const filteredAddOns = addOns.filter( ( addOn ) => ! addOn?.exceedsSiteStorageLimits ); const checkoutLink = AddOns.useAddOnCheckoutLink(); @@ -131,8 +130,7 @@ const AddOnsMain = () => { diff --git a/client/sites/overview/components/plan-card.tsx b/client/sites/overview/components/plan-card.tsx index 791d79b70efdfa..7b86e60b40f208 100644 --- a/client/sites/overview/components/plan-card.tsx +++ b/client/sites/overview/components/plan-card.tsx @@ -1,16 +1,11 @@ -import { - getPlan, - PlanSlug, - PRODUCT_1GB_SPACE, - PLAN_MONTHLY_PERIOD, -} from '@automattic/calypso-products'; +import { getPlan, PlanSlug, PLAN_MONTHLY_PERIOD } from '@automattic/calypso-products'; import { Button, PlanPrice, LoadingPlaceholder, Badge } from '@automattic/components'; import { AddOns } from '@automattic/data-stores'; import { usePricingMetaForGridPlans } from '@automattic/data-stores/src/plans'; import { usePlanBillingDescription } from '@automattic/plans-grid-next'; import clsx from 'clsx'; import { useTranslate } from 'i18n-calypso'; -import { PropsWithChildren, useState } from 'react'; +import { useState } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import PlanStorage, { useDisplayUpgradeLink } from 'calypso/blocks/plan-storage'; import QuerySitePlans from 'calypso/components/data/query-site-plans'; @@ -193,27 +188,30 @@ const PricingSection = () => { ); }; -function PlanStorageFooter( { children }: PropsWithChildren ) { +type NeedMoreStorageProps = { + noLink?: boolean; +}; + +function NeedMoreStorage( { noLink = false }: NeedMoreStorageProps ) { + const translate = useTranslate(); const site = useSelector( getSelectedSite ); const dispatch = useDispatch(); - const wrapperIsLink = useDisplayUpgradeLink( site?.ID ?? null ); + const text = translate( 'Need more storage?' ); - if ( wrapperIsLink ) { - return
{ children }
; + if ( noLink ) { + return text; } return ( -
- -
+ ); } @@ -242,11 +240,8 @@ const PlanCard = () => { const planPurchaseLoading = ! isFreePlan && planPurchase === null; const isLoading = ! planDetails || planPurchaseLoading; - // Check for storage addons available for purchase. - const addOns = AddOns.useAddOns( { selectedSiteId: site?.ID } ); - const storageAddons = addOns.filter( - ( addOn ) => addOn?.productSlug === PRODUCT_1GB_SPACE && ! addOn?.exceedsSiteStorageLimits - ); + const footerWrapperIsLink = useDisplayUpgradeLink( site?.ID ?? null ); + const availableStorageAddOns = AddOns.useAvailableStorageAddOns( { siteId: site?.ID } ); const renderManageButton = () => { if ( isJetpack || ! site || isStaging || isAgencyPurchase || isDevelopmentSite ) { @@ -331,9 +326,11 @@ const PlanCard = () => { siteId={ site?.ID } storageBarComponent={ PlanStorageBar } > - { storageAddons.length > 0 && ! isAgencyPurchase && ( - { translate( 'Need more storage?' ) } - ) } + { availableStorageAddOns.length && ! isAgencyPurchase ? ( +
+ +
+ ) : null } { site && ( diff --git a/packages/data-stores/src/add-ons/hooks/use-add-on-purchase-status.ts b/packages/data-stores/src/add-ons/hooks/use-add-on-purchase-status.ts index 6e3f56990051e1..8a31ac0dca8b18 100644 --- a/packages/data-stores/src/add-ons/hooks/use-add-on-purchase-status.ts +++ b/packages/data-stores/src/add-ons/hooks/use-add-on-purchase-status.ts @@ -1,17 +1,22 @@ -import { useTranslate } from 'i18n-calypso'; +import i18n, { useTranslate } from 'i18n-calypso'; import * as Purchases from '../../purchases'; import * as Site from '../../site'; import type { AddOnMeta } from '../types'; interface Props { addOnMeta: AddOnMeta; - selectedSiteId?: number | null | undefined; + selectedSiteId?: number | null; } +type AddOnPurchaseStatus = { + available: boolean; + text?: ReturnType< typeof i18n.translate >; +}; + /** * Returns whether add-on product has been purchased or included in site plan. */ -const useAddOnPurchaseStatus = ( { addOnMeta, selectedSiteId }: Props ) => { +const useAddOnPurchaseStatus = ( { addOnMeta, selectedSiteId }: Props ): AddOnPurchaseStatus => { const translate = useTranslate(); const matchingPurchases = Purchases.useSitePurchasesByProductSlug( { siteId: selectedSiteId, @@ -22,15 +27,21 @@ const useAddOnPurchaseStatus = ( { addOnMeta, selectedSiteId }: Props ) => { ( slug ) => siteFeatures.data?.active?.includes( slug ) ); - /* - * Order matters below: - * 1. Check if purchased first. - * 2. Check if site feature next. - * Reason: `siteFeatures.active` involves both purchases and plan features. + /** + * First, check if the add-on has a matching purchase. If storage add-on, check matching + * quantity. Secondly, check if the feature is active on the site. If there's no matching + * purchase but `siteFeatures.active` still contains the feature, it's because the feature is + * included in the plan. */ - if ( matchingPurchases ) { - return { available: false, text: translate( 'Purchased' ) }; + if ( addOnMeta.quantity ) { + const purchase: Purchases.Purchase = Object.values( matchingPurchases )[ 0 ]; + if ( purchase.purchaseRenewalQuantity === addOnMeta.quantity ) { + return { available: false, text: translate( 'Purchased' ) }; + } + } else { + return { available: false, text: translate( 'Purchased' ) }; + } } if ( isSiteFeature ) { diff --git a/packages/data-stores/src/add-ons/hooks/use-add-ons.ts b/packages/data-stores/src/add-ons/hooks/use-add-ons.ts index 7395a2f2c66f35..5e60b93366ec26 100644 --- a/packages/data-stores/src/add-ons/hooks/use-add-ons.ts +++ b/packages/data-stores/src/add-ons/hooks/use-add-ons.ts @@ -8,14 +8,12 @@ import { import { useMemo } from '@wordpress/element'; import { useTranslate } from 'i18n-calypso'; import * as ProductsList from '../../products-list'; -import * as Purchases from '../../purchases'; import * as Site from '../../site'; import { ADD_ON_100GB_STORAGE, ADD_ON_50GB_STORAGE, ADD_ON_CUSTOM_DESIGN, ADD_ON_UNLIMITED_THEMES, - STORAGE_LIMIT, } from '../constants'; import customDesignIcon from '../icons/custom-design'; import spaceUpgradeIcon from '../icons/space-upgrade'; @@ -81,7 +79,6 @@ const useActiveAddOnsDefs = ( selectedSiteId: Props[ 'selectedSiteId' ] ) => { 'Make more space for high-quality photos, videos, and other media. ' ), featured: false, - purchased: false, checkoutLink: checkoutLink( selectedSiteId ?? null, PRODUCT_1GB_SPACE, 50 ), }, { @@ -97,7 +94,6 @@ const useActiveAddOnsDefs = ( selectedSiteId: Props[ 'selectedSiteId' ] ) => { 'Take your site to the next level. Store all your media in one place without worrying about running out of space.' ), featured: false, - purchased: false, checkoutLink: checkoutLink( selectedSiteId ?? null, PRODUCT_1GB_SPACE, 100 ), }, ] as const, @@ -124,10 +120,6 @@ const useAddOns = ( { selectedSiteId }: Props = {} ): ( AddOnMeta | null )[] => const productSlugs = activeAddOns.map( ( item ) => item.productSlug ); const productsList = ProductsList.useProducts( productSlugs ); const mediaStorage = Site.useSiteMediaStorage( { siteIdOrSlug: selectedSiteId } ); - const spaceUpgradesPurchased = Purchases.useSitePurchasesByProductSlug( { - siteId: selectedSiteId, - productSlug: PRODUCT_1GB_SPACE, - } ); return useMemo( () => @@ -137,7 +129,7 @@ const useAddOns = ( { selectedSiteId }: Props = {} ): ( AddOnMeta | null )[] => const description = addOn.description ?? ( product?.description || '' ); /** - * If siteFeatures, sitePurchases, or productsList are still loading, show the add-on as loading. + * If data required by the `/add-ons` page is still loading, show the add-on as loading. * TODO: Potentially another candidate for migrating to `use-add-on-purchase-status`, and attach * that to the add-on's meta if need to. */ @@ -159,45 +151,6 @@ const useAddOns = ( { selectedSiteId }: Props = {} ): ( AddOnMeta | null )[] => return null; } - /** - * If it's a storage add-on. - */ - if ( addOn.productSlug === PRODUCT_1GB_SPACE ) { - /** - * If storage add-on is already purchased. - * TODO: Consider migrating this part to `use-add-on-purchase-status` and attach - * that to the add-on's meta if need to. The intention is to have a single source of truth. - */ - const isStorageAddOnPurchased = Object.values( spaceUpgradesPurchased ?? [] ).some( - ( purchase ) => purchase.purchaseRenewalQuantity === addOn.quantity - ); - if ( isStorageAddOnPurchased ) { - return { - ...addOn, - name, - description, - purchased: true, - }; - } - - /** - * If the current storage add-on option is greater than the available upgrade. - * TODO: This is also potentially a candidate for `use-add-on-purchase-status`. - */ - const currentMaxStorage = mediaStorage.data?.maxStorageBytes - ? mediaStorage.data.maxStorageBytes / Math.pow( 1024, 3 ) - : 0; - const availableStorageUpgrade = STORAGE_LIMIT - currentMaxStorage; - if ( ( addOn.quantity ?? 0 ) > availableStorageUpgrade ) { - return { - ...addOn, - name, - description, - exceedsSiteStorageLimits: true, - }; - } - } - /** * Regular product add-ons. */ @@ -207,14 +160,7 @@ const useAddOns = ( { selectedSiteId }: Props = {} ): ( AddOnMeta | null )[] => description, }; } ), - [ - activeAddOns, - mediaStorage.data?.maxStorageBytes, - mediaStorage.isLoading, - productsList.data, - productsList.isLoading, - spaceUpgradesPurchased, - ] + [ activeAddOns, mediaStorage.isLoading, productsList.data, productsList.isLoading ] ); }; diff --git a/packages/data-stores/src/add-ons/hooks/use-available-storage-add-ons.ts b/packages/data-stores/src/add-ons/hooks/use-available-storage-add-ons.ts index 40857fc74791e3..69a4c9312f6711 100644 --- a/packages/data-stores/src/add-ons/hooks/use-available-storage-add-ons.ts +++ b/packages/data-stores/src/add-ons/hooks/use-available-storage-add-ons.ts @@ -1,38 +1,48 @@ import { useMemo } from '@wordpress/element'; -import { useSiteMediaStorage } from '../../site'; +import { SiteMediaStorage, useSiteMediaStorage } from '../../site'; import { STORAGE_LIMIT } from '../constants'; +import { AddOnMeta } from '../types'; import useStorageAddOns from './use-storage-add-ons'; interface Props { siteId?: number | null; } +/** + * Check if the quantity for a storage add-on is available for purchase. + * @param quantity The number of gigabytes the given add-on adds to the site's storage + * @param storage Data returned from + */ +export function isStorageQuantityAvailable( quantity: number, storage: SiteMediaStorage ): boolean { + const existingAddOnStorage = storage.maxStorageBytesFromAddOns / Math.pow( 1024, 3 ); + const currentMaxStorage = storage.maxStorageBytes / Math.pow( 1024, 3 ); + const availableStorageUpgrade = STORAGE_LIMIT - currentMaxStorage; + + return existingAddOnStorage < quantity && quantity <= availableStorageUpgrade; +} + /** * Returns the storage add-ons that are available for purchase considering the current site when present. * Conditions: - * - If the user has not purchased the storage add-on. * - If the storage add-on does not exceed the site storage limits. * - If the quantity of the storage add-on is less than or equal to the available storage upgrade. */ -const useAvailableStorageAddOns = ( { siteId }: Props ) => { +const useAvailableStorageAddOns = ( { siteId }: Props ): AddOnMeta[] => { const storageAddOns = useStorageAddOns( { siteId } ); const siteMediaStorage = useSiteMediaStorage( { siteIdOrSlug: siteId } ); - const currentMaxStorage = siteMediaStorage.data?.maxStorageBytes - ? siteMediaStorage.data.maxStorageBytes / Math.pow( 1024, 3 ) - : 0; - const availableStorageUpgrade = STORAGE_LIMIT - currentMaxStorage; return useMemo( () => { - const availableStorageAddOns = storageAddOns.filter( ( addOn ) => - addOn - ? ! addOn.purchased && - ! addOn.exceedsSiteStorageLimits && - ( addOn.quantity ?? 0 ) <= availableStorageUpgrade - : false - ); + const nonNullAddOns = storageAddOns.filter( ( addOn ): addOn is AddOnMeta => addOn !== null ); + const siteMediaStorageData = siteMediaStorage.data; - return availableStorageAddOns?.length ? availableStorageAddOns : null; - }, [ availableStorageUpgrade, storageAddOns ] ); + if ( ! siteMediaStorageData ) { + return nonNullAddOns; + } + + return nonNullAddOns.filter( ( addOn ) => + isStorageQuantityAvailable( addOn?.quantity ?? 0, siteMediaStorageData ) + ); + }, [ siteMediaStorage, storageAddOns ] ); }; export default useAvailableStorageAddOns; diff --git a/packages/data-stores/src/add-ons/hooks/use-storage-add-on-availability.ts b/packages/data-stores/src/add-ons/hooks/use-storage-add-on-availability.ts new file mode 100644 index 00000000000000..8b78d0205a7c7e --- /dev/null +++ b/packages/data-stores/src/add-ons/hooks/use-storage-add-on-availability.ts @@ -0,0 +1,38 @@ +import { PRODUCT_1GB_SPACE } from '@automattic/calypso-products'; +import { useSiteMediaStorage } from '../../site'; +import { AddOnMeta } from '../types'; +import { isStorageQuantityAvailable } from './use-available-storage-add-ons'; + +interface Props { + addOnMeta: AddOnMeta; + selectedSiteId?: number | null; +} + +export enum StorageAddOnAvailability { + NotAStorageAddOn, + DataLoading, + Unavailable, + Available, +} + +/** + * Check if an add-on is a storage add-on, and if so, if the quantity is available for purchase. + */ +export default function useStorageAddOnAvailability( { + addOnMeta, + selectedSiteId, +}: Props ): StorageAddOnAvailability { + const mediaStorage = useSiteMediaStorage( { siteIdOrSlug: selectedSiteId } ); + + if ( addOnMeta.productSlug !== PRODUCT_1GB_SPACE ) { + return StorageAddOnAvailability.NotAStorageAddOn; + } + + if ( ! mediaStorage.data ) { + return StorageAddOnAvailability.DataLoading; + } + + return isStorageQuantityAvailable( addOnMeta.quantity ?? 0, mediaStorage.data ) + ? StorageAddOnAvailability.Available + : StorageAddOnAvailability.Unavailable; +} diff --git a/packages/data-stores/src/add-ons/index.ts b/packages/data-stores/src/add-ons/index.ts index 905886d6b70902..bf4232f80ee827 100644 --- a/packages/data-stores/src/add-ons/index.ts +++ b/packages/data-stores/src/add-ons/index.ts @@ -4,6 +4,10 @@ export { default as useAddOnCheckoutLink } from './hooks/use-add-on-checkout-lin export { default as useAddOnPurchaseStatus } from './hooks/use-add-on-purchase-status'; export { default as useStorageAddOns } from './hooks/use-storage-add-ons'; export { default as useAvailableStorageAddOns } from './hooks/use-available-storage-add-ons'; +export { + default as useStorageAddOnAvailability, + StorageAddOnAvailability, +} from './hooks/use-storage-add-on-availability'; export * from './constants'; /** Types */ diff --git a/packages/data-stores/src/site/queries/use-site-media-storage.ts b/packages/data-stores/src/site/queries/use-site-media-storage.ts index 321bf6b023c38e..048ee21fb5ed74 100644 --- a/packages/data-stores/src/site/queries/use-site-media-storage.ts +++ b/packages/data-stores/src/site/queries/use-site-media-storage.ts @@ -25,6 +25,7 @@ function useSiteMediaStorage( { } ); return { + maxStorageBytesFromAddOns: Number( mediaStorage.max_storage_bytes_from_add_ons ), maxStorageBytes: Number( mediaStorage.max_storage_bytes ), storageUsedBytes: Number( mediaStorage.storage_used_bytes ), }; diff --git a/packages/data-stores/src/site/types.ts b/packages/data-stores/src/site/types.ts index e0005038953246..6134500317e086 100644 --- a/packages/data-stores/src/site/types.ts +++ b/packages/data-stores/src/site/types.ts @@ -675,6 +675,7 @@ export interface AssembleSiteOptions { * Site media storage from `/sites/[ siteIdOrSlug ]/media-storage` endpoint */ export interface RawSiteMediaStorage { + max_storage_bytes_from_add_ons: number; max_storage_bytes: number; storage_used_bytes: number; } @@ -683,6 +684,7 @@ export interface RawSiteMediaStorage { * Site media storage transformed for frontend use */ export interface SiteMediaStorage { + maxStorageBytesFromAddOns: number; maxStorageBytes: number; storageUsedBytes: number; } diff --git a/packages/plans-grid-next/src/components/shared/storage/components/plan-storage.tsx b/packages/plans-grid-next/src/components/shared/storage/components/plan-storage.tsx index 079ddd364a2582..8c0982629b3c13 100644 --- a/packages/plans-grid-next/src/components/shared/storage/components/plan-storage.tsx +++ b/packages/plans-grid-next/src/components/shared/storage/components/plan-storage.tsx @@ -36,7 +36,7 @@ const PlanStorage = ( { const canUpgradeStorageForPlan = ( current || availableForPurchase ) && showUpgradeableStorage && - availableStorageAddOns && + availableStorageAddOns.length && ELIGIBLE_PLANS_FOR_STORAGE_UPGRADE.includes( planSlug ); return ( diff --git a/packages/plans-grid-next/src/components/shared/storage/hooks/use-available-storage-dropdown-options.ts b/packages/plans-grid-next/src/components/shared/storage/hooks/use-available-storage-dropdown-options.ts index 0b8ba5d1fb044b..d2be3f5ba916f2 100644 --- a/packages/plans-grid-next/src/components/shared/storage/hooks/use-available-storage-dropdown-options.ts +++ b/packages/plans-grid-next/src/components/shared/storage/hooks/use-available-storage-dropdown-options.ts @@ -25,7 +25,7 @@ const useAvailableStorageDropdownOptions = ( { } = gridPlansIndex[ planSlug ]; return useMemo( () => { - return storageFeature || availableStorageAddOns + return storageFeature || availableStorageAddOns.length ? [ ...( storageFeature ? [ storageFeature?.getSlug() as WPComPlanStorageFeatureSlug ] : [] ), /** @@ -33,7 +33,7 @@ const useAvailableStorageDropdownOptions = ( { * But being extra cautious here (in case the call is made elsewhere in the future, where it might not be redundant) * TODO: Also planning to refactor this closer to the data layer e.g. plans having a "storage-upgradeable" flag. */ - ...( ELIGIBLE_PLANS_FOR_STORAGE_UPGRADE.includes( planSlug ) && availableStorageAddOns + ...( ELIGIBLE_PLANS_FOR_STORAGE_UPGRADE.includes( planSlug ) ? availableStorageAddOns.map( ( addOn ) => addOn?.addOnSlug as AddOns.StorageAddOnSlug ) : [] ), ]