Skip to content
Open
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
31 changes: 22 additions & 9 deletions src/components/Stepper/CheckoutStepperContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,36 @@
import { getConfig } from '@edx/frontend-platform/config';
import { Col, Row, Stack, Stepper } from '@openedx/paragon';
import { ReactElement, useEffect } from 'react';

import { PurchaseSummary } from '@/components/PurchaseSummary';
import { StepperTitle } from '@/components/Stepper/StepperTitle';
import { AccountDetails, BillingDetails, PlanDetails } from '@/components/Stepper/Steps';
import { AccountDetails, BillingDetails, EssentialsAcademicSelection, PlanDetails } from '@/components/Stepper/Steps';
import { CheckoutSubstepKey } from '@/constants/checkout';
import useCurrentStep from '@/hooks/useCurrentStep';
import { isFeatureEnabled } from '@/utils/common';

const Steps = (): ReactElement => (
<>
<PlanDetails />
<AccountDetails />
<BillingDetails />
</>
);
const Steps = (): ReactElement => {
const {
FEATURE_SELF_SERVICE_ESSENTIALS,
FEATURE_SELF_SERVICE_ESSENTIALS_KEY,
} = getConfig();
return (
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

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

Remove console.log statement before merging. This debug output should not be committed to production code.

Copilot uses AI. Check for mistakes.
<>
{
isFeatureEnabled(
FEATURE_SELF_SERVICE_ESSENTIALS,
FEATURE_SELF_SERVICE_ESSENTIALS_KEY,
) && <EssentialsAcademicSelection />
Comment on lines +20 to +23
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This is very important

}
<PlanDetails />
<AccountDetails />
<BillingDetails />
</>
);
};

