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
16 changes: 16 additions & 0 deletions src/components/ErrorPage/ErrorPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,22 @@ function getErrorMessage(err: unknown): string | undefined {
return err;
}

// Plain object (e.g. raw API error, Stripe error object) – avoid "[object Object]"
if (err !== null && typeof err === 'object') {
const errObj = err as Record<string, unknown>;
if (typeof errObj.message === 'string') {
return errObj.message;
}
if (typeof errObj.detail === 'string') {
return errObj.detail;
}
try {
return JSON.stringify(errObj, null, 2);
} catch {
return '[Unknown error]';
}
Comment on lines +80 to +84
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

getErrorMessage falls back to pretty-printing the entire thrown object via JSON.stringify(errObj, null, 2) and rendering it to end users. Depending on what is thrown (e.g., API/Stripe error payloads), this can expose sensitive details; prefer returning a safe generic message (or only stringify in non-production environments), and keep full details in logs/telemetry instead.

Suggested change
try {
return JSON.stringify(errObj, null, 2);
} catch {
return '[Unknown error]';
}
// In non-production, include a pretty-printed version to aid debugging.
if (process.env.NODE_ENV !== 'production') {
try {
return JSON.stringify(errObj, null, 2);
} catch {
// fall through to generic message below
}
}
// In production (or if stringification fails), avoid exposing raw error details.
return '[Unknown error]';

Copilot uses AI. Check for mistakes.
}

return undefined;
}

Expand Down
22 changes: 22 additions & 0 deletions src/components/ErrorPage/tests/ErrorPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,4 +127,26 @@ describe('ErrorPage', () => {
renderComponent();
validateText("We're sorry, something went wrong");
});

it('displays error detail from plain object route error', () => {
(useRouteError as jest.Mock).mockReturnValue({ detail: 'Detailed object error' });
renderComponent();
validateText('Detailed object error');
});

it('displays JSON string when plain object has no message or detail', () => {
(useRouteError as jest.Mock).mockReturnValue({ code: 'E_SOMETHING' });
renderComponent();
validateText('"code": "E_SOMETHING"', { exact: false });
});

it('falls back to provided message when route error processing throws', () => {
const fallbackMessage = 'Fallback prop message';
(isRouteErrorResponse as unknown as jest.Mock).mockImplementation(() => {
throw new Error('route parsing failed');
});
(useRouteError as jest.Mock).mockReturnValue({ status: 500, statusText: 'Boom' });
renderComponent({ message: fallbackMessage });
validateText(fallbackMessage);
});
});
73 changes: 62 additions & 11 deletions src/components/FormFields/BillingFormFields.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Form } from '@openedx/paragon';
import { AddressElement, PaymentElement } from '@stripe/react-stripe-js';
import { StripeAddressElementOptions } from '@stripe/stripe-js';
import { StripeAddressElementChangeEvent, StripeAddressElementOptions } from '@stripe/stripe-js';
import { useCallback } from 'react';

import { FieldContainer } from '@/components/FieldContainer';
import { DataStoreKey } from '@/constants/checkout';
import { useCheckoutFormStore } from '@/hooks/useCheckoutFormStore';

import type { UseFormReturn } from 'react-hook-form';

interface BillingFormFieldsProps {
form: UseFormReturn<BillingDetailsData>;
}

const BillingAddressTitle = () => (
<h3 className="mb-3">
Expand Down Expand Up @@ -33,31 +43,72 @@ const BillingPaymentTitle = () => (
</>
);

const BillingFormFields = () => {
/**
* Stripe AddressElement configured for collecting the cardholder’s billing address.
*
* The `mode: "billing"` option ensures the address is tied to the payment method
* and used for fraud checks and payment authorization.
*
* Docs: https://docs.stripe.com/elements/address-element
*/
/**
* BillingFormFields component
*
* Renders the billing address form and payment element for the billing details page.
* Uses Stripe AddressElement and PaymentElement.
* AddressElement provides autocomplete/manual entry and returns
* normalized address fields (line1, line2, city, state, postal code).
*/
const BillingFormFields = ({ form }: BillingFormFieldsProps) => {
const billingDetailsData = useCheckoutFormStore(
(state) => state.formData[DataStoreKey.BillingDetails],
);
const setFormData = useCheckoutFormStore((state) => state.setFormData);

const onAddressChange = useCallback((event: StripeAddressElementChangeEvent) => {
const name = event.value?.name || '';
const country = event.value?.address?.country || '';
const line1 = event.value?.address?.line1 || '';
const line2 = event.value?.address?.line2 || '';
const city = event.value?.address?.city || '';
const state = event.value?.address?.state || '';
const zip = event.value?.address?.postal_code || '';

form.setValue('fullName', name, { shouldValidate: true, shouldDirty: true, shouldTouch: true });
form.setValue('country', country, { shouldValidate: true, shouldDirty: true, shouldTouch: true });
form.setValue('line1', line1, { shouldValidate: true, shouldDirty: true, shouldTouch: true });
form.setValue('line2', line2, { shouldValidate: true, shouldDirty: true, shouldTouch: true });
form.setValue('city', city, { shouldValidate: true, shouldDirty: true, shouldTouch: true });
form.setValue('state', state, { shouldValidate: true, shouldDirty: true, shouldTouch: true });
form.setValue('zip', zip, { shouldValidate: true, shouldDirty: true, shouldTouch: true });

setFormData(DataStoreKey.BillingDetails, {
...billingDetailsData,
fullName: name,
country,
line1,
line2,
city,
state,
zip,
});
}, [billingDetailsData, form, setFormData]);

const addressElementOptions: StripeAddressElementOptions = {
mode: 'billing',
};

return (
<>
<FieldContainer>
<BillingAddressTitle />
<AddressElement
options={addressElementOptions}
onChange={onAddressChange}
/>
{form.formState.errors?.fullName?.message && (
<Form.Control.Feedback type="invalid" hasIcon={false} className="d-block mt-2">
{form.formState.errors.fullName.message}
</Form.Control.Feedback>
)}
Comment on lines +101 to +105
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

Only fullName validation feedback is rendered, but the schema now requires other address fields (country, line1, city, state, zip). If the AddressElement doesn’t populate these (or is incomplete), the user may be blocked from submitting with no visible error. Consider adding a generalized address error message when any of those related fields have validation errors (or integrating validation feedback with the AddressElement).

Copilot uses AI. Check for mistakes.
</FieldContainer>
<FieldContainer>
<BillingPaymentTitle />
<PaymentElement
options={{
layout: 'tabs', // or 'accordion'
layout: 'tabs',
}}
/>
</FieldContainer>
Expand Down
Loading
Loading