From 5bd6fe02e2e38317eec1e21a6d52d7e52583850a Mon Sep 17 00:00:00 2001 From: Troy Sankey Date: Wed, 1 Oct 2025 18:05:10 -0700 Subject: [PATCH] feat: PlanDetails page now uses a StatefulButton and creates a CheckoutIntent ENT-10756 --- src/components/app/data/hooks/index.ts | 2 + .../hooks/useCreateCheckoutIntentMutation.tsx | 38 ++++++ .../app/data/services/checkout-intent.ts | 76 +++++++++++ .../plan-details-pages/PlanDetailsPage.tsx | 126 +++++++++++++----- .../PlanDetailsSubmitButton.tsx | 88 ++++++++++++ 5 files changed, 298 insertions(+), 32 deletions(-) create mode 100644 src/components/app/data/hooks/useCreateCheckoutIntentMutation.tsx create mode 100644 src/components/app/data/services/checkout-intent.ts create mode 100644 src/components/plan-details-pages/PlanDetailsSubmitButton.tsx diff --git a/src/components/app/data/hooks/index.ts b/src/components/app/data/hooks/index.ts index 2adbc946..ab9a09b2 100644 --- a/src/components/app/data/hooks/index.ts +++ b/src/components/app/data/hooks/index.ts @@ -6,3 +6,5 @@ export { default as useCreateCheckoutSessionMutation } from './useCreateCheckout export { default as usePurchaseSummaryPricing } from './usePurchaseSummaryPricing'; export { default as useCheckoutIntent } from './useCheckoutIntent'; export { default as useCreateBillingPortalSession } from './useCreateBillingPortalSession'; +export { default as useCreateCheckoutIntentMutation } from './useCreateCheckoutIntentMutation'; +export { default as useCheckoutSessionClientSecret } from './useCheckoutSessionClientSecret'; diff --git a/src/components/app/data/hooks/useCreateCheckoutIntentMutation.tsx b/src/components/app/data/hooks/useCreateCheckoutIntentMutation.tsx new file mode 100644 index 00000000..4bd917ec --- /dev/null +++ b/src/components/app/data/hooks/useCreateCheckoutIntentMutation.tsx @@ -0,0 +1,38 @@ +import { useMutation, UseMutationOptions } from '@tanstack/react-query'; + +import createCheckoutIntent from '@/components/app/data/services/checkout-intent'; + +import type { AxiosError, AxiosResponse } from 'axios'; + +interface UseCreateCheckoutIntentMutationProps extends Omit< +UseMutationOptions< +AxiosResponse, +AxiosError, +CreateCheckoutIntentRequestSchema +>, +'mutationFn' | 'onSuccess' | 'onError' +> { + onSuccess?: (data: CreateCheckoutIntentSuccessResponseSchema) => void; + onError?: (errorData: CreateCheckoutIntentErrorResponseSchema | undefined) => void; +} + +export default function useCreateCheckoutIntentMutation({ + onSuccess, + onError, + ...mutationConfig +}: UseCreateCheckoutIntentMutationProps = {}) { + return useMutation< + AxiosResponse, + AxiosError, + CreateCheckoutIntentRequestSchema + >({ + mutationFn: (requestData) => createCheckoutIntent(requestData), + onSuccess: (axiosResponse) => { + onSuccess?.(axiosResponse.data); + }, + onError: (axiosError) => { + onError?.(axiosError.response?.data); + }, + ...mutationConfig, + }); +} diff --git a/src/components/app/data/services/checkout-intent.ts b/src/components/app/data/services/checkout-intent.ts new file mode 100644 index 00000000..060fed4f --- /dev/null +++ b/src/components/app/data/services/checkout-intent.ts @@ -0,0 +1,76 @@ +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { getConfig } from '@edx/frontend-platform/config'; +import { camelCaseObject, snakeCaseObject } from '@edx/frontend-platform/utils'; +import axios, { AxiosResponse } from 'axios'; + +/** + * Types for creating a CheckoutIntent. + * These are intentionally minimal for the current usage (Plan Details step). + * Extend as needed if additional writable fields are later required. + */ +declare global { + /** + * Writable request subset for creating a CheckoutIntent. + * (Backend serializer may accept additional fields; include as needed.) + */ + interface CreateCheckoutIntentRequestSchema { + quantity: number; + enterpriseName?: string; + enterpriseSlug?: string; + country?: string; + termsMetadata?: Record; + } + + /** + * Success response (shape based on CheckoutIntentCreateRequest / Read serializer). + * We keep it loose but strongly typed for known fields we might use. + */ + interface CreateCheckoutIntentSuccessResponseSchema { + id: number; + state: string; + quantity: number; + enterpriseName?: string; + enterpriseSlug?: string; + stripeCheckoutSessionId?: string | null; + expiresAt?: string; + created?: string; + modified?: string; + // Add any other fields returned by backend as needed + [key: string]: any; + } + + type CreateCheckoutIntentErrorResponseSchema = Record; + + type CreateCheckoutIntentRequestPayload = Payload; + type CreateCheckoutIntentSuccessResponsePayload = Payload; + type CreateCheckoutIntentErrorResponsePayload = Payload; +} + +const camelCaseResponse = ( + data: CreateCheckoutIntentSuccessResponsePayload | CreateCheckoutIntentErrorResponsePayload, +) => camelCaseObject(data); + +/** + * createCheckoutIntent + * POST /api/v1/checkout-intent/ + * + * Only callable for an authenticated user. + */ +export default async function createCheckoutIntent( + requestData: CreateCheckoutIntentRequestSchema, +): Promise> { + const { ENTERPRISE_ACCESS_BASE_URL } = getConfig(); + const url = `${ENTERPRISE_ACCESS_BASE_URL}/api/v1/checkout-intent/`; + const requestPayload: CreateCheckoutIntentRequestPayload = snakeCaseObject(requestData); + + const requestConfig = { + // Preserve default transforms plus our camelCase transform. + // @ts-ignore(TS2339) + transformResponse: axios.defaults.transformResponse!.concat(camelCaseResponse), + }; + + const response: AxiosResponse = await getAuthenticatedHttpClient() + .post(url, requestPayload, requestConfig); + + return response; +} diff --git a/src/components/plan-details-pages/PlanDetailsPage.tsx b/src/components/plan-details-pages/PlanDetailsPage.tsx index ae706dd7..1c8d1010 100644 --- a/src/components/plan-details-pages/PlanDetailsPage.tsx +++ b/src/components/plan-details-pages/PlanDetailsPage.tsx @@ -1,4 +1,4 @@ -import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { AppContext } from '@edx/frontend-platform/react'; import { zodResolver } from '@hookform/resolvers/zod'; import { @@ -7,6 +7,7 @@ import { Stack, Stepper, } from '@openedx/paragon'; +import { useQueryClient } from '@tanstack/react-query'; import { useContext, useMemo } from 'react'; import { Helmet } from 'react-helmet'; import { useForm } from 'react-hook-form'; @@ -14,7 +15,11 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { z } from 'zod'; import { useFormValidationConstraints } from '@/components/app/data'; -import { useLoginMutation } from '@/components/app/data/hooks'; +import { + useCreateCheckoutIntentMutation, + useLoginMutation, +} from '@/components/app/data/hooks'; +import { queryBffContext, queryBffSuccess } from '@/components/app/data/queries/queries'; import { useStepperContent } from '@/components/Stepper/Steps/hooks'; import { CheckoutPageRoute, @@ -28,11 +33,12 @@ import { useCurrentPageDetails, } from '@/hooks/index'; +import PlanDetailsSubmitButton from './PlanDetailsSubmitButton'; import '../Stepper/Steps/css/PriceAlert.css'; const PlanDetailsPage = () => { - const intl = useIntl(); const location = useLocation(); + const queryClient = useQueryClient(); const { data: formValidationConstraints } = useFormValidationConstraints(); const planDetailsFormData = useCheckoutFormStore((state) => state.formData[DataStoreKey.PlanDetails]); const setFormData = useCheckoutFormStore((state) => state.setFormData); @@ -70,23 +76,68 @@ const PlanDetailsPage = () => { }, }); + // Use existing checkout intent if already created (avoid duplicate POST) + async function queryClientInvalidate(userId?: number) { + if (!userId) { + return; + } + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryBffContext(userId).queryKey }), + queryClient.invalidateQueries({ queryKey: queryBffSuccess(userId).queryKey }), + ]); + } + + const createCheckoutIntentMutation = useCreateCheckoutIntentMutation({ + onSuccess: () => { + // Invalidate BFF context queries so downstream pages see the new intent. + queryClientInvalidate(authenticatedUser?.userId); + navigate(CheckoutPageRoute.AccountDetails); + }, + onError: (errorData) => { + // Basic field-level mapping if backend returns validation-like structure. + if (errorData && typeof errorData === 'object') { + Object.entries(errorData).forEach(([fieldKey, fieldVal]) => { + if (fieldKey === 'quantity' && (fieldVal as any)?.errorCode) { + setError('quantity', { + type: 'manual', + message: (fieldVal as any).errorCode, + }); + } + }); + } else { + setError('root.serverError', { + type: 'manual', + message: 'Server Error', + }); + } + }, + }); + const onSubmitCallbacks: { [K in SubmitCallbacks]: (data: PlanDetailsData | PlanDetailsLoginPageData | PlanDetailsRegisterPageData) => void } = { [SubmitCallbacks.PlanDetails]: (data: PlanDetailsData) => { + // Always persist plan details first. setFormData(DataStoreKey.PlanDetails, data); - // TODO: replace with existing user email logic - const emailExists = true; + // Determine if user is authenticated; if not, proceed to logistration flows. if (!authenticatedUser) { + // TODO: replace with existing user email logic + const emailExists = true; if (emailExists) { navigate(CheckoutPageRoute.PlanDetailsLogin); } else { navigate(CheckoutPageRoute.PlanDetailsRegister); } - } else { - navigate(CheckoutPageRoute.AccountDetails); + return; } + + // Trigger mutation (spinner state handled by button component) + createCheckoutIntentMutation.mutate({ + quantity: planDetailsFormData.quantity, + country: planDetailsFormData.country, + // TODO: Record terms metadata too. + }); }, [SubmitCallbacks.PlanDetailsLogin]: (data: PlanDetailsLoginPageData) => { loginMutation.mutate({ @@ -95,14 +146,27 @@ const PlanDetailsPage = () => { }); }, [SubmitCallbacks.PlanDetailsRegister]: (data: PlanDetailsRegisterPageData) => { - // TODO: actually call registerRequest service function. + // Placeholder for a future register API call. navigate(CheckoutPageRoute.PlanDetails); - // TODO: temporarily return data to make linter happy. return data; }, }; - const onSubmit = (data: PlanDetailsData) => onSubmitCallbacks[currentPage!](data); + const onSubmit = ( + data: PlanDetailsData | PlanDetailsLoginPageData | PlanDetailsRegisterPageData, + ) => onSubmitCallbacks[currentPage!](data); + + // Determine which mutation states to surface to the button + const isPlanDetailsMain = currentPage === SubmitCallbacks.PlanDetails; + const submissionIsPending = isPlanDetailsMain + ? createCheckoutIntentMutation.isPending + : loginMutation.isPending; + const submissionIsSuccess = isPlanDetailsMain + ? createCheckoutIntentMutation.isSuccess + : loginMutation.isSuccess; + const submissionIsError = isPlanDetailsMain + ? createCheckoutIntentMutation.isError + : loginMutation.isError; const StepperContent = useStepperContent(); const eventKey = CheckoutStepKey.PlanDetails; @@ -117,29 +181,27 @@ const PlanDetailsPage = () => { {stepperActionButtonMessage && ( - - {location.pathname !== CheckoutPageRoute.PlanDetails && ( - + )} + + - - )} - - - + )} diff --git a/src/components/plan-details-pages/PlanDetailsSubmitButton.tsx b/src/components/plan-details-pages/PlanDetailsSubmitButton.tsx new file mode 100644 index 00000000..f87ea30c --- /dev/null +++ b/src/components/plan-details-pages/PlanDetailsSubmitButton.tsx @@ -0,0 +1,88 @@ +import { defineMessages, useIntl } from '@edx/frontend-platform/i18n'; +import { Icon, StatefulButton } from '@openedx/paragon'; +import { CheckCircleOutline, Refresh, SpinnerSimple } from '@openedx/paragon/icons'; +import { useEffect, useState } from 'react'; +import { MessageDescriptor } from 'react-intl'; + +import { useCurrentPageDetails } from '@/hooks/index'; + +interface PlanDetailsSubmitButtonProps { + formIsValid: boolean; + submissionIsPending: boolean; + submissionIsSuccess: boolean; + submissionIsError: boolean; +} + +type ButtonState = 'inactive' | 'default' | 'pending' | 'complete' | 'errored'; + +const overrideButtonMessages: Record = defineMessages({ + submitting: { + id: 'checkout.submitting', + defaultMessage: 'Submitting...', + description: 'Button label when submission is in progress', + }, + submitted: { + id: 'checkout.submitted', + defaultMessage: 'Submitted', + description: 'Button label when submission completed successfully', + }, + tryAgain: { + id: 'checkout.tryAgain', + defaultMessage: 'Try Again', + description: 'Button label after an error occurred', + }, +}); + +const PlanDetailsSubmitButton = ({ + formIsValid, + submissionIsPending, + submissionIsSuccess, + submissionIsError, +}: PlanDetailsSubmitButtonProps) => { + const intl = useIntl(); + const { buttonMessage } = useCurrentPageDetails(); + + const [buttonState, setButtonState] = useState('default'); + + useEffect(() => { + // Priority: pending > complete > errored > inactive > default + if (submissionIsPending) { + setButtonState('pending'); + } else if (submissionIsSuccess) { + setButtonState('complete'); + } else if (submissionIsError) { + setButtonState('errored'); + } else if (!formIsValid) { + setButtonState('inactive'); + } else { + setButtonState('default'); + } + }, [formIsValid, submissionIsPending, submissionIsSuccess, submissionIsError]); + + return ( + , + complete: , + errored: , + }} + // Allow re-click after success or error (only block inactive/pending) + disabledStates={['inactive', 'pending']} + variant="secondary" + data-testid="stepper-submit-button" + /> + ); +}; + +export default PlanDetailsSubmitButton;