Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/components/app/data/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
38 changes: 38 additions & 0 deletions src/components/app/data/hooks/useCreateCheckoutIntentMutation.tsx
Original file line number Diff line number Diff line change
@@ -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<CreateCheckoutIntentSuccessResponseSchema>,
AxiosError<CreateCheckoutIntentErrorResponseSchema>,
CreateCheckoutIntentRequestSchema
>,
'mutationFn' | 'onSuccess' | 'onError'
> {
onSuccess?: (data: CreateCheckoutIntentSuccessResponseSchema) => void;
onError?: (errorData: CreateCheckoutIntentErrorResponseSchema | undefined) => void;
}

export default function useCreateCheckoutIntentMutation({
onSuccess,
onError,
...mutationConfig
}: UseCreateCheckoutIntentMutationProps = {}) {
return useMutation<
AxiosResponse<CreateCheckoutIntentSuccessResponseSchema>,
AxiosError<CreateCheckoutIntentErrorResponseSchema>,
CreateCheckoutIntentRequestSchema
>({
mutationFn: (requestData) => createCheckoutIntent(requestData),
onSuccess: (axiosResponse) => {
onSuccess?.(axiosResponse.data);
},
onError: (axiosError) => {
onError?.(axiosError.response?.data);
},
...mutationConfig,
});
}
76 changes: 76 additions & 0 deletions src/components/app/data/services/checkout-intent.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>;
}

/**
* 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<string, unknown>;

type CreateCheckoutIntentRequestPayload = Payload<CreateCheckoutIntentRequestSchema>;
type CreateCheckoutIntentSuccessResponsePayload = Payload<CreateCheckoutIntentSuccessResponseSchema>;
type CreateCheckoutIntentErrorResponsePayload = Payload<CreateCheckoutIntentErrorResponseSchema>;
}

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<AxiosResponse<CreateCheckoutIntentSuccessResponseSchema>> {
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),
Comment on lines +68 to +69
Copy link

Copilot AI Oct 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using @ts-ignore suppresses TypeScript errors and should be avoided. Consider properly typing the transformResponse property or using a type assertion with proper typing instead.

Suggested change
// @ts-ignore(TS2339)
transformResponse: axios.defaults.transformResponse!.concat(camelCaseResponse),
transformResponse: axios.defaults.transformResponse!.concat(camelCaseResponse) as import('axios').AxiosRequestConfig['transformResponse'],

Copilot uses AI. Check for mistakes.

};

const response: AxiosResponse<CreateCheckoutIntentSuccessResponseSchema> = await getAuthenticatedHttpClient()
.post<CreateCheckoutIntentSuccessResponsePayload>(url, requestPayload, requestConfig);

return response;
}
126 changes: 94 additions & 32 deletions src/components/plan-details-pages/PlanDetailsPage.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -7,14 +7,19 @@ 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';
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,
Expand All @@ -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);
Expand Down Expand Up @@ -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({
Expand All @@ -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;
Expand All @@ -117,29 +181,27 @@ const PlanDetailsPage = () => {
</Stack>
</Stepper.Step>
{stepperActionButtonMessage && (
<Stepper.ActionRow eventKey={eventKey}>
{location.pathname !== CheckoutPageRoute.PlanDetails && (
<Button
variant="outline-primary"
onClick={() => navigate(CheckoutPageRoute.PlanDetails)}
>
<FormattedMessage
id="checkout.back"
defaultMessage="Back"
description="Button to go back to the previous step"
<Stepper.ActionRow eventKey={eventKey}>
{location.pathname !== CheckoutPageRoute.PlanDetails && (
<Button
variant="outline-primary"
onClick={() => navigate(CheckoutPageRoute.PlanDetails)}
>
<FormattedMessage
id="checkout.back"
defaultMessage="Back"
description="Button to go back to the previous step"
/>
</Button>
)}
<Stepper.ActionRow.Spacer />
<PlanDetailsSubmitButton
formIsValid={isValid}
submissionIsPending={submissionIsPending}
submissionIsSuccess={submissionIsSuccess}
submissionIsError={submissionIsError}
/>
</Button>
)}
<Stepper.ActionRow.Spacer />
<Button
variant="secondary"
type="submit"
disabled={!isValid}
data-testid="stepper-submit-button"
>
{intl.formatMessage(stepperActionButtonMessage)}
</Button>
</Stepper.ActionRow>
</Stepper.ActionRow>
)}
</Stack>
</Form>
Expand Down
Loading