diff --git a/static/gsApp/components/organizationSubscriptionContext.tsx b/static/gsApp/components/organizationSubscriptionContext.tsx new file mode 100644 index 00000000000000..69776e808ded0d --- /dev/null +++ b/static/gsApp/components/organizationSubscriptionContext.tsx @@ -0,0 +1,19 @@ +import type React from 'react'; + +import OrganizationContainer from 'sentry/views/organizationContainer'; + +import SubscriptionContext from 'getsentry/components/subscriptionContext'; + +type Props = { + children: React.JSX.Element; +}; + +function OrganizationSubscriptionContext({children}: Props) { + return ( + + {children} + + ); +} + +export default OrganizationSubscriptionContext; diff --git a/static/gsApp/hooks/rootRoutes.tsx b/static/gsApp/hooks/rootRoutes.tsx index ecae9c4800904b..d51d1db1175ff2 100644 --- a/static/gsApp/hooks/rootRoutes.tsx +++ b/static/gsApp/hooks/rootRoutes.tsx @@ -1,5 +1,26 @@ import type {SentryRouteObject} from 'sentry/components/route'; +import {makeLazyloadComponent as make} from 'sentry/makeLazyloadComponent'; +import errorHandler from 'sentry/utils/errorHandler'; -const rootRoutes = (): SentryRouteObject => ({}); +import OrganizationSubscriptionContext from 'getsentry/components/organizationSubscriptionContext'; + +const rootRoutes = (): SentryRouteObject => ({ + children: [ + { + // TODO(checkout v3): change this to the correct path (/settings/billing/checkout/) + // when GA'd + path: '/checkout-v3/', + component: errorHandler(OrganizationSubscriptionContext), + deprecatedRouteProps: true, + customerDomainOnlyRoute: true, + children: [ + { + index: true, + component: make(() => import('getsentry/views/decideCheckout')), + }, + ], + }, + ], +}); export default rootRoutes; diff --git a/static/gsApp/hooks/settingsRoutes.tsx b/static/gsApp/hooks/settingsRoutes.tsx index 69228fc3946ade..ce63fcec0eddeb 100644 --- a/static/gsApp/hooks/settingsRoutes.tsx +++ b/static/gsApp/hooks/settingsRoutes.tsx @@ -23,6 +23,7 @@ const settingsRoutes = (): SentryRouteObject => ({ redirectTo: 'overview/', }, { + // TODO(checkout v3): This should be removed when checkout v3 is GA'd path: 'checkout/', name: 'Change', component: errorHandler(SubscriptionContext), diff --git a/static/gsApp/views/amCheckout/cart.spec.tsx b/static/gsApp/views/amCheckout/cart.spec.tsx index c05776e5838e0f..c236b09fa941ed 100644 --- a/static/gsApp/views/amCheckout/cart.spec.tsx +++ b/static/gsApp/views/amCheckout/cart.spec.tsx @@ -23,6 +23,7 @@ describe('Cart', () => { const billingConfig = BillingConfigFixture(PlanTier.AM3); const props = { ...routerProps, + navigate: jest.fn(), isNewCheckout: true, }; const businessPlan = PlanDetailsLookupFixture('am3_business')!; diff --git a/static/gsApp/views/amCheckout/checkoutOverview.spec.tsx b/static/gsApp/views/amCheckout/checkoutOverview.spec.tsx index c980315f3f067b..42306f0e12515f 100644 --- a/static/gsApp/views/amCheckout/checkoutOverview.spec.tsx +++ b/static/gsApp/views/amCheckout/checkoutOverview.spec.tsx @@ -16,7 +16,6 @@ describe('CheckoutOverview', () => { const api = new MockApiClient(); const {organization, routerProps} = initializeOrg(); const subscription = SubscriptionFixture({organization, plan: 'am1_f'}); - const params = {}; const billingConfig = BillingConfigFixture(PlanTier.AM2); const teamPlanAnnual = PlanDetailsLookupFixture('am1_team_auf')!; @@ -52,7 +51,7 @@ describe('CheckoutOverview', () => { api={api} checkoutTier={PlanTier.AM2} onToggleLegacy={jest.fn()} - params={params} + navigate={jest.fn()} /> ); @@ -86,7 +85,7 @@ describe('CheckoutOverview', () => { checkoutTier={PlanTier.AM1} location={LocationFixture({hash: '#step3'})} onToggleLegacy={jest.fn()} - params={params} + navigate={jest.fn()} /> ); diff --git a/static/gsApp/views/amCheckout/checkoutOverviewV2.spec.tsx b/static/gsApp/views/amCheckout/checkoutOverviewV2.spec.tsx index 93e7a146568a98..492b0875c9591f 100644 --- a/static/gsApp/views/amCheckout/checkoutOverviewV2.spec.tsx +++ b/static/gsApp/views/amCheckout/checkoutOverviewV2.spec.tsx @@ -14,7 +14,6 @@ describe('CheckoutOverviewV2', () => { const api = new MockApiClient(); const {organization, routerProps} = initializeOrg(); const subscription = SubscriptionFixture({organization, plan: 'am3_f'}); - const params = {}; const billingConfig = BillingConfigFixture(PlanTier.AM3); const teamPlanAnnual = PlanDetailsLookupFixture('am3_team_auf')!; @@ -50,7 +49,7 @@ describe('CheckoutOverviewV2', () => { api={api} checkoutTier={PlanTier.AM3} onToggleLegacy={jest.fn()} - params={params} + navigate={jest.fn()} /> ); diff --git a/static/gsApp/views/amCheckout/index.spec.tsx b/static/gsApp/views/amCheckout/index.spec.tsx index 2eb3b4644e7361..aedfbf4809fda4 100644 --- a/static/gsApp/views/amCheckout/index.spec.tsx +++ b/static/gsApp/views/amCheckout/index.spec.tsx @@ -29,7 +29,6 @@ describe('AM1 Checkout', () => { const api = new MockApiClient(); const organization = OrganizationFixture({features: []}); const subscription = SubscriptionFixture({organization}); - const params = {}; beforeEach(() => { SubscriptionStore.set(organization.slug, subscription); @@ -62,7 +61,7 @@ describe('AM1 Checkout', () => { , @@ -92,7 +91,7 @@ describe('AM1 Checkout', () => { , @@ -123,7 +122,7 @@ describe('AM1 Checkout', () => { render( { render( { render( { const {container} = render( { const {container} = render( { render( { render( { render( { render( { render( { render( { render( { render( { const api = new MockApiClient(); const organization = OrganizationFixture(); const subscription = SubscriptionFixture({organization}); - const params = {}; beforeEach(() => { SubscriptionStore.set(organization.slug, subscription); @@ -675,7 +673,7 @@ describe('AM2 Checkout', () => { render( { render( { render( { render( { render( { render( { render( { render( { render( { render( { render( { render( { render( { render( { const organization = OrganizationFixture({ features: ['ondemand-budgets', 'am3-billing'], }); - const params = {}; beforeEach(() => { MockApiClient.clearMockResponses(); @@ -1391,7 +1388,7 @@ describe('AM3 Checkout', () => { render( { render( { render( { render( { render( { render( { render( { render( { render( { render( { render( void; organization: Organization; queryClient: QueryClient; @@ -100,7 +107,7 @@ type Props = { isNewCheckout?: boolean; promotionData?: PromotionData; refetch?: () => Promise>; -} & RouteComponentProps, unknown>; +}; type State = { billingConfig: BillingConfig | null; @@ -122,6 +129,12 @@ class AMCheckout extends Component { ) { props.onToggleLegacy(props.subscription.planTier); } + // TODO(checkout v3): remove these checks once checkout v3 is GA'd + if (props.location?.pathname.includes('checkout-v3') && !props.isNewCheckout) { + props.navigate(`/settings/${props.organization.slug}/billing/checkout/`); + } else if (!props.location?.pathname.includes('checkout-v3') && props.isNewCheckout) { + props.navigate(`/checkout-v3/`); + } let step = 1; if (props.location?.hash) { const stepMatch = /^#step(\d)$/.exec(props.location.hash); @@ -205,7 +218,10 @@ class AMCheckout extends Component { get referrer(): string | undefined { const {location} = this.props; - return location?.query?.referrer; + if (Array.isArray(location?.query?.referrer)) { + return location?.query?.referrer[0]; + } + return location?.query?.referrer ?? undefined; } /** @@ -213,8 +229,8 @@ class AMCheckout extends Component { * changes to their plan and cannot use the self-serve checkout flow */ handleRedirect() { - const {organization, router} = this.props; - return router.push(normalizeUrl(`/settings/${organization.slug}/billing/overview/`)); + const {organization, navigate} = this.props; + return navigate(normalizeUrl(`/settings/${organization.slug}/billing/overview/`)); } async fetchBillingConfig() { @@ -719,6 +735,19 @@ class AMCheckout extends Component { ); } + // TODO(checkout v3): remove this once checkout v3 is GA'd + renderParentComponent({children}: {children: React.ReactNode}) { + const {isNewCheckout} = this.props; + if (isNewCheckout) { + return ( + + {children} + + ); + } + return children; + } + render() { const { subscription, @@ -727,6 +756,7 @@ class AMCheckout extends Component { promotionData, checkoutTier, isNewCheckout, + navigate, } = this.props; const {loading, error, formData, billingConfig} = this.state; @@ -771,112 +801,141 @@ class AMCheckout extends Component { const isOnSponsoredPartnerPlan = (subscription.partner?.isActive && subscription.isSponsored) || false; - return ( - - - {isOnSponsoredPartnerPlan && ( - - - {t( - 'Your promotional plan with %s ends on %s.', - subscription.partner?.partnership.displayName, - moment(subscription.contractPeriodEnd).format('ll') - )} - - - )} - {promotionDisclaimerText && ( - - {promotionDisclaimerText} - - )} - {!isNewCheckout && ( - + - )} - - -
- {this.renderPartnerAlert()} - - {this.renderSteps()} - -
- - - {isNewCheckout ? ( - - ) : checkoutTier === PlanTier.AM3 ? ( - - ) : ( - + {isNewCheckout && ( + + + + + + )} + {isOnSponsoredPartnerPlan && ( + + + {t( + 'Your promotional plan with %s ends on %s.', + subscription.partner?.partnership.displayName, + moment(subscription.contractPeriodEnd).format('ll') + )} + + + )} + {promotionDisclaimerText && ( + + {promotionDisclaimerText} + + )} + {!isNewCheckout && ( + + )} + +
+ {isNewCheckout && ( + + { + navigate(`/settings/${organization.slug}/billing/`); + }} + > + + + {t('Back')} + + + )} - - {t('Have a question?')} - - {tct('[help:Find an Answer] or [contact:Ask Support]', { - help: ( - - ), - contact: , - })} - - - - {discountInfo?.disclaimerText} - - {subscription.canCancel && ( - - - {subscription.cancelAtPeriodEnd - ? t('Pending Cancellation') - : t('Cancel Subscription')} - - - )} - {showAnnualTerms && ( - - {tct( - `Annual subscriptions require a one-year non-cancellable commitment. - By using Sentry you agree to our [terms: Terms of Service].`, - {terms: } + {this.renderPartnerAlert()} + + {this.renderSteps()} + +
+ + + {isNewCheckout ? ( + + ) : checkoutTier === PlanTier.AM3 ? ( + + ) : ( + )} - - )} - -
-
- ); + + {t('Have a question?')} + + {tct('[help:Find an Answer] or [contact:Ask Support]', { + help: ( + + ), + contact: ( + + ), + })} + + + + {discountInfo?.disclaimerText} + + {subscription.canCancel && ( + + + {subscription.cancelAtPeriodEnd + ? t('Pending Cancellation') + : t('Cancel Subscription')} + + + )} + {showAnnualTerms && ( + + {tct( + `Annual subscriptions require a one-year non-cancellable commitment. + By using Sentry you agree to our [terms: Terms of Service].`, + {terms: } + )} + + )} + + + + ), + }); } } -const CheckoutContainer = styled('div')<{isNewCheckout: boolean}>` - display: grid; - gap: ${p => p.theme.space['2xl']}; - grid-template-columns: 3fr 2fr; - - @media (max-width: ${p => - p.isNewCheckout ? p.theme.breakpoints.md : p.theme.breakpoints.lg}) { - grid-template-columns: auto; - } +const BackButton = styled(Button)` + align-self: flex-start; + padding: 0; `; const SidePanel = styled('div')` diff --git a/static/gsApp/views/amCheckout/steps/addBillingDetails.spec.tsx b/static/gsApp/views/amCheckout/steps/addBillingDetails.spec.tsx index 0cb001418131aa..917c0c4923141d 100644 --- a/static/gsApp/views/amCheckout/steps/addBillingDetails.spec.tsx +++ b/static/gsApp/views/amCheckout/steps/addBillingDetails.spec.tsx @@ -23,7 +23,6 @@ describe('Billing Details Step', () => { }); const subscription = SubscriptionFixture({organization}); - const params = {}; const billingDetails = BillingDetailsFixture({addressType: null}); const stepNumber = 6; @@ -101,7 +100,7 @@ describe('Billing Details Step', () => { api={api} checkoutTier={PlanTier.AM2} onToggleLegacy={jest.fn()} - params={params} + navigate={jest.fn()} /> ); diff --git a/static/gsApp/views/amCheckout/steps/addDataVolume.spec.tsx b/static/gsApp/views/amCheckout/steps/addDataVolume.spec.tsx index 8c7d236b98e00b..d8f5a7da360916 100644 --- a/static/gsApp/views/amCheckout/steps/addDataVolume.spec.tsx +++ b/static/gsApp/views/amCheckout/steps/addDataVolume.spec.tsx @@ -58,7 +58,6 @@ describe('AddDataVolume for legacy plans', () => { const api = new MockApiClient(); const {organization, routerProps} = initializeOrg(); const subscription = SubscriptionFixture({organization}); - const params = {}; const billingConfig = BillingConfigFixture(PlanTier.AM2); const bizPlan = PlanDetailsLookupFixture('am1_business')!; @@ -114,7 +113,7 @@ describe('AddDataVolume for legacy plans', () => { api={api} checkoutTier={PlanTier.AM2} onToggleLegacy={jest.fn()} - params={params} + navigate={jest.fn()} /> ); @@ -133,7 +132,7 @@ describe('AddDataVolume for legacy plans', () => { api={api} checkoutTier={PlanTier.AM2} onToggleLegacy={jest.fn()} - params={params} + navigate={jest.fn()} /> ); @@ -293,7 +292,7 @@ describe('AddDataVolume for legacy plans', () => { api={api} checkoutTier={PlanTier.AM2} onToggleLegacy={jest.fn()} - params={params} + navigate={jest.fn()} /> ); const panel = await screen.findByTestId('step-add-data-volume'); @@ -363,7 +362,6 @@ describe('AddDataVolume for modern plans', () => { const api = new MockApiClient(); const {organization, routerProps} = initializeOrg(); const subscription = SubscriptionFixture({organization}); - const params = {}; const billingConfig = BillingConfigFixture(PlanTier.AM3); const bizPlan = PlanDetailsLookupFixture('am3_business')!; @@ -418,7 +416,7 @@ describe('AddDataVolume for modern plans', () => { api={api} checkoutTier={PlanTier.AM3} onToggleLegacy={jest.fn()} - params={params} + navigate={jest.fn()} /> ); @@ -437,7 +435,7 @@ describe('AddDataVolume for modern plans', () => { api={api} checkoutTier={PlanTier.AM3} onToggleLegacy={jest.fn()} - params={params} + navigate={jest.fn()} /> ); @@ -596,7 +594,7 @@ describe('AddDataVolume for modern plans', () => { api={api} checkoutTier={PlanTier.AM3} onToggleLegacy={jest.fn()} - params={params} + navigate={jest.fn()} /> ); const panel = await screen.findByTestId('step-add-data-volume'); diff --git a/static/gsApp/views/amCheckout/steps/addPaymentMethod.spec.tsx b/static/gsApp/views/amCheckout/steps/addPaymentMethod.spec.tsx index a1319b77ea8400..1b1ea187e53293 100644 --- a/static/gsApp/views/amCheckout/steps/addPaymentMethod.spec.tsx +++ b/static/gsApp/views/amCheckout/steps/addPaymentMethod.spec.tsx @@ -22,7 +22,6 @@ describe('AddPaymentMethod', () => { const api = new MockApiClient(); const organization = OrganizationFixture(); const subscription = SubscriptionFixture({organization}); - const params = {}; let setupIntent!: jest.Mock; const stepNumber = 5; @@ -87,7 +86,7 @@ describe('AddPaymentMethod', () => { render( { checkoutTier={PlanTier.AM3} isNewCheckout={isNewCheckout} location={location} + navigate={jest.fn()} />, {organization} ); diff --git a/static/gsApp/views/amCheckout/steps/contractSelect.spec.tsx b/static/gsApp/views/amCheckout/steps/contractSelect.spec.tsx index 7f5909453f8615..ad6df4553cf6fe 100644 --- a/static/gsApp/views/amCheckout/steps/contractSelect.spec.tsx +++ b/static/gsApp/views/amCheckout/steps/contractSelect.spec.tsx @@ -19,7 +19,6 @@ describe('ContractSelect', () => { contractPeriodStart: '2025-07-16', contractPeriodEnd: '2025-08-15', }); - const params = {}; const warningText = /You are currently on an annual contract/; @@ -27,7 +26,7 @@ describe('ContractSelect', () => { return render( { const createWrapper = ({subscription}: {subscription: SubscriptionType}) => { SubscriptionStore.set(organization.slug, subscription); - const params = {}; return render( { const api = new MockApiClient(); const organization = OrganizationFixture(); const subscription = SubscriptionFixture({organization}); - const params = {}; const stepBody = /On-Demand spend allows you to pay for additional data/; @@ -74,7 +73,7 @@ describe('OnDemandSpend', () => { render( { render( { render( { render( { const api = new MockApiClient(); const organization = OrganizationFixture(); const subscription = SubscriptionFixture({organization}); - const params = {}; beforeEach(() => { SubscriptionStore.set(organization.slug, subscription); @@ -67,7 +66,7 @@ describe('PlanSelect', () => { render( { render( { render( { render( { render( { render( { render( { render( { render( { render( { render( { render( { render( { render( { render( { render( { render( { render( { render( { const api = new MockApiClient(); const organization = OrganizationFixture(); const subscription = SubscriptionFixture({organization}); - const params = {}; beforeEach(() => { MockApiClient.clearMockResponses(); @@ -66,7 +65,7 @@ describe('ProductSelect', () => { render( { render( { render( { render( { render( { render( { render( { render( ReviewAndConfirm', () => { const api = new MockApiClient(); const organization = OrganizationFixture(); const subscription = SubscriptionFixture({organization}); - const params = {}; const bizPlan = PlanDetailsLookupFixture('am1_business')!; const billingConfig = BillingConfigFixture(PlanTier.AM2); @@ -141,7 +140,7 @@ describe('AmCheckout > ReviewAndConfirm', () => { render( { const organization = OrganizationFixture({ features: ['ondemand-budgets', 'am3-billing'], }); - const params = {}; const stepBody = /Pay-as-you-go applies across all Sentry products, on a first-come, first-served basis./; @@ -60,7 +59,7 @@ describe('SetPayAsYouGo', () => { render( { render( { render( { render( { render( { render( { render( { render( { checkoutTier={PlanTier.AM3} onToggleLegacy={jest.fn()} isNewCheckout + navigate={jest.fn()} /> ); @@ -102,6 +103,7 @@ describe('SetSpendCap', () => { checkoutTier={PlanTier.AM2} onToggleLegacy={jest.fn()} isNewCheckout + navigate={jest.fn()} /> ); diff --git a/static/gsApp/views/decideCheckout.tsx b/static/gsApp/views/decideCheckout.tsx index 7060bab565503e..00446b814dd12a 100644 --- a/static/gsApp/views/decideCheckout.tsx +++ b/static/gsApp/views/decideCheckout.tsx @@ -1,7 +1,8 @@ import {useState} from 'react'; import ErrorBoundary from 'sentry/components/errorBoundary'; -import type {RouteComponentProps} from 'sentry/types/legacyReactRouter'; +import {useLocation} from 'sentry/utils/useLocation'; +import {useNavigate} from 'sentry/utils/useNavigate'; import useOrganization from 'sentry/utils/useOrganization'; import {PlanTier} from 'getsentry/types'; @@ -9,17 +10,18 @@ import {hasPartnerMigrationFeature} from 'getsentry/utils/billing'; import AMCheckout from 'getsentry/views/amCheckout'; import {hasCheckoutV3} from 'getsentry/views/amCheckout/utils'; -interface Props extends RouteComponentProps, unknown> {} - -function DecideCheckout(props: Props) { +function DecideCheckout() { + const navigate = useNavigate(); + const location = useLocation(); const organization = useOrganization(); const [tier, setTier] = useState(null); const checkoutProps = { - ...props, organization, onToggleLegacy: setTier, isNewCheckout: hasCheckoutV3(organization), + location, + navigate, }; const hasAm3Feature = organization.features?.includes('am3-billing');