diff --git a/client/web/BUILD.bazel b/client/web/BUILD.bazel index a8df1224738b..50aed63dc9d3 100644 --- a/client/web/BUILD.bazel +++ b/client/web/BUILD.bazel @@ -245,16 +245,18 @@ ts_project( "src/cody/management/api/teamMembers.ts", "src/cody/management/api/teamSubscriptions.ts", "src/cody/management/api/types.ts", + "src/cody/management/subscription/StripeAddressElement.tsx", + "src/cody/management/subscription/StripeCardDetails.tsx", + "src/cody/management/subscription/manage/BillingAddress.tsx", "src/cody/management/subscription/manage/CodySubscriptionManagePage.tsx", "src/cody/management/subscription/manage/InvoiceHistory.tsx", "src/cody/management/subscription/manage/LoadingIconButton.tsx", + "src/cody/management/subscription/manage/NonEditableBillingAddress.tsx", "src/cody/management/subscription/manage/PaymentDetails.tsx", "src/cody/management/subscription/manage/SubscriptionDetails.tsx", "src/cody/management/subscription/manage/utils.ts", "src/cody/management/subscription/new/CodyProCheckoutForm.tsx", - "src/cody/management/subscription/new/CodyProCheckoutFormContainer.tsx", "src/cody/management/subscription/new/NewCodyProSubscriptionPage.tsx", - "src/cody/management/subscription/new/PayButton.tsx", "src/cody/onboarding/CodyOnboarding.tsx", "src/cody/onboarding/EditorStep.tsx", "src/cody/onboarding/PurposeStep.tsx", diff --git a/client/web/src/cody/management/api/client.ts b/client/web/src/cody/management/api/client.ts index 0679f834d5fa..fb8451965582 100644 --- a/client/web/src/cody/management/api/client.ts +++ b/client/web/src/cody/management/api/client.ts @@ -20,6 +20,12 @@ export module Client { return { method: 'PATCH', urlSuffix: '/team/current/subscription', requestBody } } + export function previewUpdateCurrentSubscription( + requestBody: types.PreviewUpdateSubscriptionRequest + ): Call { + return { method: 'PATCH', urlSuffix: '/team/current/subscription/preview', requestBody } + } + export function getCurrentSubscriptionInvoices(): Call { return { method: 'GET', urlSuffix: '/team/current/subscription/invoices' } } @@ -30,6 +36,16 @@ export module Client { return { method: 'POST', urlSuffix: '/team/current/subscription/reactivate', requestBody } } + // Teams + + export function createTeam(requestBody: types.CreateTeamRequest): Call { + return { method: 'POST', urlSuffix: '/team', requestBody } + } + + export function previewCreateTeam(requestBody: types.PreviewCreateTeamRequest): Call { + return { method: 'POST', urlSuffix: '/team/preview', requestBody } + } + // Stripe Checkout export function createStripeCheckoutSession( diff --git a/client/web/src/cody/management/api/react-query/callCodyProApi.ts b/client/web/src/cody/management/api/react-query/callCodyProApi.ts index 54b623a8988b..e9d6d1a4fada 100644 --- a/client/web/src/cody/management/api/react-query/callCodyProApi.ts +++ b/client/web/src/cody/management/api/react-query/callCodyProApi.ts @@ -1,5 +1,11 @@ import type { Call } from '../client' +export class CodyProApiError extends Error { + constructor(message: string, public status: number) { + super(message) + } +} + /** * Builds the RequestInit object for the fetch API with the necessary headers and options * to authenticate the request with the Sourcegraph backend. @@ -29,7 +35,9 @@ const signOutAndRedirectToSignIn = async (): Promise => { } } -export const callCodyProApi = async (call: Call): Promise => { +// Important: This function has the side effect of logging the user out and redirecting them +// to the sign-in page with the current page as the return URL if they are not authenticated. +export const callCodyProApi = async (call: Call): Promise => { const response = await fetch( `/.api/ssc/proxy${call.urlSuffix}`, buildRequestInit({ @@ -41,14 +49,14 @@ export const callCodyProApi = async (call: Call): Promise => useQuery({ queryKey: queryKeys.subscription(), - queryFn: async () => callCodyProApi(Client.getCurrentSubscription()), + queryFn: async () => { + const response = await callCodyProApi(Client.getCurrentSubscription()) + return response.ok ? response.json() : undefined + }, }) export const useUpdateCurrentSubscription = (): UseMutationResult< @@ -32,7 +41,10 @@ export const useUpdateCurrentSubscription = (): UseMutationResult< > => { const queryClient = useQueryClient() return useMutation({ - mutationFn: async requestBody => callCodyProApi(Client.updateCurrentSubscription(requestBody)), + mutationFn: async requestBody => { + const response = await callCodyProApi(Client.updateCurrentSubscription(requestBody)) + return (await response.json()) as Subscription + }, onSuccess: data => { // We get updated subscription data in response - no need to refetch subscription. // All the `queryKeys.subscription()` subscribers (`useCurrentSubscription` callers) will get the updated value automatically. @@ -45,3 +57,21 @@ export const useUpdateCurrentSubscription = (): UseMutationResult< }, }) } + +export const useCreateTeam = (): UseMutationResult => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async requestBody => { + await callCodyProApi(Client.createTeam(requestBody)) + }, + onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKeys.all }), + }) +} + +export const usePreviewCreateTeam = (): UseMutationResult => + useMutation({ + mutationFn: async requestBody => { + const response = await callCodyProApi(Client.previewCreateTeam(requestBody)) + return (await response.json()) as PreviewResult + }, + }) diff --git a/client/web/src/cody/management/api/teamSubscriptions.ts b/client/web/src/cody/management/api/teamSubscriptions.ts index 6fa93031af35..6b23197a3bfa 100644 --- a/client/web/src/cody/management/api/teamSubscriptions.ts +++ b/client/web/src/cody/management/api/teamSubscriptions.ts @@ -1,4 +1,4 @@ -import { TeamRole } from './teamMembers' +import type { TeamRole } from './teamMembers' // BillingInterval is the subscription's billing cycle. 'daily' is only // available in the dev environment. @@ -112,7 +112,29 @@ export interface UpdateSubscriptionRequest { subscriptionUpdate?: SubscriptionUpdateOptions } +export interface PreviewUpdateSubscriptionRequest { + newSeatCount?: number + newBillingInterval?: BillingInterval + newCancelAtPeriodEnd?: boolean +} + export interface GetSubscriptionInvoicesResponse { invoices: Invoice[] continuationToken?: string } + +export interface CreateTeamRequest { + name: string + slug: string + seats: number + address: Address + billingInterval: BillingInterval + couponCode?: string + creditCardToken: string +} + +export interface PreviewCreateTeamRequest { + seats: number + billingInterval: BillingInterval + couponCode?: string +} diff --git a/client/web/src/cody/management/api/types.ts b/client/web/src/cody/management/api/types.ts index 2d2d98658480..4323351ef559 100644 --- a/client/web/src/cody/management/api/types.ts +++ b/client/web/src/cody/management/api/types.ts @@ -1,4 +1,4 @@ -// Export all of the API types, so consumers we can organize the type definitions +// Export all API types, so consumers we can organize the type definitions // into smaller files, without consumers needing to care about that organization. export * from './teamInvites' export * from './teamMembers' diff --git a/client/web/src/cody/management/subscription/StripeAddressElement.tsx b/client/web/src/cody/management/subscription/StripeAddressElement.tsx new file mode 100644 index 000000000000..eddcf71df1b8 --- /dev/null +++ b/client/web/src/cody/management/subscription/StripeAddressElement.tsx @@ -0,0 +1,35 @@ +import React, { useMemo } from 'react' + +import { AddressElement } from '@stripe/react-stripe-js' +import type { StripeAddressElementOptions } from '@stripe/stripe-js' + +import type { Subscription } from '../api/types' + +interface StripeAddressElementProps { + subscription?: Subscription + onFocus?: () => void +} + +export const StripeAddressElement: React.FC = ({ subscription, onFocus }) => { + const options = useMemo( + (): StripeAddressElementOptions => ({ + mode: 'billing', + display: { name: 'full' }, + ...(subscription + ? { + defaultValues: { + name: subscription.name, + address: { + ...subscription.address, + postal_code: subscription.address.postalCode, + }, + }, + } + : {}), + autocomplete: { mode: 'automatic' }, + }), + [subscription] + ) + + return +} diff --git a/client/web/src/cody/management/subscription/StripeCardDetails.tsx b/client/web/src/cody/management/subscription/StripeCardDetails.tsx new file mode 100644 index 000000000000..0be64434b161 --- /dev/null +++ b/client/web/src/cody/management/subscription/StripeCardDetails.tsx @@ -0,0 +1,62 @@ +import React, { useCallback } from 'react' + +import { CardNumberElement, CardExpiryElement, CardCvcElement } from '@stripe/react-stripe-js' +import type { StripeCardElementOptions } from '@stripe/stripe-js' + +import { useTheme, Theme } from '@sourcegraph/shared/src/theme' +import { Label, Text, Grid } from '@sourcegraph/wildcard' + +const useCardElementOptions = (): ((type: 'number' | 'expiry' | 'cvc') => StripeCardElementOptions) => { + const { theme } = useTheme() + + return useCallback( + (type: 'number' | 'expiry' | 'cvc') => ({ + ...(type === 'number' ? { disableLink: true } : {}), + + classes: { + base: 'form-control py-2', + focus: 'focus-visible', + invalid: 'is-invalid', + }, + + style: { + base: { + color: theme === Theme.Light ? '#262b38' : '#dbe2f0', + }, + }, + }), + [theme] + ) +} + +interface StripeCardDetailsProps { + onFocus?: () => void + className?: string +} + +export const StripeCardDetails: React.FC = ({ onFocus, className }) => { + const getOptions = useCardElementOptions() + + return ( +
+
+ +
+ + + + + + +
+ ) +} diff --git a/client/web/src/cody/management/subscription/manage/BillingAddress.tsx b/client/web/src/cody/management/subscription/manage/BillingAddress.tsx new file mode 100644 index 000000000000..64eef97c7182 --- /dev/null +++ b/client/web/src/cody/management/subscription/manage/BillingAddress.tsx @@ -0,0 +1,194 @@ +import React, { useMemo, useState, useEffect } from 'react' + +import { mdiPencilOutline, mdiCheck } from '@mdi/js' +import { useStripe, useElements, AddressElement, Elements } from '@stripe/react-stripe-js' +import type { Stripe, StripeElementsOptions } from '@stripe/stripe-js' +import classNames from 'classnames' + +import { useTheme, Theme } from '@sourcegraph/shared/src/theme' +import { H3, Button, Icon, Text, Form } from '@sourcegraph/wildcard' + +import { useUpdateCurrentSubscription } from '../../api/react-query/subscriptions' +import type { Subscription } from '../../api/teamSubscriptions' +import { StripeAddressElement } from '../StripeAddressElement' + +import { LoadingIconButton } from './LoadingIconButton' +import { NonEditableBillingAddress } from './NonEditableBillingAddress' + +import styles from './PaymentDetails.module.scss' + +export const useBillingAddressStripeElementsOptions = (): StripeElementsOptions => { + const { theme } = useTheme() + + return useMemo( + () => ({ + appearance: { + variables: { + // corresponds to var(--font-family-base) + fontFamily: + "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'", + gridRowSpacing: '16px', + borderRadius: '3px', + }, + + rules: { + '.Label': { + marginBottom: '8px', + fontWeight: '500', + color: theme === Theme.Light ? '#343a4d' : '#dbe2f0', + lineHeight: '20px', + fontSize: '14px', + }, + '.Input': { + backgroundColor: theme === Theme.Light ? '#ffffff' : '#1d212f', + color: theme === Theme.Light ? '#262b38' : '#dbe2f0', + paddingTop: '6px', + paddingBottom: '6px', + borderColor: theme === Theme.Light ? '#dbe2f0' : '#343a4d', + boxShadow: 'none', + lineHeight: '20px', + fontSize: '14px', + }, + '.Input:focus': { + borderColor: '#0b70db', + boxShadow: `0 0 0 0.125rem ${theme === Theme.Light ? '#a3d0ff' : '#0f59aa'}`, + }, + }, + }, + }), + [theme] + ) +} + +interface BillingAddressProps { + stripe: Stripe | null + subscription: Subscription + title?: string + editable: boolean +} + +export const BillingAddress: React.FC = ({ stripe, subscription, title, editable }) => { + const [isEditMode, setIsEditMode] = useState(false) + + const options = useBillingAddressStripeElementsOptions() + + return ( +
+
+ {title ??

{title}

} + {editable && ( + + )} +
+ + {isEditMode ? ( + + setIsEditMode(false)} + onSubmit={() => setIsEditMode(false)} + /> + + ) : ( + + )} +
+ ) +} + +interface BillingAddressFormProps { + subscription: Subscription + onReset: () => void + onSubmit: () => void +} + +const updateSubscriptionMutationErrorText = + "We couldn't update your credit card info. Please try again. If this happens again, contact support at support@sourcegraph.com." + +const BillingAddressForm: React.FC = ({ subscription, onReset, onSubmit }) => { + const stripe = useStripe() + const elements = useElements() + + const updateCurrentSubscriptionMutation = useUpdateCurrentSubscription() + + const [isStripeLoading, setIsStripeLoading] = useState(false) + const [stripeErrorMessage, setStripeErrorMessage] = useState('') + + const [isErrorVisible, setIsErrorVisible] = useState(true) + + const isLoading = isStripeLoading || updateCurrentSubscriptionMutation.isPending + const errorMessage = + stripeErrorMessage || updateCurrentSubscriptionMutation.isError ? updateSubscriptionMutationErrorText : '' + + useEffect(() => { + if (errorMessage) { + setIsErrorVisible(true) + } + }, [errorMessage]) + + const handleSubmit: React.FormEventHandler = async (event): Promise => { + event.preventDefault() + + setStripeErrorMessage('') + + if (!stripe || !elements) { + return setStripeErrorMessage('Stripe or Stripe Elements libraries are not available.') + } + + const addressElement = elements.getElement(AddressElement) + if (!addressElement) { + return setStripeErrorMessage('Stripe address element was not found.') + } + + setIsStripeLoading(true) + const addressElementValue = await addressElement.getValue() + setIsStripeLoading(false) + if (!addressElementValue.complete) { + return setStripeErrorMessage('Address is not complete.') + } + + const { line1, line2, postal_code, city, state, country } = addressElementValue.value.address + updateCurrentSubscriptionMutation.mutate( + { + customerUpdate: { + newName: addressElementValue.value.name, + newAddress: { + line1, + line2: line2 || '', + postalCode: postal_code, + city, + state, + country, + }, + }, + }, + { onSuccess: onSubmit } + ) + } + + return ( +
+ setIsErrorVisible(false)} /> + + {isErrorVisible && errorMessage ? {errorMessage} : null} + +
+ + + Save + +
+ + ) +} diff --git a/client/web/src/cody/management/subscription/manage/CodySubscriptionManagePage.tsx b/client/web/src/cody/management/subscription/manage/CodySubscriptionManagePage.tsx index 5a9793e1e90d..77c40878f80f 100644 --- a/client/web/src/cody/management/subscription/manage/CodySubscriptionManagePage.tsx +++ b/client/web/src/cody/management/subscription/manage/CodySubscriptionManagePage.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import React, { useEffect } from 'react' import classNames from 'classnames' import { Navigate } from 'react-router-dom' @@ -192,7 +192,7 @@ const CodyIcon: React.FC<{ className?: string }> = ({ className }) => ( gradientUnits="userSpaceOnUse" gradientTransform="translate(23.4233 -44.4873) rotate(77.074) scale(58.2161)" > - + = ({ className }) => ( y2="17" gradientUnits="userSpaceOnUse" > - - - - - + + + + + = ({ className }) => ( y2="26.4367" gradientUnits="userSpaceOnUse" > - - + + = ({ className }) => ( y2="77.5" gradientUnits="userSpaceOnUse" > - - - - + + + + diff --git a/client/web/src/cody/management/subscription/manage/NonEditableBillingAddress.tsx b/client/web/src/cody/management/subscription/manage/NonEditableBillingAddress.tsx new file mode 100644 index 000000000000..d91d85e9d438 --- /dev/null +++ b/client/web/src/cody/management/subscription/manage/NonEditableBillingAddress.tsx @@ -0,0 +1,60 @@ +import React from 'react' + +import { Text } from '@sourcegraph/wildcard' + +import type { Subscription } from '../../api/teamSubscriptions' + +export const NonEditableBillingAddress: React.FC<{ subscription: Subscription }> = ({ + subscription: { name, address }, +}) => ( +
+
+ + Full name + + {name} +
+ +
+ + Country or region + + {address.country || '-'} +
+ +
+ + Address line 1 + + {address.line1 || '-'} +
+ +
+ + Address line 2 + + {address.line2 || '-'} +
+ +
+ + City + + {address.city || '-'} +
+ +
+ + State + + {address.state || '-'} +
+ +
+ + Postal code + + {address.postalCode || '-'} +
+
+) diff --git a/client/web/src/cody/management/subscription/manage/PaymentDetails.tsx b/client/web/src/cody/management/subscription/manage/PaymentDetails.tsx index 327dbdf10045..b8c0a709d7a4 100644 --- a/client/web/src/cody/management/subscription/manage/PaymentDetails.tsx +++ b/client/web/src/cody/management/subscription/manage/PaymentDetails.tsx @@ -1,86 +1,69 @@ -import { useEffect, useMemo, useState } from 'react' +import React, { useEffect, useState } from 'react' import { mdiPencilOutline, mdiCreditCardOutline, mdiPlus, mdiCheck } from '@mdi/js' -import { - AddressElement, - CardCvcElement, - CardExpiryElement, - CardNumberElement, - Elements, - useElements, - useStripe, -} from '@stripe/react-stripe-js' -import { - loadStripe, - type StripeCardElementOptions, - type StripeAddressElementOptions, - type StripeElementsOptions, -} from '@stripe/stripe-js' +import { CardNumberElement, Elements, useElements, useStripe } from '@stripe/react-stripe-js' +import { loadStripe } from '@stripe/stripe-js' import classNames from 'classnames' import { logger } from '@sourcegraph/common' -import { Theme, useTheme } from '@sourcegraph/shared/src/theme' -import { Button, Form, Grid, H3, Icon, Label, Text } from '@sourcegraph/wildcard' +import { Button, Form, Grid, H3, Icon, Text } from '@sourcegraph/wildcard' import { useUpdateCurrentSubscription } from '../../api/react-query/subscriptions' -import type { Subscription } from '../../api/teamSubscriptions' +// Suppressing false positive caused by an ESLint bug. See https://github.com/typescript-eslint/typescript-eslint/issues/4608 +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import type { PaymentMethod, Subscription } from '../../api/types' +import { StripeCardDetails } from '../StripeCardDetails' +import { BillingAddress } from './BillingAddress' import { LoadingIconButton } from './LoadingIconButton' import styles from './PaymentDetails.module.scss' +// NOTE: Call loadStripe outside a component’s render to avoid recreating the object. +// We do it here, meaning that "stripe.js" will get loaded lazily, when the user +// routes to this page. const publishableKey = window.context.frontendCodyProConfig?.stripePublishableKey if (!publishableKey) { logger.error('Stripe publishable key not found') } - -const stripePromise = loadStripe(publishableKey || '') +const stripe = await loadStripe(publishableKey || '') const updateSubscriptionMutationErrorText = - 'An error occurred while updating your credit card info. Please try again. If the problem persists, contact support at support@sourcegraph.com.' - -interface PaymentDetailsProps { - subscription: Subscription -} + "We couldn't update your credit card info. Please try again. If this happens again, contact support at support@sourcegraph.com." -export const PaymentDetails: React.FC = props => ( +export const PaymentDetails: React.FC<{ subscription: Subscription }> = ({ subscription }) => (
- +
- +
) -const PaymentMethod: React.FC = props => { +const PaymentMethod: React.FC<{ paymentMethod: PaymentMethod | undefined }> = ({ paymentMethod }) => { const [isEditMode, setIsEditMode] = useState(false) - if (!props.subscription.paymentMethod) { + if (!paymentMethod) { return setIsEditMode(true)} /> } if (isEditMode) { return ( - + setIsEditMode(false)} onSubmit={() => setIsEditMode(false)} /> ) } - return ( - setIsEditMode(true)} - /> - ) + return setIsEditMode(true)} /> } -const PaymentMethodMissing: React.FC<{ onAddButtonClick: () => void }> = props => ( +const PaymentMethodMissing: React.FC<{ onAddButtonClick: () => void }> = ({ onAddButtonClick }) => (

No payment method is available

-
@@ -107,30 +90,6 @@ const ActivePaymentMethod: React.FC< ) -const useStripeCardElementOptions = (): StripeCardElementOptions => { - const { theme } = useTheme() - - return useMemo( - () => ({ - disableLink: true, - hidePostalCode: true, - - classes: { - base: 'form-control py-2', - focus: 'focus-visible', - invalid: 'is-invalid', - }, - - style: { - base: { - color: theme === Theme.Light ? '#262b38' : '#dbe2f0', - }, - }, - }), - [theme] - ) -} - interface PaymentMethodFormProps { onReset: () => void onSubmit: () => void @@ -139,7 +98,6 @@ interface PaymentMethodFormProps { const PaymentMethodForm: React.FC = props => { const stripe = useStripe() const elements = useElements() - const cardElementOptions = useStripeCardElementOptions() const updateCurrentSubscriptionMutation = useUpdateCurrentSubscription() @@ -185,31 +143,12 @@ const PaymentMethodForm: React.FC = props => { ) } - const cardElementProps = { options: cardElementOptions, onFocus: () => setIsErrorVisible(false) } - return ( <>

Edit credit card

-
- -
- - - - - - + setIsErrorVisible(false)} /> {isErrorVisible && errorMessage ? {errorMessage} : null} @@ -232,233 +171,3 @@ const PaymentMethodForm: React.FC = props => { ) } - -const useBillingAddressStripeElementsOptions = (): StripeElementsOptions => { - const { theme } = useTheme() - - return useMemo( - () => ({ - appearance: { - variables: { - // corresponds to var(--font-family-base) - fontFamily: - "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'", - gridRowSpacing: '16px', - borderRadius: '3px', - }, - - rules: { - '.Label': { - marginBottom: '8px', - fontWeight: '500', - color: theme === Theme.Light ? '#343a4d' : '#dbe2f0', - lineHeight: '20px', - fontSize: '14px', - }, - '.Input': { - backgroundColor: theme === Theme.Light ? '#ffffff' : '#1d212f', - color: theme === Theme.Light ? '#262b38' : '#dbe2f0', - paddingTop: '6px', - paddingBottom: '6px', - borderColor: theme === Theme.Light ? '#dbe2f0' : '#343a4d', - boxShadow: 'none', - lineHeight: '20px', - fontSize: '14px', - }, - '.Input:focus': { - borderColor: '#0b70db', - boxShadow: `0 0 0 0.125rem ${theme === Theme.Light ? '#a3d0ff' : '#0f59aa'}`, - }, - }, - }, - }), - [theme] - ) -} - -const BillingAddress: React.FC = props => { - const options = useBillingAddressStripeElementsOptions() - const [isEditMode, setIsEditMode] = useState(false) - - return ( -
-
-

Billing address

- -
- - {isEditMode ? ( - - setIsEditMode(false)} - onSubmit={() => setIsEditMode(false)} - /> - - ) : ( - - )} -
- ) -} - -const ActiveBillingAddress: React.FC<{ subscription: Subscription }> = ({ subscription }) => ( -
-
- - Full name - - {subscription.name} -
- -
- - Country or region - - {subscription.address.country || '-'} -
- -
- - Address line 1 - - {subscription.address.line1 || '-'} -
- -
- - Address line 2 - - {subscription.address.line2 || '-'} -
- -
- - City - - {subscription.address.city || '-'} -
- -
- - State - - {subscription.address.state || '-'} -
- -
- - Postal code - - {subscription.address.postalCode || '-'} -
-
-) - -interface BillingAddressFormProps extends PaymentDetailsProps { - onReset: () => void - onSubmit: () => void -} - -const BillingAddressForm: React.FC = props => { - const stripe = useStripe() - const elements = useElements() - - const updateCurrentSubscriptionMutation = useUpdateCurrentSubscription() - - const [isStripeLoading, setIsStripeLoading] = useState(false) - const [stripeErrorMessage, setStripeErrorMessage] = useState('') - - const [isErrorVisible, setIsErrorVisible] = useState(true) - - const isLoading = isStripeLoading || updateCurrentSubscriptionMutation.isPending - const errorMessage = - stripeErrorMessage || updateCurrentSubscriptionMutation.isError ? updateSubscriptionMutationErrorText : '' - - useEffect(() => { - if (errorMessage) { - setIsErrorVisible(true) - } - }, [errorMessage]) - - const handleSubmit: React.FormEventHandler = async (event): Promise => { - event.preventDefault() - - setStripeErrorMessage('') - - if (!stripe || !elements) { - return setStripeErrorMessage('Stripe or Stripe Elements libraries are not available.') - } - - const addressElement = elements.getElement(AddressElement) - if (!addressElement) { - return setStripeErrorMessage('Stripe address element was not found.') - } - - setIsStripeLoading(true) - const addressElementValue = await addressElement.getValue() - setIsStripeLoading(false) - if (!addressElementValue.complete) { - return setStripeErrorMessage('Address is not complete.') - } - - const { line1, line2, postal_code, city, state, country } = addressElementValue.value.address - updateCurrentSubscriptionMutation.mutate( - { - customerUpdate: { - newName: addressElementValue.value.name, - newAddress: { - line1, - line2: line2 || '', - postalCode: postal_code, - city, - state, - country, - }, - }, - }, - { onSuccess: props.onSubmit } - ) - } - - const options = useMemo( - (): StripeAddressElementOptions => ({ - mode: 'billing', - display: { name: 'full' }, - defaultValues: { - name: props.subscription.name, - address: { - ...props.subscription.address, - postal_code: props.subscription.address.postalCode, - }, - }, - }), - [props.subscription] - ) - - return ( - - setIsErrorVisible(false)} /> - - {isErrorVisible && errorMessage ? {errorMessage} : null} - -
- - - Save - -
- - ) -} diff --git a/client/web/src/cody/management/subscription/manage/SubscriptionDetails.tsx b/client/web/src/cody/management/subscription/manage/SubscriptionDetails.tsx index c7480b86e80e..090c62f9718f 100644 --- a/client/web/src/cody/management/subscription/manage/SubscriptionDetails.tsx +++ b/client/web/src/cody/management/subscription/manage/SubscriptionDetails.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import React, { useEffect, useState } from 'react' import { mdiCancel, mdiCheck, mdiRefresh } from '@mdi/js' import classNames from 'classnames' @@ -112,7 +112,7 @@ export const SubscriptionDetails: React.FC = props => Pro features after {humanizeDate(props.subscription.currentPeriodEnd)}. - Do you want to procceed? + Do you want to proceed?
diff --git a/client/web/src/cody/management/subscription/new/CodyProCheckoutForm.tsx b/client/web/src/cody/management/subscription/new/CodyProCheckoutForm.tsx index b0e13196d534..393e9834efb5 100644 --- a/client/web/src/cody/management/subscription/new/CodyProCheckoutForm.tsx +++ b/client/web/src/cody/management/subscription/new/CodyProCheckoutForm.tsx @@ -1,7 +1,9 @@ -import React, { useEffect } from 'react' +import React from 'react' import { mdiMinusThick, mdiPlusThick } from '@mdi/js' -import { useCustomCheckout, PaymentElement, AddressElement } from '@stripe/react-stripe-js' +import { AddressElement, useStripe, useElements, CardNumberElement } from '@stripe/react-stripe-js' +import type { Stripe, StripeCardNumberElement } from '@stripe/stripe-js' +import type { StripeAddressElementChangeEvent } from '@stripe/stripe-js/dist/stripe-js/elements/address' import classNames from 'classnames' import { useNavigate } from 'react-router-dom' @@ -17,76 +19,141 @@ import { Icon, Input, Label, - LoadingSpinner, H3, - useDebounce, + LoadingSpinner, } from '@sourcegraph/wildcard' import { CodyAlert } from '../../../components/CodyAlert' - -import { PayButton } from './PayButton' +import { useCreateTeam } from '../../api/react-query/subscriptions' +import { StripeAddressElement } from '../StripeAddressElement' +import { StripeCardDetails } from '../StripeCardDetails' import styles from './NewCodyProSubscriptionPage.module.scss' const MIN_SEAT_COUNT = 1 const MAX_SEAT_COUNT = 50 -export const CodyProCheckoutForm: React.FunctionComponent<{ - creatingTeam: boolean +interface CodyProCheckoutFormProps { + initialSeatCount: number customerEmail: string | undefined -}> = ({ creatingTeam, customerEmail }) => { +} + +async function createStripeToken( + stripe: Stripe, + cardNumberElement: StripeCardNumberElement, + suppliedAddress: StripeAddressElementChangeEvent['value']['address'] +): Promise { + let response + try { + // Note that Stripe may have returned an error response. + response = await stripe.createToken(cardNumberElement, { + // We send the address data along with the card info to let Stripe do more validation such as + // confirming the zip code matches the card's. Later, we'll also store this as the Customer's address. + address_line1: suppliedAddress.line1, + address_line2: suppliedAddress.line2 || '', + address_city: suppliedAddress.city, + address_state: suppliedAddress.state, + address_zip: suppliedAddress.postal_code, + address_country: suppliedAddress.country, + currency: 'usd', + }) + } catch (error) { + throw new Error(`We couldn't create the team. This is what happened: ${error}`) + } + if (response.error) { + throw new Error(response.error.message ?? 'We got an unknown error from Stripe.') + } + const tokenId = response.token?.id + if (!tokenId) { + throw new Error('Stripe token not found.') + } + return tokenId +} + +export const CodyProCheckoutForm: React.FunctionComponent = ({ + initialSeatCount, + customerEmail, +}) => { + const stripe = useStripe() + const elements = useElements() const navigate = useNavigate() - const { total, lineItems, updateLineItemQuantity, email, updateEmail, status } = useCustomCheckout() + const isTeam = initialSeatCount > 1 const [errorMessage, setErrorMessage] = React.useState(null) - const [updatingSeatCount, setUpdatingSeatCount] = React.useState(false) - const [seatCount, setSeatCount] = React.useState(lineItems[0]?.quantity) - const debouncedSeatCount = useDebounce(seatCount, 800) - const firstLineItemId = lineItems[0]?.id - - useEffect(() => { - const updateSeatCount = async (): Promise => { - setUpdatingSeatCount(true) - try { - await updateLineItemQuantity({ - lineItem: firstLineItemId, - quantity: debouncedSeatCount, - }) - } catch { - setErrorMessage('Failed to update seat count. Please change the number of seats to try again.') - } - setUpdatingSeatCount(false) - } + const [seatCount, setSeatCount] = React.useState(initialSeatCount) + const [submitting, setSubmitting] = React.useState(false) - void updateSeatCount() - }, [firstLineItemId, debouncedSeatCount, updateLineItemQuantity]) + // N * $9. We expect this to be more complex later with annual plans, etc. + const total = seatCount * 9 - const isPriceLoading = seatCount !== debouncedSeatCount || updatingSeatCount + const createTeamMutation = useCreateTeam() - // Set initial seat count. - useEffect(() => { - if (lineItems.length === 1) { - setSeatCount(lineItems[0].quantity) + const handleSubmit = async (event: React.FormEvent): Promise => { + event.preventDefault() + + if (!stripe || !elements) { + setErrorMessage('Stripe or Stripe Elements libraries not available.') + return + } + const cardNumberElement = elements.getElement(CardNumberElement) + if (!cardNumberElement) { + setErrorMessage('CardNumberElement not found.') + return } - }, [lineItems]) + const addressElement = elements.getElement(AddressElement) + if (!addressElement) { + setErrorMessage('AddressElement not found.') + return + } + const addressElementValue = await addressElement.getValue() + if (!addressElementValue.complete) { + setErrorMessage('Please fill out your billing address.') + return + } + + const suppliedAddress = addressElementValue.value.address - // Set customer email to initial value. - useEffect(() => { - if (customerEmail) { - updateEmail(customerEmail) + setSubmitting(true) + + let token + try { + token = await createStripeToken(stripe, cardNumberElement, suppliedAddress) + } catch (error) { + setErrorMessage(error) + setSubmitting(false) + return } - }, [customerEmail, updateEmail]) - // Redirect once we're done. - // Display an error message if the session is expired. - useEffect(() => { - if (status.type === 'complete') { + // This is where we send the token to the backend to create a subscription. + try { + // Even though .mutate is recommended (https://tkdodo.eu/blog/mastering-mutations-in-react-query#mutate-or-mutateasync), + // this use makes it very convenient to just have a linear flow with error handling and a redirect at the end. + await createTeamMutation.mutateAsync({ + name: '(no name yet)', + slug: '(no slug yet)', + seats: seatCount, + address: { + line1: suppliedAddress.line1, + line2: suppliedAddress.line2 || '', + city: suppliedAddress.city, + state: suppliedAddress.state, + postalCode: suppliedAddress.postal_code, + country: suppliedAddress.country, + }, + billingInterval: 'monthly', + couponCode: '', + creditCardToken: token, + }) + navigate('/cody/manage?welcome=1') - } else if (status.type === 'expired') { - setErrorMessage('Session expired. Please refresh the page.') + + setSubmitting(false) + } catch (error) { + setErrorMessage(`We couldn't create the Stripe token. This is what happened: ${error}`) + setSubmitting(false) } - }, [navigate, status.type]) + } return ( <> @@ -102,7 +169,7 @@ export const CodyProCheckoutForm: React.FunctionComponent<{
-

{creatingTeam ? 'Add seats' : 'Select number of seats'}

+

{isTeam ? 'Add seats' : 'Select number of seats'}

$9 per seat / month
+
By clicking the button, you agree to the{' '} diff --git a/client/web/src/cody/management/subscription/new/CodyProCheckoutFormContainer.tsx b/client/web/src/cody/management/subscription/new/CodyProCheckoutFormContainer.tsx deleted file mode 100644 index cac35624529b..000000000000 --- a/client/web/src/cody/management/subscription/new/CodyProCheckoutFormContainer.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import React, { useMemo } from 'react' - -import { CustomCheckoutProvider } from '@stripe/react-stripe-js' -import type { Stripe } from '@stripe/stripe-js' -import { useSearchParams } from 'react-router-dom' - -import { useTheme, Theme } from '@sourcegraph/shared/src/theme' -import { H3, LoadingSpinner, Text } from '@sourcegraph/wildcard' - -import { Client } from '../../api/client' -import { useApiCaller } from '../../api/hooks/useApiClient' -import type { CreateCheckoutSessionRequest } from '../../api/types' - -import { CodyProCheckoutForm } from './CodyProCheckoutForm' - -export const CodyProCheckoutFormContainer: React.FunctionComponent<{ - stripe: Stripe | null - initialSeatCount: number - customerEmail: string | undefined -}> = ({ stripe, initialSeatCount, customerEmail }) => { - const [urlSearchParams] = useSearchParams() - // Optionally support the "showCouponCodeAtCheckout" URL query parameter, which, if present, - // will display a "promotional code" element in the Stripe Checkout UI. - const showPromoCodeField = urlSearchParams.get('showCouponCodeAtCheckout') !== null - - const { theme } = useTheme() - - // Make the API call to create the Stripe Checkout session. - const createStripeCheckoutSessionCall = useMemo(() => { - const requestBody: CreateCheckoutSessionRequest = { - interval: 'monthly', - seats: initialSeatCount, - canChangeSeatCount: true, // Seat count is always adjustable. - customerEmail, - - showPromoCodeField, - - // URL the user is redirected to when the checkout process is complete. - // - // CHECKOUT_SESSION_ID will be replaced by Stripe with the correct value, - // when the user finishes the Stripe-hosted checkout form. - // - // BUG: Due to the race conditions between Stripe, the SSC backend, - // and Sourcegraph.com, immediately loading the Dashboard page isn't - // going to show the right data reliably. We will need to instead show - // some prompt, to give the backends an opportunity to sync. - returnUrl: `${origin}/cody/manage?session_id={CHECKOUT_SESSION_ID}&welcome=1`, - stripeUiMode: 'custom', - } - return Client.createStripeCheckoutSession(requestBody) - }, [customerEmail, initialSeatCount, showPromoCodeField]) - const { loading, error, data } = useApiCaller(createStripeCheckoutSessionCall) - - // Show a spinner while we wait for the Checkout session to be created. - if (loading) { - return - } - - // Error page if we aren't able to show the Checkout session. - if (error) { - return ( -
-

Awe snap!

- There was an error creating the checkout session: {error.message} -
- ) - } - - return ( -
- {data?.clientSecret && ( - - 1} customerEmail={customerEmail} /> - - )} -
- ) -} diff --git a/client/web/src/cody/management/subscription/new/NewCodyProSubscriptionPage.tsx b/client/web/src/cody/management/subscription/new/NewCodyProSubscriptionPage.tsx index 33b0c52f64ba..5c35547d37e3 100644 --- a/client/web/src/cody/management/subscription/new/NewCodyProSubscriptionPage.tsx +++ b/client/web/src/cody/management/subscription/new/NewCodyProSubscriptionPage.tsx @@ -1,10 +1,11 @@ import { useEffect, type FunctionComponent, useMemo } from 'react' +import { Elements } from '@stripe/react-stripe-js' // NOTE: A side effect of loading this library will update the DOM and // fetch stripe.js. This is a subtle detail but means that the Stripe // functionality won't be loaded until this actual module does, via // the lazily loaded router module. -import * as stripeJs from '@stripe/stripe-js' +import { loadStripe } from '@stripe/stripe-js' import classNames from 'classnames' import { Navigate, useSearchParams } from 'react-router-dom' @@ -24,14 +25,15 @@ import { import { WhiteIcon } from '../../../components/WhiteIcon' import { USER_CODY_PLAN } from '../../../subscription/queries' import { defaultCodyProApiClientContext, CodyProApiClientContext } from '../../api/components/CodyProApiClient' +import { useBillingAddressStripeElementsOptions } from '../manage/BillingAddress' -import { CodyProCheckoutFormContainer } from './CodyProCheckoutFormContainer' +import { CodyProCheckoutForm } from './CodyProCheckoutForm' // NOTE: Call loadStripe outside a component’s render to avoid recreating the object. // We do it here, meaning that "stripe.js" will get loaded lazily, when the user // routes to this page. const publishableKey = window.context.frontendCodyProConfig?.stripePublishableKey -const stripe = await stripeJs.loadStripe(publishableKey || '', { betas: ['custom_checkout_beta_2'] }) +const stripe = await loadStripe(publishableKey || '') interface NewCodyProSubscriptionPageProps extends TelemetryV2Props { authenticatedUser: AuthenticatedUser @@ -51,6 +53,8 @@ const AuthenticatedNewCodyProSubscriptionPage: FunctionComponent { telemetryRecorder.recordEvent('cody.new-subscription-checkout', 'view') }, [telemetryRecorder]) @@ -77,11 +81,12 @@ const AuthenticatedNewCodyProSubscriptionPage: FunctionComponent - + + + ) diff --git a/client/web/src/cody/management/subscription/new/PayButton.tsx b/client/web/src/cody/management/subscription/new/PayButton.tsx deleted file mode 100644 index 3e628ce51a3d..000000000000 --- a/client/web/src/cody/management/subscription/new/PayButton.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import React, { useCallback, type MouseEventHandler } from 'react' - -import { useCustomCheckout } from '@stripe/react-stripe-js' - -import { Button, LoadingSpinner } from '@sourcegraph/wildcard' - -import styles from './NewCodyProSubscriptionPage.module.scss' - -interface PayButtonProps { - setErrorMessage: (message: string) => void - className?: string - children: React.ReactNode -} - -export const PayButton: React.FunctionComponent = ({ - setErrorMessage, - className, - children, - ...props -}) => { - const { confirm, canConfirm, confirmationRequirements } = useCustomCheckout() - const [loading, setLoading] = React.useState(false) - - const handleClick: MouseEventHandler = useCallback(async () => { - if (!canConfirm) { - if (confirmationRequirements.includes('paymentDetails')) { - setErrorMessage('Please fill out your payment details') - } else if (confirmationRequirements.includes('billingAddress')) { - setErrorMessage('Please fill out your billing address') - } else { - setErrorMessage('Please fill out all required fields') - } - return - } - setLoading(true) - try { - const result = await confirm() - if (result.error?.message) { - setErrorMessage(result.error.message) - } - setLoading(false) - } catch (error) { - setErrorMessage(error) - setLoading(false) - } - }, [canConfirm, confirm, confirmationRequirements, setErrorMessage]) - - return ( - // eslint-disable-next-line no-restricted-syntax - - ) -}