From e0cfe62cd751db64316bbbffdc45151c8ce05c58 Mon Sep 17 00:00:00 2001 From: jajjibhai008 Date: Wed, 1 Oct 2025 16:25:33 +0500 Subject: [PATCH] feat: update purchase summary sidebar --- .../PurchaseSummary/EditPlanButton.tsx | 27 +++ .../PurchaseSummary/PurchaseSummary.tsx | 4 + .../PurchaseSummaryCardButton.tsx | 45 +++++ .../PurchaseSummary/ReceiptButton.tsx | 39 ++++ .../tests/PurchaseSummary.test.tsx | 8 +- .../tests/PurchaseSummaryCardButton.test.tsx | 189 ++++++++++++++++++ src/components/app/data/hooks/index.ts | 1 + .../hooks/useCreateBillingPortalSession.tsx | 21 ++ src/components/app/data/queries/queries.ts | 6 + .../app/data/queries/queryKeyFactory.ts | 5 + .../data/services/create-billing-portal.ts | 16 ++ src/constants/events.ts | 1 + 12 files changed, 360 insertions(+), 2 deletions(-) create mode 100644 src/components/PurchaseSummary/EditPlanButton.tsx create mode 100644 src/components/PurchaseSummary/PurchaseSummaryCardButton.tsx create mode 100644 src/components/PurchaseSummary/ReceiptButton.tsx create mode 100644 src/components/PurchaseSummary/tests/PurchaseSummaryCardButton.test.tsx create mode 100644 src/components/app/data/hooks/useCreateBillingPortalSession.tsx create mode 100644 src/components/app/data/services/create-billing-portal.ts diff --git a/src/components/PurchaseSummary/EditPlanButton.tsx b/src/components/PurchaseSummary/EditPlanButton.tsx new file mode 100644 index 00000000..fa32fc99 --- /dev/null +++ b/src/components/PurchaseSummary/EditPlanButton.tsx @@ -0,0 +1,27 @@ +import { Button } from '@openedx/paragon'; +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import { useNavigate } from 'react-router'; + +import { CheckoutPageRoute } from '@/constants/checkout'; + +const EditPlanButton: React.FC = () => { + const navigate = useNavigate(); + + return ( + + ); +}; + +export default React.memo(EditPlanButton); diff --git a/src/components/PurchaseSummary/PurchaseSummary.tsx b/src/components/PurchaseSummary/PurchaseSummary.tsx index 8fed7385..b6143df9 100644 --- a/src/components/PurchaseSummary/PurchaseSummary.tsx +++ b/src/components/PurchaseSummary/PurchaseSummary.tsx @@ -9,6 +9,7 @@ import AutoRenewNotice from './AutoRenewNotice'; import DueTodayRow from './DueTodayRow'; import LicensesRow from './LicensesRow'; import PricePerUserRow from './PricePerUserRow'; +import PurchaseSummaryCardButton from './PurchaseSummaryCardButton'; import PurchaseSummaryHeader from './PurchaseSummaryHeader'; import TotalAfterTrialRow from './TotalAfterTrialRow'; @@ -36,6 +37,9 @@ const PurchaseSummary: React.FC = () => { + + + ); }; diff --git a/src/components/PurchaseSummary/PurchaseSummaryCardButton.tsx b/src/components/PurchaseSummary/PurchaseSummaryCardButton.tsx new file mode 100644 index 00000000..e41dcc6d --- /dev/null +++ b/src/components/PurchaseSummary/PurchaseSummaryCardButton.tsx @@ -0,0 +1,45 @@ +import React, { useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; + +import { CheckoutPageRoute } from '@/constants/checkout'; + +import EditPlanButton from './EditPlanButton'; +import ReceiptButton from './ReceiptButton'; + +const BUTTON_TYPES = { + EDIT: 'edit', + RECEIPT: 'receipt', + NONE: 'none', +} as const; + +type ButtonType = typeof BUTTON_TYPES[keyof typeof BUTTON_TYPES]; + +const ROUTE_BUTTON_MAP: Record = { + [CheckoutPageRoute.AccountDetails]: BUTTON_TYPES.EDIT, + [CheckoutPageRoute.BillingDetails]: BUTTON_TYPES.EDIT, + [CheckoutPageRoute.BillingDetailsSuccess]: BUTTON_TYPES.RECEIPT, + [CheckoutPageRoute.PlanDetails]: BUTTON_TYPES.NONE, + [CheckoutPageRoute.PlanDetailsLogin]: BUTTON_TYPES.NONE, + [CheckoutPageRoute.PlanDetailsRegister]: BUTTON_TYPES.NONE, +}; + +const BUTTON_COMPONENTS: Record = { + [BUTTON_TYPES.EDIT]: EditPlanButton, + [BUTTON_TYPES.RECEIPT]: ReceiptButton, + [BUTTON_TYPES.NONE]: null, +}; + +const PurchaseSummaryCardButton: React.FC = () => { + const location = useLocation(); + + const buttonType = useMemo( + (): ButtonType => ROUTE_BUTTON_MAP[location.pathname] ?? BUTTON_TYPES.NONE, + [location.pathname], + ); + + const ButtonComponent = BUTTON_COMPONENTS[buttonType]; + + return ButtonComponent ? : null; +}; + +export default PurchaseSummaryCardButton; diff --git a/src/components/PurchaseSummary/ReceiptButton.tsx b/src/components/PurchaseSummary/ReceiptButton.tsx new file mode 100644 index 00000000..95783d2e --- /dev/null +++ b/src/components/PurchaseSummary/ReceiptButton.tsx @@ -0,0 +1,39 @@ +import { Button } from '@openedx/paragon'; +import { FormattedMessage } from 'react-intl'; + +import { useCheckoutIntent, useCreateBillingPortalSession } from '@/components/app/data'; +import EVENT_NAMES from '@/constants/events'; +import { sendEnterpriseCheckoutTrackingEvent } from '@/utils/common'; + +const ReceiptButton: React.FC = () => { + const { data: billingPortalSession } = useCreateBillingPortalSession(); + const { data: checkoutIntent } = useCheckoutIntent(); + return ( + + ); +}; + +export default ReceiptButton; diff --git a/src/components/PurchaseSummary/tests/PurchaseSummary.test.tsx b/src/components/PurchaseSummary/tests/PurchaseSummary.test.tsx index e062357a..00db16d7 100644 --- a/src/components/PurchaseSummary/tests/PurchaseSummary.test.tsx +++ b/src/components/PurchaseSummary/tests/PurchaseSummary.test.tsx @@ -1,7 +1,7 @@ import { IntlProvider } from '@edx/frontend-platform/i18n'; import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; import '@testing-library/jest-dom'; -import React from 'react'; import { usePurchaseSummaryPricing } from '@/components/app/data'; import { DataStoreKey } from '@/constants/checkout'; @@ -12,6 +12,8 @@ import PurchaseSummary from '../PurchaseSummary'; jest.mock('@/components/app/data', () => ({ __esModule: true, usePurchaseSummaryPricing: jest.fn(), + useCreateBillingPortalSession: jest.fn(() => ({ data: { url: null } })), + useCheckoutIntent: jest.fn(() => ({ data: { id: 123 } })), })); describe('PurchaseSummary', () => { @@ -35,7 +37,9 @@ describe('PurchaseSummary', () => { it('renders header and rows with computed values', () => { render( - + + + , ); diff --git a/src/components/PurchaseSummary/tests/PurchaseSummaryCardButton.test.tsx b/src/components/PurchaseSummary/tests/PurchaseSummaryCardButton.test.tsx new file mode 100644 index 00000000..a1ccd32f --- /dev/null +++ b/src/components/PurchaseSummary/tests/PurchaseSummaryCardButton.test.tsx @@ -0,0 +1,189 @@ +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import '@testing-library/jest-dom'; + +import { CheckoutPageRoute } from '@/constants/checkout'; + +import PurchaseSummaryCardButton from '../PurchaseSummaryCardButton'; + +jest.mock('@/components/app/data', () => ({ + __esModule: true, + useCreateBillingPortalSession: jest.fn(() => ({ + data: { url: 'https://billing.example.com/portal' }, + })), + useCheckoutIntent: jest.fn(() => ({ + data: { id: 123 }, + })), +})); + +jest.mock('@/utils/common', () => ({ + sendEnterpriseCheckoutTrackingEvent: jest.fn(), +})); + +const mockNavigate = jest.fn(); +jest.mock('react-router', () => ({ + ...jest.requireActual('react-router'), + useNavigate: () => mockNavigate, +})); + +const renderWithRouter = (initialRoute: string) => render( + + + + + , +); + +describe('PurchaseSummaryCardButton', () => { + const mockSendTrackingEvent = jest.requireMock('@/utils/common').sendEnterpriseCheckoutTrackingEvent; + + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('Edit Plan Button Rendering', () => { + it('renders EditPlanButton on AccountDetails route', () => { + renderWithRouter(CheckoutPageRoute.AccountDetails); + + expect(screen.getByTestId('edit-plan-button')).toBeInTheDocument(); + expect(screen.getByText('Edit Plan')).toBeInTheDocument(); + expect(screen.queryByText('View receipt')).not.toBeInTheDocument(); + }); + + it('renders EditPlanButton on BillingDetails route', () => { + renderWithRouter(CheckoutPageRoute.BillingDetails); + + expect(screen.getByTestId('edit-plan-button')).toBeInTheDocument(); + expect(screen.getByText('Edit Plan')).toBeInTheDocument(); + expect(screen.queryByText('View receipt')).not.toBeInTheDocument(); + }); + }); + + describe('Receipt Button Rendering', () => { + it('renders ReceiptButton on BillingDetailsSuccess route', () => { + renderWithRouter(CheckoutPageRoute.BillingDetailsSuccess); + + expect(screen.getByText('View receipt')).toBeInTheDocument(); + expect(screen.queryByTestId('edit-plan-button')).not.toBeInTheDocument(); + expect(screen.queryByText('Edit Plan')).not.toBeInTheDocument(); + }); + }); + + describe('No Button Rendering', () => { + it('renders nothing on PlanDetails route', () => { + renderWithRouter(CheckoutPageRoute.PlanDetails); + + expect(screen.queryByTestId('edit-plan-button')).not.toBeInTheDocument(); + expect(screen.queryByText('Edit Plan')).not.toBeInTheDocument(); + expect(screen.queryByText('View receipt')).not.toBeInTheDocument(); + }); + + it('renders nothing on PlanDetailsLogin route', () => { + renderWithRouter(CheckoutPageRoute.PlanDetailsLogin); + + expect(screen.queryByTestId('edit-plan-button')).not.toBeInTheDocument(); + expect(screen.queryByText('Edit Plan')).not.toBeInTheDocument(); + expect(screen.queryByText('View receipt')).not.toBeInTheDocument(); + }); + + it('renders nothing on PlanDetailsRegister route', () => { + renderWithRouter(CheckoutPageRoute.PlanDetailsRegister); + + expect(screen.queryByTestId('edit-plan-button')).not.toBeInTheDocument(); + expect(screen.queryByText('Edit Plan')).not.toBeInTheDocument(); + expect(screen.queryByText('View receipt')).not.toBeInTheDocument(); + }); + + it('renders nothing for unknown/unmapped routes', () => { + renderWithRouter('/unknown-route'); + + expect(screen.queryByTestId('edit-plan-button')).not.toBeInTheDocument(); + expect(screen.queryByText('Edit Plan')).not.toBeInTheDocument(); + expect(screen.queryByText('View receipt')).not.toBeInTheDocument(); + }); + }); + + describe('Component Behavior', () => { + it('returns null when no button should be rendered', () => { + const { container } = renderWithRouter(CheckoutPageRoute.PlanDetails); + + expect(container.firstChild).toBeNull(); + }); + + it('memoizes button type calculation correctly', () => { + const { rerender } = renderWithRouter(CheckoutPageRoute.AccountDetails); + + expect(screen.getByTestId('edit-plan-button')).toBeInTheDocument(); + + rerender( + + + + + , + ); + + expect(screen.getByTestId('edit-plan-button')).toBeInTheDocument(); + }); + }); + + describe('Button Interactions', () => { + it('calls navigate when Edit Plan button is clicked', async () => { + const user = userEvent.setup(); + renderWithRouter(CheckoutPageRoute.AccountDetails); + + const editButton = screen.getByTestId('edit-plan-button'); + await user.click(editButton); + + expect(mockNavigate).toHaveBeenCalledWith(CheckoutPageRoute.PlanDetails); + }); + + it('calls tracking event when View Receipt button is clicked', async () => { + const user = userEvent.setup(); + + renderWithRouter(CheckoutPageRoute.BillingDetailsSuccess); + + const receiptButton = screen.getByText('View receipt'); + await user.click(receiptButton); + + expect(mockSendTrackingEvent).toHaveBeenCalledWith({ + checkoutIntentId: 123, + eventName: 'edx.ui.enterprise.checkout.self_service_subscription_checkout.billing_details_success.view_receipt_button.clicked', + properties: { + checkoutIntent: { id: 123 }, + billingPortalSessionUrl: 'https://billing.example.com/portal', + }, + }); + }); + }); + + describe('Edge Cases', () => { + it('handles empty string route gracefully', () => { + renderWithRouter(''); + + expect(screen.queryByTestId('edit-plan-button')).not.toBeInTheDocument(); + expect(screen.queryByText('Edit Plan')).not.toBeInTheDocument(); + expect(screen.queryByText('View receipt')).not.toBeInTheDocument(); + }); + + it('handles route with query parameters', () => { + renderWithRouter(`${CheckoutPageRoute.AccountDetails}?param=value`); + + // Route with query parameters should still match the base route + expect(screen.getByTestId('edit-plan-button')).toBeInTheDocument(); + expect(screen.getByText('Edit Plan')).toBeInTheDocument(); + expect(screen.queryByText('View receipt')).not.toBeInTheDocument(); + }); + + it('handles route with hash fragment', () => { + renderWithRouter(`${CheckoutPageRoute.BillingDetails}#section`); + + // Route with hash fragment should still match the base route + expect(screen.getByTestId('edit-plan-button')).toBeInTheDocument(); + expect(screen.getByText('Edit Plan')).toBeInTheDocument(); + expect(screen.queryByText('View receipt')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/app/data/hooks/index.ts b/src/components/app/data/hooks/index.ts index b6b6507b..2adbc946 100644 --- a/src/components/app/data/hooks/index.ts +++ b/src/components/app/data/hooks/index.ts @@ -5,3 +5,4 @@ export { default as useLoginMutation } from './useLoginMutation'; export { default as useCreateCheckoutSessionMutation } from './useCreateCheckoutSessionMutation'; export { default as usePurchaseSummaryPricing } from './usePurchaseSummaryPricing'; export { default as useCheckoutIntent } from './useCheckoutIntent'; +export { default as useCreateBillingPortalSession } from './useCreateBillingPortalSession'; diff --git a/src/components/app/data/hooks/useCreateBillingPortalSession.tsx b/src/components/app/data/hooks/useCreateBillingPortalSession.tsx new file mode 100644 index 00000000..882cef7a --- /dev/null +++ b/src/components/app/data/hooks/useCreateBillingPortalSession.tsx @@ -0,0 +1,21 @@ +import { AppContext } from '@edx/frontend-platform/react'; +import { queryOptions, useQuery } from '@tanstack/react-query'; +import { useContext } from 'react'; + +import useBFFSuccess from '@/components/app/data/hooks/useBFFSuccess'; +import { queryCreateBillingPortalSession } from '@/components/app/data/queries/queries'; + +const useCreateBillingPortalSession = (options = {}) => { + const { authenticatedUser }: AppContextValue = useContext(AppContext); + const { data: contextData } = useBFFSuccess(authenticatedUser?.userId ?? null); + const { checkoutIntent } = contextData ?? {}; + + return useQuery( + queryOptions({ + ...queryCreateBillingPortalSession(checkoutIntent?.id), + ...options, + }), + ); +}; + +export default useCreateBillingPortalSession; diff --git a/src/components/app/data/queries/queries.ts b/src/components/app/data/queries/queries.ts index 63acea71..c525febe 100644 --- a/src/components/app/data/queries/queries.ts +++ b/src/components/app/data/queries/queries.ts @@ -33,3 +33,9 @@ export const queryBffValidation = (payload: ValidationSchema) => { ._ctx.validation(fields, snakeCasedPayload) ); }; + +export const queryCreateBillingPortalSession = (checkout_intent_id?: number) => ( + queries + .enterpriseCheckout + .createBillingPortalSession(checkout_intent_id) +); diff --git a/src/components/app/data/queries/queryKeyFactory.ts b/src/components/app/data/queries/queryKeyFactory.ts index 59c042b1..c43667ef 100644 --- a/src/components/app/data/queries/queryKeyFactory.ts +++ b/src/components/app/data/queries/queryKeyFactory.ts @@ -2,6 +2,7 @@ import { createQueryKeys, mergeQueryKeys } from '@lukemorales/query-key-factory' import createCheckoutSession from '@/components/app/data/services/checkout-session'; import { fetchCheckoutContext, fetchCheckoutSuccess } from '@/components/app/data/services/context'; +import createBillingPortalSession from '@/components/app/data/services/create-billing-portal'; import fetchCheckoutValidation from '@/components/app/data/services/validation'; const enterpriseCheckout = createQueryKeys('enterpriseCheckout', { @@ -26,6 +27,10 @@ const enterpriseCheckout = createQueryKeys('enterpriseCheckout', { queryKey: [fields], queryFn: () => createCheckoutSession(payload), }), + createBillingPortalSession: (checkout_intent_id) => ({ + queryKey: [checkout_intent_id], + queryFn: () => createBillingPortalSession(checkout_intent_id), + }), }); const queries = mergeQueryKeys(enterpriseCheckout); diff --git a/src/components/app/data/services/create-billing-portal.ts b/src/components/app/data/services/create-billing-portal.ts new file mode 100644 index 00000000..40c1e47a --- /dev/null +++ b/src/components/app/data/services/create-billing-portal.ts @@ -0,0 +1,16 @@ +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { getConfig } from '@edx/frontend-platform/config'; +import { camelCaseObject } from '@edx/frontend-platform/utils'; + +export interface CreateBillingPortalSessionResponseSchema { + url: string; +} + +const createBillingPortalSession = async (checkoutIntentId?: number | null) => { + const { ENTERPRISE_ACCESS_BASE_URL } = getConfig(); + const url = `${ENTERPRISE_ACCESS_BASE_URL}/api/v1/customer-billing/${checkoutIntentId}/create-checkout-portal-session`; + const response = await getAuthenticatedHttpClient().get(url); + return camelCaseObject(response.data) as CreateBillingPortalSessionResponseSchema; +}; + +export default createBillingPortalSession; diff --git a/src/constants/events.ts b/src/constants/events.ts index 0420e57c..49789d18 100644 --- a/src/constants/events.ts +++ b/src/constants/events.ts @@ -25,6 +25,7 @@ const SUBSCRIPTION_CHECKOUT_EVENTS = { TOGGLE_TNC_TERMS: `${SUBSCRIPTION_CHECKOUT_PREFIX}.terms_and_conditions_checkbox.toggled`, TOGGLE_SUBSCRIPTION_TERMS: `${SUBSCRIPTION_CHECKOUT_PREFIX}.subscription_terms_checkbox.toggled`, // BillingDetailsSuccess + VIEW_RECEIPT_BUTTON_CLICKED: `${SUBSCRIPTION_CHECKOUT_PREFIX}.billing_details_success.view_receipt_button.clicked`, }; const EVENT_NAMES = {