const CheckoutStepperContainer = (): ReactElement => {
const { currentStepKey, currentSubstepKey } = useCurrentStep();

useEffect(() => {
const preventUnload = (e: BeforeUnloadEvent) => {
if (currentSubstepKey !== CheckoutSubstepKey.Success) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { UseFormReturn } from 'react-hook-form';

interface EssentialsAcademicSelectionContentProps {
form: UseFormReturn<EssentialAcademicSelectionData>;
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

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

Inconsistent naming: The type is "EssentialAcademicSelectionData" (singular) but should be "EssentialsAcademicSelectionData" (plural) to match the schema name "EssentialsAcademicSelectionSchema" and maintain naming consistency across the codebase.

Suggested change
form: UseFormReturn<EssentialAcademicSelectionData>;
form: UseFormReturn<EssentialsAcademicSelectionData>;

Copilot uses AI. Check for mistakes.
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const EssentialsAcademicSelectionContent = ({ form: _form }: EssentialsAcademicSelectionContentProps) => (
<>
Essentials Academic Selection
</>
);

export default EssentialsAcademicSelectionContent;
1 change: 1 addition & 0 deletions src/components/Stepper/StepperContent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export { default as PlanDetailsRegisterContent } from './PlanDetailsRegisterCont
export { default as AccountDetailsContent } from './AccountDetailsContent';
export { default as BillingDetailsContent } from './BillingDetailsContent';
export { default as BillingDetailsSuccessContent } from './BillingDetailsSuccessContent';
export { default as EssentialsAcademicSelectionContent } from './EssentialsAcademicSelectionContent';
8 changes: 8 additions & 0 deletions src/components/Stepper/Steps/EssentialsAcademicSelection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { EssentialsAcademicSelectionPage } from '@/components/academic-selection-page';

// TODO: unnecessary layer of abstraction, just move component logic into this file.
const EssentialsAcademicSelection: React.FC = () => (
<EssentialsAcademicSelectionPage />
);

export default EssentialsAcademicSelection;
2 changes: 2 additions & 0 deletions src/components/Stepper/Steps/hooks/useStepperContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
AccountDetailsContent,
BillingDetailsContent,
BillingDetailsSuccessContent,
EssentialsAcademicSelectionContent,
PlanDetailsContent,
PlanDetailsLoginContent,
PlanDetailsRegisterContent,
Expand All @@ -12,6 +13,7 @@ import useCurrentPage from '@/hooks/useCurrentPage';
type StepperContentComponent = React.FC<{ form?: any }>;

const StepperContentByPage = {
EssentialsAcademicSelection: EssentialsAcademicSelectionContent,
PlanDetails: PlanDetailsContent,
PlanDetailsLogin: PlanDetailsLoginContent,
PlanDetailsRegister: PlanDetailsRegisterContent,
Expand Down
1 change: 1 addition & 0 deletions src/components/Stepper/Steps/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { default as PlanDetails } from './PlanDetails';
export { default as AccountDetails } from './AccountDetails';
export { default as BillingDetails } from './BillingDetails';
export { default as EssentialsAcademicSelection } from './EssentialsAcademicSelection';
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Stack, Stepper } from '@openedx/paragon';
import { useMemo } from 'react';
import { Helmet } from 'react-helmet';
import { useForm } from 'react-hook-form';

import { useFormValidationConstraints } from '@/components/app/data';
import { useStepperContent } from '@/components/Stepper/Steps/hooks';
import { CheckoutStepKey, DataStoreKey } from '@/constants/checkout';
import { useCheckoutFormStore, useCurrentPageDetails } from '@/hooks/index';

const EssentialsAcademicSelectionPage = () => {
const StepperContent = useStepperContent();
const { data: formValidationConstraints } = useFormValidationConstraints();
const eventKey = CheckoutStepKey.Essentials;
const {
formSchema,
} = useCurrentPageDetails();
const essentialsFormData = useCheckoutFormStore((state) => state.formData[DataStoreKey.EssentialsAcademicSelection]);

const essentialsAcademicSelectionSchema = useMemo(() => (
formSchema(formValidationConstraints)
), [formSchema, formValidationConstraints]);

const form = useForm<EssentialAcademicSelectionData>({
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

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

Inconsistent naming: The type is "EssentialAcademicSelectionData" (singular) but should be "EssentialsAcademicSelectionData" (plural) to match the schema name "EssentialsAcademicSelectionSchema" and maintain naming consistency across the codebase.

Suggested change
const form = useForm<EssentialAcademicSelectionData>({
const form = useForm<EssentialsAcademicSelectionData>({

Copilot uses AI. Check for mistakes.
mode: 'onTouched',
resolver: zodResolver(essentialsAcademicSelectionSchema),
defaultValues: essentialsFormData,
});

return (
<>
<Helmet title="Academic Selection Page" />
<Stack gap={4}>
<Stepper.Step eventKey={eventKey} title="Academic Selection Page">
<Stack gap={4}>
<StepperContent form={form} />
</Stack>
</Stepper.Step>
</Stack>
</>
);
};

export default EssentialsAcademicSelectionPage;
Comment on lines +1 to +45
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

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

The new EssentialsAcademicSelectionPage component lacks test coverage. Similar page components in the codebase (e.g., AccountDetailsPage) have corresponding test files. Consider adding tests to cover the component's rendering, form validation, and integration with the stepper content.

Copilot uses AI. Check for mistakes.
1 change: 1 addition & 0 deletions src/components/academic-selection-page/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as EssentialsAcademicSelectionPage } from './EssentialsAcademicSelectionPage';
5 changes: 5 additions & 0 deletions src/components/app/routes/loaders/checkoutStepperLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import { CheckoutPageRoute, DataStoreKey } from '@/constants/checkout';
import { checkoutFormStore } from '@/hooks/useCheckoutFormStore';
import { extractPriceId, getCheckoutPageDetails, getStepFromParams } from '@/utils/checkout';

async function essentialsAcademicSelectionLoader(): Promise<Response | null> {
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

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

The essentialsAcademicSelectionLoader function doesn't match the signature of other loader functions in this file. It should accept a QueryClient parameter for consistency with other loaders (e.g., accountDetailsLoader, billingDetailsLoader) even if it doesn't use it yet. This ensures a consistent interface for all page loaders.

Suggested change
async function essentialsAcademicSelectionLoader(): Promise<Response | null> {
async function essentialsAcademicSelectionLoader(_queryClient: QueryClient): Promise<Response | null> {

Copilot uses AI. Check for mistakes.
return null;
}

/**
* Route loader for Plan Details page.
*
Expand Down Expand Up @@ -169,6 +173,7 @@ async function billingDetailsSuccessLoader(queryClient: QueryClient): Promise<Re
* Page-specific route loaders mapped by checkout page
*/
const PAGE_LOADERS: Record<CheckoutPage, (queryClient: QueryClient) => Promise<Response | null>> = {
EssentialsAcademicSelection: essentialsAcademicSelectionLoader,
PlanDetails: planDetailsLoader,
PlanDetailsLogin: planDetailsLoginLoader,
PlanDetailsRegister: planDetailsRegisterLoader,
Expand Down
20 changes: 8 additions & 12 deletions src/components/app/routes/loaders/rootLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from '@/components/app/routes/loaders/utils';
import { CheckoutPageRoute, EssentialsPageRoute } from '@/constants/checkout';
import { extractPriceId } from '@/utils/checkout';
import { isFeatureEnabled } from '@/utils/common';

/**
* Factory that creates the root route loader for the Enterprise Checkout MFE.
Expand Down Expand Up @@ -87,14 +88,9 @@ const makeRootLoader = (

// Feature flag check
if (routeFeatureKey) {
const sessionKey = sessionStorage.getItem(SSP_SESSION_KEY);
const isUnlocked = isFeatureEnabled(false, routeFeatureKey);

const isUnlockedBySiteKey = !!FEATURE_SELF_SERVICE_SITE_KEY
&& sessionKey === FEATURE_SELF_SERVICE_SITE_KEY;

const isUnlockedByRouteKey = sessionKey === routeFeatureKey;

if (!isUnlockedBySiteKey && !isUnlockedByRouteKey) {
if (!isUnlocked) {
const featureParam = new URL(request.url).searchParams.get('feature');

const paramIsSiteKey = !!FEATURE_SELF_SERVICE_SITE_KEY
Expand All @@ -121,11 +117,11 @@ const makeRootLoader = (
* Essentials routes do not participate in checkout intent logic.
* This check happens AFTER feature flag validation.
*/
const isCheckoutRoute = !Object.values(EssentialsPageRoute).some(route => isPathMatch(currentPath, route));

if (!isCheckoutRoute) {
return null;
}
// const isCheckoutRoute = !Object.values(EssentialsPageRoute).some(route => isPathMatch(currentPath, route));
//
// if (!isCheckoutRoute) {
// return null;
// }
Comment on lines +120 to +124
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

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

The commented-out code block handling checkout intent validation should either be removed or have a clear explanation of why it's being commented out. Leaving large blocks of commented code in production reduces code maintainability. If this is intentional for the POC, consider adding a TODO comment explaining the reasoning.

Copilot uses AI. Check for mistakes.

// Fetch basic info about authenticated user from JWT token, and also hydrate it with additional
// information from the `<LMS>/api/user/v1/accounts/<username>` endpoint. We need access to the
Expand Down
1 change: 1 addition & 0 deletions src/components/app/routes/loaders/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ interface PrerequisiteCheck<T> {
* and the route that should be returned if that slice is invalid.
*/
export const prerequisiteSpec: Record<CheckoutStep, Array<PrerequisiteCheck<any>>> = {
Essentials: [],
PlanDetails: [],
AccountDetails: [
{
Expand Down
10 changes: 5 additions & 5 deletions src/components/app/routes/tests/rootLoader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ describe('makeRootLoader (rootLoader) tests', () => {
FEATURE_SELF_SERVICE_ESSENTIALS_KEY: 'essentials-key',
});

const result = getFeatureForPath(EssentialsPageRoute.AcademicSelection);
const result = getFeatureForPath(EssentialsPageRoute.EssentialsAcademicSelection);
expect(result).toBe('essentials-key');
});

Expand Down Expand Up @@ -236,7 +236,7 @@ describe('makeRootLoader (rootLoader) tests', () => {
const loader = makeRootLoader(queryClient);

const result = await loader({
request: makeRequest(`${EssentialsPageRoute.AcademicSelection}?feature=SSP_ESSENTIALS_CHECKOUT`),
request: makeRequest(`${EssentialsPageRoute.EssentialsAcademicSelection}?feature=SSP_ESSENTIALS_CHECKOUT`),
} as any);

expect(logInfo).toHaveBeenCalledWith(
Expand All @@ -259,7 +259,7 @@ describe('makeRootLoader (rootLoader) tests', () => {
const loader = makeRootLoader(queryClient);

await expect(
loader({ request: makeRequest(EssentialsPageRoute.AcademicSelection) } as any),
loader({ request: makeRequest(EssentialsPageRoute.EssentialsAcademicSelection) } as any),
).rejects.toThrow('Self-service purchasing is not enabled');

expect(logError).toHaveBeenCalledWith('Self-service purchasing is not enabled');
Expand All @@ -277,7 +277,7 @@ describe('makeRootLoader (rootLoader) tests', () => {
const loader = makeRootLoader(queryClient);

const result = await loader({
request: makeRequest(`${EssentialsPageRoute.AcademicSelection}?feature=SSP_ESSENTIALS_CHECKOUT`),
request: makeRequest(`${EssentialsPageRoute.EssentialsAcademicSelection}?feature=SSP_ESSENTIALS_CHECKOUT`),
} as any);

expect(logInfo).toHaveBeenCalledWith(
Expand All @@ -301,7 +301,7 @@ describe('makeRootLoader (rootLoader) tests', () => {

const result = await loader({
request: makeRequest(
`${EssentialsPageRoute.AcademicSelection}?feature=SSP_SITE_CHECKOUT`,
`${EssentialsPageRoute.EssentialsAcademicSelection}?feature=SSP_SITE_CHECKOUT`,
),
} as any);

Expand Down
6 changes: 3 additions & 3 deletions src/components/plan-details-pages/PlanDetailsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,9 +153,9 @@ const PlanDetailsPage = () => {
},
});

const onSubmitCallbacks: {
[K in SubmitCallbacks]: (data: PlanDetailsData | PlanDetailsLoginPageData | PlanDetailsRegisterPageData) => void
} = {
const onSubmitCallbacks: Record<SubmitCallbacks, (
data: PlanDetailsData | PlanDetailsLoginPageData | PlanDetailsRegisterPageData) => void> = {
[SubmitCallbacks.EssentialsAcademicSelection]: () => {},
[SubmitCallbacks.PlanDetails]: async (data: PlanDetailsData) => {
const { validationDecisions, isValid: isValidAdminEmailField } = await validateFieldDetailed(
'adminEmail',
Expand Down
28 changes: 17 additions & 11 deletions src/constants/checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import { validateFieldDetailed } from '@/components/app/data/services/validation
import { serverValidationError } from '@/utils/common';

export enum CheckoutStepKey {
Essentials = 'essentials',
PlanDetails = 'plan-details',
AccountDetails = 'account-details',
BillingDetails = 'billing-details',
}

export enum CheckoutSubstepKey {
AcademicSelection = 'academic-selection',
Login = 'login',
Register = 'register',
Success = 'success',
Expand Down Expand Up @@ -279,9 +281,15 @@ export const BillingDetailsSchema = (constraints: CheckoutContextFieldConstraint
// Simple empty schema - no validation needed for coming soon page
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const AcademicSelectionSchema = (constraints: CheckoutContextFieldConstraints) => (z.object({}));
export const EssentialsAcademicSelectionSchema = (constraints: CheckoutContextFieldConstraints) => (z.object({}));

// NEW ROUTES - Essentials flow
export const EssentialsPageRoute = {
EssentialsAcademicSelection: `/${CheckoutStepKey.Essentials}/${CheckoutSubstepKey.AcademicSelection}`,
} as const;

export const CheckoutPageRoute = {
...EssentialsPageRoute,
PlanDetails: `/${CheckoutStepKey.PlanDetails}`,
PlanDetailsLogin: `/${CheckoutStepKey.PlanDetails}/${CheckoutSubstepKey.Login}`,
PlanDetailsRegister: `/${CheckoutStepKey.PlanDetails}/${CheckoutSubstepKey.Register}`,
Expand All @@ -290,18 +298,13 @@ export const CheckoutPageRoute = {
BillingDetailsSuccess: `/${CheckoutStepKey.BillingDetails}/${CheckoutSubstepKey.Success}`,
} as const;

// NEW ROUTES - Essentials flow
export const EssentialsPageRoute = {
AcademicSelection: `/essentials/${EssentialsStepKey.AcademicSelection}`,
} as const;

// NEW PAGE DETAILS - Essentials flow
export const EssentialsPageDetails = {
AcademicSelection: {
step: 'AcademicSelection',
substep: undefined,
formSchema: AcademicSelectionSchema,
route: EssentialsPageRoute.AcademicSelection,
EssentialsAcademicSelection: {
step: 'Essentials',
substep: 'AcademicSelection',
formSchema: EssentialsAcademicSelectionSchema,
route: EssentialsPageRoute.EssentialsAcademicSelection,
title: defineMessages({
id: 'essentials.academicSelection.title',
defaultMessage: 'Academic Selection',
Expand All @@ -312,6 +315,7 @@ export const EssentialsPageDetails = {
} as const;

export const CheckoutPageDetails: { [K in CheckoutPage]: CheckoutPageDetails } = {
...EssentialsPageDetails,
PlanDetails: {
step: 'PlanDetails',
substep: undefined,
Expand Down Expand Up @@ -413,12 +417,14 @@ export const authenticatedSteps = [
] as const;

export enum DataStoreKey {
EssentialsAcademicSelection = 'EssentialsAcademicSelection',
PlanDetails = 'PlanDetails',
AccountDetails = 'AccountDetails',
BillingDetails = 'BillingDetails',
}

export enum SubmitCallbacks {
EssentialsAcademicSelection = 'EssentialsAcademicSelection',
PlanDetails = 'PlanDetails',
PlanDetailsLogin = 'PlanDetailsLogin',
PlanDetailsRegister = 'PlanDetailsRegister',
Expand Down
Loading