From 5542d81db354d15a5566301c7a57f8d136db39f2 Mon Sep 17 00:00:00 2001 From: Hamzah Ullah Date: Wed, 7 Jan 2026 23:29:34 -0500 Subject: [PATCH 1/3] feat: add base metadata --- .../Stepper/CheckoutStepperContainer.tsx | 5 +- .../EssentialsAcademicSelectionContent.tsx | 17 ++++++ .../Stepper/StepperContent/index.ts | 1 + .../Steps/EssentialsAcademicSelection.tsx | 8 +++ .../Stepper/Steps/hooks/useStepperContent.tsx | 2 + src/components/Stepper/Steps/index.ts | 1 + .../EssentialsAcademicSelectionPage.tsx | 46 +++++++++++++++ .../academic-selection-page/index.ts | 1 + .../AccountDetailsPage.tsx | 2 +- .../routes/loaders/checkoutStepperLoader.ts | 1 + .../app/routes/loaders/rootLoader.ts | 10 ++-- src/constants/checkout.ts | 23 +++++--- src/routes.tsx | 56 +++++++++---------- src/types/types.d.ts | 5 +- 14 files changed, 132 insertions(+), 46 deletions(-) create mode 100644 src/components/Stepper/StepperContent/EssentialsAcademicSelectionContent.tsx create mode 100644 src/components/Stepper/Steps/EssentialsAcademicSelection.tsx create mode 100644 src/components/academic-selection-page/EssentialsAcademicSelectionPage.tsx create mode 100644 src/components/academic-selection-page/index.ts diff --git a/src/components/Stepper/CheckoutStepperContainer.tsx b/src/components/Stepper/CheckoutStepperContainer.tsx index 86259bf5..6f30db73 100644 --- a/src/components/Stepper/CheckoutStepperContainer.tsx +++ b/src/components/Stepper/CheckoutStepperContainer.tsx @@ -3,12 +3,13 @@ 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'; const Steps = (): ReactElement => ( <> + @@ -17,7 +18,7 @@ const Steps = (): ReactElement => ( const CheckoutStepperContainer = (): ReactElement => { const { currentStepKey, currentSubstepKey } = useCurrentStep(); - + console.log('Current Step Key', currentStepKey); useEffect(() => { const preventUnload = (e: BeforeUnloadEvent) => { if (currentSubstepKey !== CheckoutSubstepKey.Success) { diff --git a/src/components/Stepper/StepperContent/EssentialsAcademicSelectionContent.tsx b/src/components/Stepper/StepperContent/EssentialsAcademicSelectionContent.tsx new file mode 100644 index 00000000..70a5db68 --- /dev/null +++ b/src/components/Stepper/StepperContent/EssentialsAcademicSelectionContent.tsx @@ -0,0 +1,17 @@ +import type { UseFormReturn } from "react-hook-form"; + +interface EssentialsAcademicSelectionContentProps { + form: UseFormReturn; +} + +const EssentialsAcademicSelectionContent = ({ form }: EssentialsAcademicSelectionContentProps) => { + console.log('Essentials Academic Selection Content', form); + return ( + <> + Essentials Academic Selection + + ); +}; + + +export default EssentialsAcademicSelectionContent; diff --git a/src/components/Stepper/StepperContent/index.ts b/src/components/Stepper/StepperContent/index.ts index 87da8570..920bcce0 100644 --- a/src/components/Stepper/StepperContent/index.ts +++ b/src/components/Stepper/StepperContent/index.ts @@ -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'; diff --git a/src/components/Stepper/Steps/EssentialsAcademicSelection.tsx b/src/components/Stepper/Steps/EssentialsAcademicSelection.tsx new file mode 100644 index 00000000..2af997a0 --- /dev/null +++ b/src/components/Stepper/Steps/EssentialsAcademicSelection.tsx @@ -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 = () => ( + +); + +export default EssentialsAcademicSelection; diff --git a/src/components/Stepper/Steps/hooks/useStepperContent.tsx b/src/components/Stepper/Steps/hooks/useStepperContent.tsx index a44b3756..090799f1 100644 --- a/src/components/Stepper/Steps/hooks/useStepperContent.tsx +++ b/src/components/Stepper/Steps/hooks/useStepperContent.tsx @@ -2,6 +2,7 @@ import { AccountDetailsContent, BillingDetailsContent, BillingDetailsSuccessContent, + EssentialsAcademicSelectionContent, PlanDetailsContent, PlanDetailsLoginContent, PlanDetailsRegisterContent, @@ -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, diff --git a/src/components/Stepper/Steps/index.ts b/src/components/Stepper/Steps/index.ts index f7006aa5..d77ee853 100644 --- a/src/components/Stepper/Steps/index.ts +++ b/src/components/Stepper/Steps/index.ts @@ -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'; diff --git a/src/components/academic-selection-page/EssentialsAcademicSelectionPage.tsx b/src/components/academic-selection-page/EssentialsAcademicSelectionPage.tsx new file mode 100644 index 00000000..f04bcffd --- /dev/null +++ b/src/components/academic-selection-page/EssentialsAcademicSelectionPage.tsx @@ -0,0 +1,46 @@ +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 { useStepperContent } from '@/components/Stepper/Steps/hooks'; +import {CheckoutStepKey, DataStoreKey} from '@/constants/checkout'; +import {useCheckoutFormStore, useCurrentPageDetails} from '@/hooks/index'; +import {useFormValidationConstraints} from "@/components/app/data"; + +const EssentialsAcademicSelectionPage = () => { + console.log('Academic Selection Page'); + 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) + )); + + const form = useForm({ + mode: 'onTouched', + resolver: zodResolver(essentialsAcademicSelectionSchema), + defaultValues: essentialsFormData, + }); + + return ( + <> + + + + + + + + + + ); +}; + +export default EssentialsAcademicSelectionPage; diff --git a/src/components/academic-selection-page/index.ts b/src/components/academic-selection-page/index.ts new file mode 100644 index 00000000..7f1e0782 --- /dev/null +++ b/src/components/academic-selection-page/index.ts @@ -0,0 +1 @@ +export { default as EssentialsAcademicSelectionPage } from './EssentialsAcademicSelectionPage' diff --git a/src/components/account-details-page/AccountDetailsPage.tsx b/src/components/account-details-page/AccountDetailsPage.tsx index b19655e9..7b14b749 100644 --- a/src/components/account-details-page/AccountDetailsPage.tsx +++ b/src/components/account-details-page/AccountDetailsPage.tsx @@ -13,6 +13,7 @@ import { Helmet } from 'react-helmet'; import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; +import AccountDetailsSubmitButton from './AccountDetailsSubmitButton'; import { useCheckoutIntent, useFormValidationConstraints } from '@/components/app/data'; import { useCreateCheckoutSessionMutation } from '@/components/app/data/hooks'; import { queryBffContext, queryBffSuccess } from '@/components/app/data/queries/queries'; @@ -29,7 +30,6 @@ import { } from '@/hooks/index'; import { sendEnterpriseCheckoutTrackingEvent } from '@/utils/common'; -import AccountDetailsSubmitButton from './AccountDetailsSubmitButton'; const AccountDetailsPage: React.FC = () => { const { data: formValidationConstraints } = useFormValidationConstraints(); diff --git a/src/components/app/routes/loaders/checkoutStepperLoader.ts b/src/components/app/routes/loaders/checkoutStepperLoader.ts index f87270f5..caf401ff 100644 --- a/src/components/app/routes/loaders/checkoutStepperLoader.ts +++ b/src/components/app/routes/loaders/checkoutStepperLoader.ts @@ -191,6 +191,7 @@ const makeCheckoutStepperLoader: MakeRouteLoaderFunctionWithQueryClient = functi return async function checkoutStepperLoader({ params = {} }) { const { currentStep, currentSubstep } = getStepFromParams(params); const pageDetails = getCheckoutPageDetails({ step: currentStep, substep: currentSubstep }); + console.log(currentStep, currentSubstep, pageDetails); if (!pageDetails) { // Invalid route, do nothing. 404 page should kick in automatically. return null; diff --git a/src/components/app/routes/loaders/rootLoader.ts b/src/components/app/routes/loaders/rootLoader.ts index fbc1e94f..b441451c 100644 --- a/src/components/app/routes/loaders/rootLoader.ts +++ b/src/components/app/routes/loaders/rootLoader.ts @@ -121,11 +121,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; + // } // Fetch basic info about authenticated user from JWT token, and also hydrate it with additional // information from the `/api/user/v1/accounts/` endpoint. We need access to the diff --git a/src/constants/checkout.ts b/src/constants/checkout.ts index fe701d92..ff496784 100644 --- a/src/constants/checkout.ts +++ b/src/constants/checkout.ts @@ -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', @@ -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}`, @@ -290,17 +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', + Essentials: { + step: 'Essentials', substep: undefined, - formSchema: AcademicSelectionSchema, + formSchema: EssentialsAcademicSelectionSchema, route: EssentialsPageRoute.AcademicSelection, title: defineMessages({ id: 'essentials.academicSelection.title', @@ -312,6 +316,7 @@ export const EssentialsPageDetails = { } as const; export const CheckoutPageDetails: { [K in CheckoutPage]: CheckoutPageDetails } = { + ...EssentialsPageDetails, PlanDetails: { step: 'PlanDetails', substep: undefined, @@ -413,12 +418,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', diff --git a/src/routes.tsx b/src/routes.tsx index 9ea48f09..a73b7413 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -99,34 +99,34 @@ export function getRoutes(queryClient: QueryClient) { loader: getRouteLoader(makeRootLoader, queryClient), errorElement: , children: [ - { - path: 'essentials', - element: ( - - }> - - - - ), - children: [ - { - index: true, - element: , - }, - { - path: EssentialsStepKey.AcademicSelection, - element: ( - - - - ), - }, - ], - }, - { - path: 'essentials/*', - element: , - }, + // { + // path: 'essentials', + // element: ( + // + // }> + // + // + // + // ), + // children: [ + // { + // index: true, + // element: , + // }, + // { + // path: EssentialsStepKey.AcademicSelection, + // element: ( + // + // + // + // ), + // }, + // ], + // }, + // { + // path: 'essentials/*', + // element: , + // }, { path: '/', loader: getRouteLoader(makeRootLoader, queryClient), diff --git a/src/types/types.d.ts b/src/types/types.d.ts index 98bf60a7..3647e945 100644 --- a/src/types/types.d.ts +++ b/src/types/types.d.ts @@ -63,9 +63,9 @@ declare global { * ============================== */ - type CheckoutStep = 'PlanDetails' | 'AccountDetails' | 'BillingDetails'; + type CheckoutStep = 'Essentials' | 'PlanDetails' | 'AccountDetails' | 'BillingDetails'; - type CheckoutSubstep = 'Login' | 'Register' | 'Success'; + type CheckoutSubstep = 'AcademicSelection' |'Login' | 'Register' | 'Success'; type CheckoutPage = 'PlanDetails' | 'PlanDetailsLogin' | 'PlanDetailsRegister' | 'AccountDetails' | 'BillingDetails' | 'BillingDetailsSuccess'; @@ -88,6 +88,7 @@ declare global { /** * Form data types derived from Zod schemas */ + type EssentialAcademicSelectionData = z.infer; type PlanDetailsData = z.infer; type PlanDetailsLoginPageData = z.infer; type PlanDetailsRegisterPageData = z.infer; From e1287094ff152d1f7e0d34ccc66bec2109a2f439 Mon Sep 17 00:00:00 2001 From: Hamzah Ullah Date: Wed, 7 Jan 2026 23:57:16 -0500 Subject: [PATCH 2/3] feat: feature flag academic selection route --- .../Stepper/CheckoutStepperContainer.tsx | 31 ++++++--- .../EssentialsAcademicSelectionContent.tsx | 3 +- .../EssentialsAcademicSelectionPage.tsx | 11 ++-- .../routes/loaders/checkoutStepperLoader.ts | 6 +- .../app/routes/loaders/rootLoader.ts | 8 +-- .../app/routes/tests/rootLoader.test.ts | 10 +-- src/constants/checkout.ts | 6 +- src/types/types.d.ts | 7 +- src/utils/common.ts | 26 ++++++++ src/utils/test/common.test.ts | 65 ++++++++++++++++++- 10 files changed, 138 insertions(+), 35 deletions(-) diff --git a/src/components/Stepper/CheckoutStepperContainer.tsx b/src/components/Stepper/CheckoutStepperContainer.tsx index 6f30db73..e51a90f8 100644 --- a/src/components/Stepper/CheckoutStepperContainer.tsx +++ b/src/components/Stepper/CheckoutStepperContainer.tsx @@ -1,3 +1,4 @@ +import { getConfig } from '@edx/frontend-platform/config'; import { Col, Row, Stack, Stepper } from '@openedx/paragon'; import { ReactElement, useEffect } from 'react'; @@ -6,19 +7,31 @@ import { StepperTitle } from '@/components/Stepper/StepperTitle'; 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 => ( - <> - - - - - -); +const Steps = (): ReactElement => { + const { + FEATURE_SELF_SERVICE_ESSENTIALS, + FEATURE_SELF_SERVICE_ESSENTIALS_KEY, + } = getConfig(); + console.log(isFeatureEnabled(FEATURE_SELF_SERVICE_ESSENTIALS, FEATURE_SELF_SERVICE_ESSENTIALS_KEY)); + return ( + <> + { + isFeatureEnabled( + FEATURE_SELF_SERVICE_ESSENTIALS, + FEATURE_SELF_SERVICE_ESSENTIALS_KEY, + ) && + } + + + + + ); +}; const CheckoutStepperContainer = (): ReactElement => { const { currentStepKey, currentSubstepKey } = useCurrentStep(); - console.log('Current Step Key', currentStepKey); useEffect(() => { const preventUnload = (e: BeforeUnloadEvent) => { if (currentSubstepKey !== CheckoutSubstepKey.Success) { diff --git a/src/components/Stepper/StepperContent/EssentialsAcademicSelectionContent.tsx b/src/components/Stepper/StepperContent/EssentialsAcademicSelectionContent.tsx index 70a5db68..d34d987a 100644 --- a/src/components/Stepper/StepperContent/EssentialsAcademicSelectionContent.tsx +++ b/src/components/Stepper/StepperContent/EssentialsAcademicSelectionContent.tsx @@ -1,11 +1,10 @@ import type { UseFormReturn } from "react-hook-form"; interface EssentialsAcademicSelectionContentProps { - form: UseFormReturn; + form: UseFormReturn; } const EssentialsAcademicSelectionContent = ({ form }: EssentialsAcademicSelectionContentProps) => { - console.log('Essentials Academic Selection Content', form); return ( <> Essentials Academic Selection diff --git a/src/components/academic-selection-page/EssentialsAcademicSelectionPage.tsx b/src/components/academic-selection-page/EssentialsAcademicSelectionPage.tsx index f04bcffd..2c2f59d2 100644 --- a/src/components/academic-selection-page/EssentialsAcademicSelectionPage.tsx +++ b/src/components/academic-selection-page/EssentialsAcademicSelectionPage.tsx @@ -4,13 +4,12 @@ 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'; -import {useFormValidationConstraints} from "@/components/app/data"; +import { CheckoutStepKey, DataStoreKey } from '@/constants/checkout'; +import { useCheckoutFormStore, useCurrentPageDetails } from '@/hooks/index'; const EssentialsAcademicSelectionPage = () => { - console.log('Academic Selection Page'); const StepperContent = useStepperContent(); const { data: formValidationConstraints } = useFormValidationConstraints(); const eventKey = CheckoutStepKey.Essentials; @@ -21,9 +20,9 @@ const EssentialsAcademicSelectionPage = () => { const essentialsAcademicSelectionSchema = useMemo(() => ( formSchema(formValidationConstraints) - )); + ), [formSchema, formValidationConstraints]); - const form = useForm({ + const form = useForm({ mode: 'onTouched', resolver: zodResolver(essentialsAcademicSelectionSchema), defaultValues: essentialsFormData, diff --git a/src/components/app/routes/loaders/checkoutStepperLoader.ts b/src/components/app/routes/loaders/checkoutStepperLoader.ts index caf401ff..3e2f9f8a 100644 --- a/src/components/app/routes/loaders/checkoutStepperLoader.ts +++ b/src/components/app/routes/loaders/checkoutStepperLoader.ts @@ -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 { + return null; +} + /** * Route loader for Plan Details page. * @@ -169,6 +173,7 @@ async function billingDetailsSuccessLoader(queryClient: QueryClient): Promise Promise> = { + EssentialsAcademicSelection: essentialsAcademicSelectionLoader, PlanDetails: planDetailsLoader, PlanDetailsLogin: planDetailsLoginLoader, PlanDetailsRegister: planDetailsRegisterLoader, @@ -191,7 +196,6 @@ const makeCheckoutStepperLoader: MakeRouteLoaderFunctionWithQueryClient = functi return async function checkoutStepperLoader({ params = {} }) { const { currentStep, currentSubstep } = getStepFromParams(params); const pageDetails = getCheckoutPageDetails({ step: currentStep, substep: currentSubstep }); - console.log(currentStep, currentSubstep, pageDetails); if (!pageDetails) { // Invalid route, do nothing. 404 page should kick in automatically. return null; diff --git a/src/components/app/routes/loaders/rootLoader.ts b/src/components/app/routes/loaders/rootLoader.ts index b441451c..f0809609 100644 --- a/src/components/app/routes/loaders/rootLoader.ts +++ b/src/components/app/routes/loaders/rootLoader.ts @@ -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. @@ -89,12 +90,9 @@ const makeRootLoader = ( if (routeFeatureKey) { const sessionKey = sessionStorage.getItem(SSP_SESSION_KEY); - const isUnlockedBySiteKey = !!FEATURE_SELF_SERVICE_SITE_KEY - && sessionKey === FEATURE_SELF_SERVICE_SITE_KEY; + const isUnlocked = isFeatureEnabled(false, routeFeatureKey); - 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 diff --git a/src/components/app/routes/tests/rootLoader.test.ts b/src/components/app/routes/tests/rootLoader.test.ts index 582bc18c..b5512ec5 100644 --- a/src/components/app/routes/tests/rootLoader.test.ts +++ b/src/components/app/routes/tests/rootLoader.test.ts @@ -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'); }); @@ -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( @@ -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'); @@ -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( @@ -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); diff --git a/src/constants/checkout.ts b/src/constants/checkout.ts index ff496784..b2ad9396 100644 --- a/src/constants/checkout.ts +++ b/src/constants/checkout.ts @@ -301,11 +301,11 @@ export const CheckoutPageRoute = { // NEW PAGE DETAILS - Essentials flow export const EssentialsPageDetails = { - Essentials: { + EssentialsAcademicSelection: { step: 'Essentials', - substep: undefined, + substep: 'AcademicSelection', formSchema: EssentialsAcademicSelectionSchema, - route: EssentialsPageRoute.AcademicSelection, + route: EssentialsPageRoute.EssentialsAcademicSelection, title: defineMessages({ id: 'essentials.academicSelection.title', defaultMessage: 'Academic Selection', diff --git a/src/types/types.d.ts b/src/types/types.d.ts index 3647e945..c2950d58 100644 --- a/src/types/types.d.ts +++ b/src/types/types.d.ts @@ -65,9 +65,9 @@ declare global { type CheckoutStep = 'Essentials' | 'PlanDetails' | 'AccountDetails' | 'BillingDetails'; - type CheckoutSubstep = 'AcademicSelection' |'Login' | 'Register' | 'Success'; + type CheckoutSubstep = 'AcademicSelection' | 'Login' | 'Register' | 'Success'; - type CheckoutPage = 'PlanDetails' | 'PlanDetailsLogin' | 'PlanDetailsRegister' | 'AccountDetails' | 'BillingDetails' | 'BillingDetailsSuccess'; + type CheckoutPage = 'EssentialsAcademicSelection' | 'PlanDetails' | 'PlanDetailsLogin' | 'PlanDetailsRegister' | 'AccountDetails' | 'BillingDetails' | 'BillingDetailsSuccess'; export type CheckoutPageRouteValue = (typeof CheckoutPageRoute)[keyof typeof CheckoutPageRoute]; @@ -88,7 +88,7 @@ declare global { /** * Form data types derived from Zod schemas */ - type EssentialAcademicSelectionData = z.infer; + type EssentialAcademicSelectionData = z.infer; type PlanDetailsData = z.infer; type PlanDetailsLoginPageData = z.infer; type PlanDetailsRegisterPageData = z.infer; @@ -99,6 +99,7 @@ declare global { * Maps step names to their corresponding data types */ interface StepDataMap { + 'EssentialsAcademicSelection': Partial; 'PlanDetails': Partial; 'AccountDetails': Partial; 'BillingDetails': Partial; diff --git a/src/utils/common.ts b/src/utils/common.ts index a52c4bb3..3ece1348 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -1,6 +1,7 @@ import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import { logError } from '@edx/frontend-platform/logging'; import dayjs from 'dayjs'; +import {getConfig} from "@edx/frontend-platform/config"; /** * Given an error, returns the status code from the custom attributes (Axios error) @@ -109,6 +110,30 @@ const sendEnterpriseCheckoutTrackingEvent = ({ ); }; +/** + * Determines whether a feature is enabled. + * A feature is enabled if its configuration value is true, or if a valid + * feature key or site key is present in the session storage. + * + * @param {boolean} enabled - The configuration value for the feature. + * @param {string|null} featureKey - The specific override key for the feature. + * @returns {boolean} True if the feature is enabled; otherwise, false. + */ +const isFeatureEnabled = (enabled: boolean, featureKey?: string | null): boolean => { + const SSP_SESSION_KEY = 'edx.checkout.self-service-purchasing'; + const { FEATURE_SELF_SERVICE_SITE_KEY } = getConfig(); + const sessionKey = sessionStorage.getItem(SSP_SESSION_KEY); + + if (enabled) { + return true; + } + + const isUnlockedBySiteKey = !!FEATURE_SELF_SERVICE_SITE_KEY && sessionKey === FEATURE_SELF_SERVICE_SITE_KEY; + const isUnlockedByFeatureKey = !!featureKey && sessionKey === featureKey; + + return isUnlockedBySiteKey || isUnlockedByFeatureKey; +}; + export { defaultQueryClientRetryHandler, getComputedStylePropertyCSSVariable, @@ -117,4 +142,5 @@ export { serverValidationError, isExpired, sendEnterpriseCheckoutTrackingEvent, + isFeatureEnabled, }; diff --git a/src/utils/test/common.test.ts b/src/utils/test/common.test.ts index 37f564a2..056126cf 100644 --- a/src/utils/test/common.test.ts +++ b/src/utils/test/common.test.ts @@ -1,7 +1,19 @@ import dayjs from 'dayjs'; +import { getConfig } from '@edx/frontend-platform/config'; import { CheckoutErrorMessagesByField } from '@/constants/checkout'; -import { defaultQueryClientRetryHandler, isExpired, serverValidationError } from '@/utils/common'; +import { + defaultQueryClientRetryHandler, + isExpired, + serverValidationError, + isFeatureEnabled, +} from '@/utils/common'; + +jest.mock('@edx/frontend-platform/config', () => ({ + getConfig: jest.fn(() => ({ + PUBLIC_PATH: '/', + })), +})); describe('defaultQueryClientRetryHandler', () => { it.each([ @@ -107,3 +119,54 @@ describe('isExpired', () => { expect(isExpired(equalToNow)).toBe(false); }); }); + +describe('isFeatureEnabled', () => { + const SSP_SESSION_KEY = 'edx.checkout.self-service-purchasing'; + + beforeEach(() => { + sessionStorage.clear(); + jest.clearAllMocks(); + }); + + it('returns true when enabled is true', () => { + (getConfig as jest.Mock).mockReturnValue({}); + expect(isFeatureEnabled(true)).toBe(true); + }); + + it('returns false when enabled is false and no keys are in session', () => { + (getConfig as jest.Mock).mockReturnValue({ + FEATURE_SELF_SERVICE_SITE_KEY: 'site-key', + }); + expect(isFeatureEnabled(false, 'feature-key')).toBe(false); + }); + + it('returns true when enabled is false but featureKey matches session storage', () => { + (getConfig as jest.Mock).mockReturnValue({}); + sessionStorage.setItem(SSP_SESSION_KEY, 'feature-key'); + expect(isFeatureEnabled(false, 'feature-key')).toBe(true); + }); + + it('returns true when enabled is false but siteKey matches session storage', () => { + (getConfig as jest.Mock).mockReturnValue({ + FEATURE_SELF_SERVICE_SITE_KEY: 'site-key', + }); + sessionStorage.setItem(SSP_SESSION_KEY, 'site-key'); + expect(isFeatureEnabled(false, 'feature-key')).toBe(true); + }); + + it('returns false when session storage key does not match either featureKey or siteKey', () => { + (getConfig as jest.Mock).mockReturnValue({ + FEATURE_SELF_SERVICE_SITE_KEY: 'site-key', + }); + sessionStorage.setItem(SSP_SESSION_KEY, 'wrong-key'); + expect(isFeatureEnabled(false, 'feature-key')).toBe(false); + }); + + it('returns true if enabled is false but siteKey is matched, even if featureKey is null', () => { + (getConfig as jest.Mock).mockReturnValue({ + FEATURE_SELF_SERVICE_SITE_KEY: 'site-key', + }); + sessionStorage.setItem(SSP_SESSION_KEY, 'site-key'); + expect(isFeatureEnabled(false, null)).toBe(true); + }); +}); From 57c8065f6cb0a8bfcfee92ba7da324d22e429ae5 Mon Sep 17 00:00:00 2001 From: Hamzah Ullah Date: Thu, 8 Jan 2026 00:08:00 -0500 Subject: [PATCH 3/3] chore: lint errors --- .../Stepper/CheckoutStepperContainer.tsx | 1 - .../EssentialsAcademicSelectionContent.tsx | 16 +++++++--------- .../Steps/EssentialsAcademicSelection.tsx | 2 +- src/components/academic-selection-page/index.ts | 2 +- .../account-details-page/AccountDetailsPage.tsx | 2 +- src/components/app/routes/loaders/rootLoader.ts | 2 -- src/components/app/routes/loaders/utils.ts | 1 + .../plan-details-pages/PlanDetailsPage.tsx | 6 +++--- src/constants/checkout.ts | 1 - src/routes.tsx | 3 +-- src/utils/common.ts | 2 +- src/utils/test/common.test.ts | 4 ++-- 12 files changed, 18 insertions(+), 24 deletions(-) diff --git a/src/components/Stepper/CheckoutStepperContainer.tsx b/src/components/Stepper/CheckoutStepperContainer.tsx index e51a90f8..c343ffb8 100644 --- a/src/components/Stepper/CheckoutStepperContainer.tsx +++ b/src/components/Stepper/CheckoutStepperContainer.tsx @@ -14,7 +14,6 @@ const Steps = (): ReactElement => { FEATURE_SELF_SERVICE_ESSENTIALS, FEATURE_SELF_SERVICE_ESSENTIALS_KEY, } = getConfig(); - console.log(isFeatureEnabled(FEATURE_SELF_SERVICE_ESSENTIALS, FEATURE_SELF_SERVICE_ESSENTIALS_KEY)); return ( <> { diff --git a/src/components/Stepper/StepperContent/EssentialsAcademicSelectionContent.tsx b/src/components/Stepper/StepperContent/EssentialsAcademicSelectionContent.tsx index d34d987a..99465115 100644 --- a/src/components/Stepper/StepperContent/EssentialsAcademicSelectionContent.tsx +++ b/src/components/Stepper/StepperContent/EssentialsAcademicSelectionContent.tsx @@ -1,16 +1,14 @@ -import type { UseFormReturn } from "react-hook-form"; +import type { UseFormReturn } from 'react-hook-form'; interface EssentialsAcademicSelectionContentProps { form: UseFormReturn; } -const EssentialsAcademicSelectionContent = ({ form }: EssentialsAcademicSelectionContentProps) => { - return ( - <> - Essentials Academic Selection - - ); -}; - +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const EssentialsAcademicSelectionContent = ({ form: _form }: EssentialsAcademicSelectionContentProps) => ( + <> + Essentials Academic Selection + +); export default EssentialsAcademicSelectionContent; diff --git a/src/components/Stepper/Steps/EssentialsAcademicSelection.tsx b/src/components/Stepper/Steps/EssentialsAcademicSelection.tsx index 2af997a0..127bc4b8 100644 --- a/src/components/Stepper/Steps/EssentialsAcademicSelection.tsx +++ b/src/components/Stepper/Steps/EssentialsAcademicSelection.tsx @@ -1,4 +1,4 @@ -import { EssentialsAcademicSelectionPage} from "@/components/academic-selection-page"; +import { EssentialsAcademicSelectionPage } from '@/components/academic-selection-page'; // TODO: unnecessary layer of abstraction, just move component logic into this file. const EssentialsAcademicSelection: React.FC = () => ( diff --git a/src/components/academic-selection-page/index.ts b/src/components/academic-selection-page/index.ts index 7f1e0782..090744cf 100644 --- a/src/components/academic-selection-page/index.ts +++ b/src/components/academic-selection-page/index.ts @@ -1 +1 @@ -export { default as EssentialsAcademicSelectionPage } from './EssentialsAcademicSelectionPage' +export { default as EssentialsAcademicSelectionPage } from './EssentialsAcademicSelectionPage'; diff --git a/src/components/account-details-page/AccountDetailsPage.tsx b/src/components/account-details-page/AccountDetailsPage.tsx index 7b14b749..b19655e9 100644 --- a/src/components/account-details-page/AccountDetailsPage.tsx +++ b/src/components/account-details-page/AccountDetailsPage.tsx @@ -13,7 +13,6 @@ import { Helmet } from 'react-helmet'; import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; -import AccountDetailsSubmitButton from './AccountDetailsSubmitButton'; import { useCheckoutIntent, useFormValidationConstraints } from '@/components/app/data'; import { useCreateCheckoutSessionMutation } from '@/components/app/data/hooks'; import { queryBffContext, queryBffSuccess } from '@/components/app/data/queries/queries'; @@ -30,6 +29,7 @@ import { } from '@/hooks/index'; import { sendEnterpriseCheckoutTrackingEvent } from '@/utils/common'; +import AccountDetailsSubmitButton from './AccountDetailsSubmitButton'; const AccountDetailsPage: React.FC = () => { const { data: formValidationConstraints } = useFormValidationConstraints(); diff --git a/src/components/app/routes/loaders/rootLoader.ts b/src/components/app/routes/loaders/rootLoader.ts index f0809609..2101a671 100644 --- a/src/components/app/routes/loaders/rootLoader.ts +++ b/src/components/app/routes/loaders/rootLoader.ts @@ -88,8 +88,6 @@ const makeRootLoader = ( // Feature flag check if (routeFeatureKey) { - const sessionKey = sessionStorage.getItem(SSP_SESSION_KEY); - const isUnlocked = isFeatureEnabled(false, routeFeatureKey); if (!isUnlocked) { diff --git a/src/components/app/routes/loaders/utils.ts b/src/components/app/routes/loaders/utils.ts index 7cd2b0a6..c5d73f11 100644 --- a/src/components/app/routes/loaders/utils.ts +++ b/src/components/app/routes/loaders/utils.ts @@ -210,6 +210,7 @@ interface PrerequisiteCheck { * and the route that should be returned if that slice is invalid. */ export const prerequisiteSpec: Record>> = { + Essentials: [], PlanDetails: [], AccountDetails: [ { diff --git a/src/components/plan-details-pages/PlanDetailsPage.tsx b/src/components/plan-details-pages/PlanDetailsPage.tsx index 5fe0f49a..34f22731 100644 --- a/src/components/plan-details-pages/PlanDetailsPage.tsx +++ b/src/components/plan-details-pages/PlanDetailsPage.tsx @@ -153,9 +153,9 @@ const PlanDetailsPage = () => { }, }); - const onSubmitCallbacks: { - [K in SubmitCallbacks]: (data: PlanDetailsData | PlanDetailsLoginPageData | PlanDetailsRegisterPageData) => void - } = { + const onSubmitCallbacks: Record void> = { + [SubmitCallbacks.EssentialsAcademicSelection]: () => {}, [SubmitCallbacks.PlanDetails]: async (data: PlanDetailsData) => { const { validationDecisions, isValid: isValidAdminEmailField } = await validateFieldDetailed( 'adminEmail', diff --git a/src/constants/checkout.ts b/src/constants/checkout.ts index b2ad9396..86546d26 100644 --- a/src/constants/checkout.ts +++ b/src/constants/checkout.ts @@ -298,7 +298,6 @@ export const CheckoutPageRoute = { BillingDetailsSuccess: `/${CheckoutStepKey.BillingDetails}/${CheckoutSubstepKey.Success}`, } as const; - // NEW PAGE DETAILS - Essentials flow export const EssentialsPageDetails = { EssentialsAcademicSelection: { diff --git a/src/routes.tsx b/src/routes.tsx index a73b7413..347b4f80 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -10,8 +10,7 @@ import AppShell from '@/components/app/routes/AppShell'; import { makeCheckoutStepperLoader, makeRootLoader } from '@/components/app/routes/loaders'; import RouterFallback from '@/components/app/routes/RouterFallback'; import CheckoutPage from '@/components/checkout-page/CheckoutPage'; -import AcademicSelection from '@/components/essentials-page/AcademicSelection'; -import { authenticatedSteps, CheckoutStepKey, EssentialsStepKey } from '@/constants/checkout'; +import { authenticatedSteps, CheckoutStepKey } from '@/constants/checkout'; import { ErrorPage } from './components/ErrorPage'; diff --git a/src/utils/common.ts b/src/utils/common.ts index 3ece1348..876139ef 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -1,7 +1,7 @@ import { sendTrackEvent } from '@edx/frontend-platform/analytics'; +import { getConfig } from '@edx/frontend-platform/config'; import { logError } from '@edx/frontend-platform/logging'; import dayjs from 'dayjs'; -import {getConfig} from "@edx/frontend-platform/config"; /** * Given an error, returns the status code from the custom attributes (Axios error) diff --git a/src/utils/test/common.test.ts b/src/utils/test/common.test.ts index 056126cf..bb244780 100644 --- a/src/utils/test/common.test.ts +++ b/src/utils/test/common.test.ts @@ -1,12 +1,12 @@ -import dayjs from 'dayjs'; import { getConfig } from '@edx/frontend-platform/config'; +import dayjs from 'dayjs'; import { CheckoutErrorMessagesByField } from '@/constants/checkout'; import { defaultQueryClientRetryHandler, isExpired, - serverValidationError, isFeatureEnabled, + serverValidationError, } from '@/utils/common'; jest.mock('@edx/frontend-platform/config', () => ({