diff --git a/src/assets/billing/bank.svg b/src/assets/billing/bank.svg new file mode 100644 index 0000000000..42a10ad51e --- /dev/null +++ b/src/assets/billing/bank.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/pages/PlanPage/PlanPage.jsx b/src/pages/PlanPage/PlanPage.jsx index 8ae321a08c..97ce11ffae 100644 --- a/src/pages/PlanPage/PlanPage.jsx +++ b/src/pages/PlanPage/PlanPage.jsx @@ -8,6 +8,7 @@ import config from 'config' import { SentryRoute } from 'sentry' +import { Theme, useThemeContext } from 'shared/ThemeContext' import LoadingLogo from 'ui/LoadingLogo' import { PlanProvider } from './context' @@ -15,6 +16,8 @@ import PlanBreadcrumb from './PlanBreadcrumb' import { PlanPageDataQueryOpts } from './queries/PlanPageDataQueryOpts' import Tabs from './Tabs' +import { StripeAppearance } from '../../stripe' + const CancelPlanPage = lazy(() => import('./subRoutes/CancelPlanPage')) const CurrentOrgPlan = lazy(() => import('./subRoutes/CurrentOrgPlan')) const InvoicesPage = lazy(() => import('./subRoutes/InvoicesPage')) @@ -37,6 +40,8 @@ function PlanPage() { const { data: ownerData } = useSuspenseQueryV5( PlanPageDataQueryOpts({ owner, provider }) ) + const { theme } = useThemeContext() + const isDarkMode = theme !== Theme.LIGHT if (config.IS_SELF_HOSTED || !ownerData?.isCurrentUserPartOfOrg) { return @@ -45,7 +50,15 @@ function PlanPage() { return (
- + }> diff --git a/src/pages/PlanPage/PlanPage.test.jsx b/src/pages/PlanPage/PlanPage.test.jsx index 3cdb714f09..b7bbc12378 100644 --- a/src/pages/PlanPage/PlanPage.test.jsx +++ b/src/pages/PlanPage/PlanPage.test.jsx @@ -11,6 +11,8 @@ import { MemoryRouter, Route } from 'react-router-dom' import config from 'config' +import { ThemeContextProvider } from 'shared/ThemeContext' + import PlanPage from './PlanPage' vi.mock('config') @@ -44,18 +46,20 @@ const wrapper = ({ children }) => ( - - - {children} - { - testLocation = location - return null - }} - /> - - + + + + {children} + { + testLocation = location + return null + }} + /> + + + ) diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/Address/AddressCard.test.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/Address/AddressCard.test.tsx index 5142eafd5c..14372d9175 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/Address/AddressCard.test.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/Address/AddressCard.test.tsx @@ -224,7 +224,7 @@ describe('AddressCard', () => { { wrapper } ) - expect(screen.getByText('Cardholder name')).toBeInTheDocument() + expect(screen.getByText('Full name')).toBeInTheDocument() expect(screen.getByText('N/A')).toBeInTheDocument() expect(screen.getByText('Billing address')).toBeInTheDocument() expect(screen.queryByText(/null/)).not.toBeInTheDocument() @@ -241,7 +241,7 @@ describe('AddressCard', () => { { wrapper } ) - expect(screen.getByText(/Cardholder name/)).toBeInTheDocument() + expect(screen.getByText(/Full name/)).toBeInTheDocument() expect(screen.getByText(/Bob Smith/)).toBeInTheDocument() }) }) diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/Address/AddressCard.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/Address/AddressCard.tsx index 377a7a3c9e..6280b6dbe5 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/Address/AddressCard.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/Address/AddressCard.tsx @@ -40,7 +40,7 @@ function AddressCard({ {!isFormOpen && ( <>
-

Cardholder name

+

Full name

setIsFormOpen(true)} diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/BankInformation.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/BankInformation.tsx new file mode 100644 index 0000000000..ed27e5f903 --- /dev/null +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/BankInformation.tsx @@ -0,0 +1,40 @@ +import { z } from 'zod' + +import bankLogo from 'assets/billing/bank.svg' +import { USBankAccountSchema } from 'services/account' + +interface BankInformationProps { + usBankAccount: z.infer + nextBillingDisplayDate: string | null +} + +function BankInformation({ + usBankAccount, + nextBillingDisplayDate, +}: BankInformationProps) { + return ( +
+
+ bank logo +
+ + {usBankAccount?.bankName} +  ••••  + {usBankAccount?.last4} + +
+
+ {nextBillingDisplayDate && ( +

+ Your next billing date is{' '} + + {nextBillingDisplayDate} + + . +

+ )} +
+ ) +} + +export default BankInformation diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/CardInformation.jsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/CardInformation.jsx index b1830d0174..5dba09473c 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/CardInformation.jsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/CardInformation.jsx @@ -76,7 +76,6 @@ CardInformation.propTypes = { expMonth: PropTypes.number.isRequired, expYear: PropTypes.number.isRequired, }).isRequired, - openForm: PropTypes.func.isRequired, } export default CardInformation diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.jsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.jsx index 97433deb8c..4def60fbd4 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.jsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.jsx @@ -2,43 +2,59 @@ import PropTypes from 'prop-types' import { useState } from 'react' import { subscriptionDetailType } from 'services/account' +import { formatTimestampToCalendarDate } from 'shared/utils/billing' import A from 'ui/A' import Button from 'ui/Button' import Icon from 'ui/Icon' +import BankInformation from './BankInformation' import CardInformation from './CardInformation' -import CreditCardForm from './CreditCardForm' +import PaymentMethodForm from './PaymentMethodForm' function PaymentCard({ subscriptionDetail, provider, owner }) { const [isFormOpen, setIsFormOpen] = useState(false) const card = subscriptionDetail?.defaultPaymentMethod?.card + const usBankAccount = subscriptionDetail?.defaultPaymentMethod?.usBankAccount + + let nextBillingDisplayDate = null + if (!subscriptionDetail?.cancelAtPeriodEnd) { + nextBillingDisplayDate = formatTimestampToCalendarDate( + subscriptionDetail?.currentPeriodEnd + ) + } return ( -
+
{isFormOpen ? ( - setIsFormOpen(false)} + subscriptionDetail={subscriptionDetail} /> ) : card ? ( + ) : usBankAccount ? ( + ) : (

- No credit card set. Please contact support if you think it’s an - error or set it yourself. + No payment method set. Please contact support if you think it's + an error or set it yourself.

+ +
+
+
+ + ) +} + +export const stripeAddress = ( + billingDetails: z.infer | null | undefined +) => { + const address = billingDetails?.address + if (!address) return undefined + + return { + line1: address.line1 || null, + line2: address.line2 || null, + city: address.city || null, + state: address.state || null, + // eslint-disable-next-line camelcase + postal_code: address.postalCode || null, + country: address.country || null, + } +} + +export default PaymentMethodForm diff --git a/src/services/account/index.ts b/src/services/account/index.ts index 4780cda4a0..d1518703b7 100644 --- a/src/services/account/index.ts +++ b/src/services/account/index.ts @@ -1,14 +1,15 @@ -export * from './useInvoices' export * from './propTypes' export * from './useAccountDetails' export * from './useAutoActivate' +export * from './useAvailablePlans' export * from './useCancelPlan' +export * from './useCreateStripeSetupIntent' export * from './useEraseAccount' export * from './useInvoice' +export * from './useInvoices' export * from './usePlanData' -export * from './useAvailablePlans' export * from './useSentryToken' +export * from './useUpdateBillingEmail' export * from './useUpdateCard' export * from './useUpdatePaymentMethod' export * from './useUpgradePlan' -export * from './useUpdateBillingEmail' diff --git a/src/services/account/propTypes.js b/src/services/account/propTypes.js index 02a7f1e5eb..1a81954ab1 100644 --- a/src/services/account/propTypes.js +++ b/src/services/account/propTypes.js @@ -20,11 +20,15 @@ export const subscriptionDetailType = PropType.shape({ latestInvoice: invoicePropType, defaultPaymentMethod: PropType.shape({ card: PropType.shape({ - brand: PropType.string.isRequired, - expMonth: PropType.number.isRequired, - expYear: PropType.number.isRequired, - last4: PropType.string.isRequired, - }).isRequired, + brand: PropType.string, + expMonth: PropType.number, + expYear: PropType.number, + last4: PropType.string, + }), + usBankAccount: PropType.shape({ + bankName: PropType.string, + last4: PropType.string, + }), }), trialEnd: PropType.number, }) diff --git a/src/services/account/useAccountDetails.ts b/src/services/account/useAccountDetails.ts index d3fb95b85b..ec46cf8709 100644 --- a/src/services/account/useAccountDetails.ts +++ b/src/services/account/useAccountDetails.ts @@ -65,6 +65,11 @@ export const BillingDetailsSchema = z }) .nullable() +export const USBankAccountSchema = z.object({ + bankName: z.string(), + last4: z.string(), +}) + export const PaymentMethodSchema = z .object({ card: z @@ -75,12 +80,7 @@ export const PaymentMethodSchema = z last4: z.string(), }) .nullish(), - usBankAccount: z - .object({ - bankName: z.string(), - last4: z.string(), - }) - .nullish(), + usBankAccount: USBankAccountSchema.nullish(), billingDetails: BillingDetailsSchema.nullable(), }) .nullable() diff --git a/src/services/account/useUpdatePaymentMethod.ts b/src/services/account/useUpdatePaymentMethod.ts index 8cdb844901..40c88afced 100644 --- a/src/services/account/useUpdatePaymentMethod.ts +++ b/src/services/account/useUpdatePaymentMethod.ts @@ -1,4 +1,5 @@ -import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js' +import { useElements, useStripe } from '@stripe/react-stripe-js' +import { Address, StripePaymentElement } from '@stripe/stripe-js' import { useMutation, useQueryClient } from '@tanstack/react-query' import config from 'config' @@ -11,7 +12,9 @@ import { useCreateStripeSetupIntent } from './useCreateStripeSetupIntent' interface useUpdatePaymentMethodProps { provider: Provider owner: string + name?: string email?: string + address?: Address } interface useUpdatePaymentMethodReturn { @@ -19,7 +22,7 @@ interface useUpdatePaymentMethodReturn { error: null | Error isLoading: boolean mutate: ( - variables: typeof PaymentElement, + variables: StripePaymentElement | null, data?: { onSuccess?: () => void } ) => void data: undefined | unknown @@ -38,7 +41,9 @@ function getPathAccountDetails({ export function useUpdatePaymentMethod({ provider, owner, + name, email, + address, }: useUpdatePaymentMethodProps): useUpdatePaymentMethodReturn { const stripe = useStripe() const elements = useElements() @@ -61,9 +66,7 @@ export function useUpdatePaymentMethod({ // eslint-disable-next-line camelcase payment_method_data: { // eslint-disable-next-line camelcase - billing_details: { - email: email, - }, + billing_details: { name, email, address }, }, // eslint-disable-next-line camelcase return_url: `${config.BASE_URL}/plan/${provider}/${owner}`, diff --git a/src/shared/ThemeContext/ThemeContext.tsx b/src/shared/ThemeContext/ThemeContext.tsx index eb2d134876..59e9222bc6 100644 --- a/src/shared/ThemeContext/ThemeContext.tsx +++ b/src/shared/ThemeContext/ThemeContext.tsx @@ -29,7 +29,9 @@ interface ThemeContextProviderProps { export const ThemeContextProvider: FC = ({ children, }) => { - const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches + const prefersDark = + typeof window !== 'undefined' && + (window.matchMedia?.('(prefers-color-scheme: dark)')?.matches ?? false) let systemTheme = Theme.LIGHT if (prefersDark) { diff --git a/src/stripe.ts b/src/stripe.ts new file mode 100644 index 0000000000..4df192e892 --- /dev/null +++ b/src/stripe.ts @@ -0,0 +1,51 @@ +export const StripeAppearance = (isDarkMode: boolean) => { + return { + appearance: { + variables: { + fontFamily: 'Poppins, ui-sans-serif, system-ui, sans-serif', + }, + rules: { + '.Label': { + fontWeight: '600', + color: isDarkMode ? 'rgb(210,212,215)' : 'rgb(14,27,41)', // Same values as --color-app-text-primary. + }, + '.Input': { + backgroundColor: isDarkMode ? 'rgb(22,24,29)' : 'rgb(255,255,255)', // Same values as --color-app-container. + borderColor: isDarkMode ? 'rgb(47,51,60)' : 'rgb(216,220,226)', // Same values as --color-ds-gray-tertiary. + color: isDarkMode ? 'rgb(210,212,215)' : 'rgb(14,27,41)', // Same values as --color-app-text-primary. + }, + '.Input:focus': { + borderColor: isDarkMode ? 'rgb(210,212,215)' : 'rgb(14,27,41)', // Same values as --color-app-text-primary. + }, + '.Tab': { + backgroundColor: isDarkMode ? 'rgb(22,24,29)' : 'rgb(255,255,255)', // Same values as --color-app-container. + borderColor: isDarkMode ? 'rgb(47,51,60)' : 'rgb(216,220,226)', // Same values as --color-ds-gray-tertiary. + color: isDarkMode ? 'rgb(210,212,215)' : 'rgb(14,27,41)', // Same values as --color-app-text-primary. + }, + '.Tab:hover': { + backgroundColor: isDarkMode ? 'rgb(22,24,29)' : 'rgb(255,255,255)', // Same values as --color-app-container. + borderColor: isDarkMode ? 'rgb(47,51,60)' : 'rgb(216,220,226)', // Same values as --color-ds-gray-tertiary. + color: isDarkMode ? 'rgb(210,212,215)' : 'rgb(14,27,41)', // Same values as --color-app-text-primary. + }, + '.Tab--selected': { + backgroundColor: isDarkMode ? 'rgb(22,24,29)' : 'rgb(255,255,255)', // Same values as --color-app-container. + borderColor: isDarkMode ? 'rgb(47,51,60)' : 'rgb(216,220,226)', // Same values as --color-ds-gray-tertiary. + color: isDarkMode ? 'rgb(210,212,215)' : 'rgb(14,27,41)', // Same values as --color-app-text-primary. + }, + '.PickerItem': { + backgroundColor: isDarkMode ? 'rgb(22,24,29)' : 'rgb(255,255,255)', // Same values as --color-app-container. + borderColor: isDarkMode ? 'rgb(47,51,60)' : 'rgb(216,220,226)', // Same values as --color-ds-gray-tertiary. + color: isDarkMode ? 'rgb(210,212,215)' : 'rgb(14,27,41)', // Same values as --color-app-text-primary. + }, + '.PickerItem:hover': { + backgroundColor: isDarkMode ? 'rgb(22,24,29)' : 'rgb(255,255,255)', // Same values as --color-app-container. + borderColor: isDarkMode ? 'rgb(210,212,215)' : 'rgb(14,27,41)', // Same values as --color-app-text-primary. color: isDarkMode ? 'rgb(210,212,215)' : 'rgb(14,27,41)', // Same values as --color-app-text-primary. + }, + '.PickerItem--selected': { + backgroundColor: isDarkMode ? 'rgb(22,24,29)' : 'rgb(255,255,255)', // Same values as --color-app-container. + borderColor: isDarkMode ? 'rgb(47,51,60)' : 'rgb(216,220,226)', // Same values as --color-ds-gray-tertiary. + }, + }, + }, + } +}