richie-education 2.18.0 → 2.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -175,6 +175,10 @@
175
175
  "description": "Accessible text for the initial loading spinner displayed when product is fetching",
176
176
  "message": "Loading product information..."
177
177
  },
178
+ "components.CourseProductItem.pending": {
179
+ "description": "Message displayed when authenticated user has purchased the product but order is still pending",
180
+ "message": "Pending"
181
+ },
178
182
  "components.CourseProductsList.end": {
179
183
  "description": "End label displayed in the header of course run dates section",
180
184
  "message": "End"
@@ -827,14 +831,22 @@
827
831
  "description": "Error message shown to the user when enrollment update request fails.",
828
832
  "message": "An error occurred while updating the enrollment. Please retry later."
829
833
  },
830
- "hooks.useProduct.errorNotFound": {
831
- "description": "Error message shown to the user when no product matches.",
832
- "message": "Cannot find the product"
834
+ "hooks.useOrders.errorGet": {
835
+ "description": "Error message shown to the user when orders fetch request fails.",
836
+ "message": "An error occurred while fetching orders. Please retry later."
837
+ },
838
+ "hooks.useOrders.errorNotFound": {
839
+ "description": "Error message shown to the user when no orders matches.",
840
+ "message": "Cannot find the orders."
833
841
  },
834
- "hooks.useProduct.errorSelect": {
842
+ "hooks.useProduct.errorGet": {
835
843
  "description": "Error message shown to the user when product fetch request fails.",
836
844
  "message": "An error occurred while fetching product. Please retry later."
837
845
  },
846
+ "hooks.useProduct.errorNotFound": {
847
+ "description": "Error message shown to the user when no product matches.",
848
+ "message": "Cannot find the product."
849
+ },
838
850
  "hooks.useResources.errorCreate": {
839
851
  "description": "Error message shown to the user when resource creation request fails.",
840
852
  "message": "An error occurred while creating a resource. Please retry later."
@@ -175,6 +175,10 @@
175
175
  "description": "Accessible text for the initial loading spinner displayed when product is fetching",
176
176
  "message": "Loading product information..."
177
177
  },
178
+ "components.CourseProductItem.pending": {
179
+ "description": "Message displayed when authenticated user has purchased the product but order is still pending",
180
+ "message": "Pending"
181
+ },
178
182
  "components.CourseProductsList.end": {
179
183
  "description": "End label displayed in the header of course run dates section",
180
184
  "message": "Fin"
@@ -827,14 +831,22 @@
827
831
  "description": "Error message shown to the user when enrollment update request fails.",
828
832
  "message": "An error occurred while updating the enrollment. Please retry later."
829
833
  },
830
- "hooks.useProduct.errorNotFound": {
831
- "description": "Error message shown to the user when no product matches.",
832
- "message": "Cannot find the product"
834
+ "hooks.useOrders.errorGet": {
835
+ "description": "Error message shown to the user when orders fetch request fails.",
836
+ "message": "An error occurred while fetching orders. Please retry later."
837
+ },
838
+ "hooks.useOrders.errorNotFound": {
839
+ "description": "Error message shown to the user when no orders matches.",
840
+ "message": "Cannot find the orders."
833
841
  },
834
- "hooks.useProduct.errorSelect": {
842
+ "hooks.useProduct.errorGet": {
835
843
  "description": "Error message shown to the user when product fetch request fails.",
836
844
  "message": "An error occurred while fetching product. Please retry later."
837
845
  },
846
+ "hooks.useProduct.errorNotFound": {
847
+ "description": "Error message shown to the user when no product matches.",
848
+ "message": "Cannot find the product."
849
+ },
838
850
  "hooks.useResources.errorCreate": {
839
851
  "description": "Error message shown to the user when resource creation request fails.",
840
852
  "message": "An error occurred while creating a resource. Please retry later."
@@ -175,6 +175,10 @@
175
175
  "description": "Accessible text for the initial loading spinner displayed when product is fetching",
176
176
  "message": "Loading product information..."
177
177
  },
178
+ "components.CourseProductItem.pending": {
179
+ "description": "Message displayed when authenticated user has purchased the product but order is still pending",
180
+ "message": "Pending"
181
+ },
178
182
  "components.CourseProductsList.end": {
179
183
  "description": "End label displayed in the header of course run dates section",
180
184
  "message": "End"
@@ -827,14 +831,22 @@
827
831
  "description": "Error message shown to the user when enrollment update request fails.",
828
832
  "message": "An error occurred while updating the enrollment. Please retry later."
829
833
  },
830
- "hooks.useProduct.errorNotFound": {
831
- "description": "Error message shown to the user when no product matches.",
832
- "message": "Cannot find the product"
834
+ "hooks.useOrders.errorGet": {
835
+ "description": "Error message shown to the user when orders fetch request fails.",
836
+ "message": "An error occurred while fetching orders. Please retry later."
837
+ },
838
+ "hooks.useOrders.errorNotFound": {
839
+ "description": "Error message shown to the user when no orders matches.",
840
+ "message": "Cannot find the orders."
833
841
  },
834
- "hooks.useProduct.errorSelect": {
842
+ "hooks.useProduct.errorGet": {
835
843
  "description": "Error message shown to the user when product fetch request fails.",
836
844
  "message": "An error occurred while fetching product. Please retry later."
837
845
  },
846
+ "hooks.useProduct.errorNotFound": {
847
+ "description": "Error message shown to the user when no product matches.",
848
+ "message": "Cannot find the product."
849
+ },
838
850
  "hooks.useResources.errorCreate": {
839
851
  "description": "Error message shown to the user when resource creation request fails.",
840
852
  "message": "An error occurred while creating a resource. Please retry later."
@@ -175,6 +175,10 @@
175
175
  "description": "Accessible text for the initial loading spinner displayed when product is fetching",
176
176
  "message": "Chargement des informations du produit..."
177
177
  },
178
+ "components.CourseProductItem.pending": {
179
+ "description": "Message displayed when authenticated user has purchased the product but order is still pending",
180
+ "message": "Pending"
181
+ },
178
182
  "components.CourseProductsList.end": {
179
183
  "description": "End label displayed in the header of course run dates section",
180
184
  "message": "Fin"
@@ -827,13 +831,21 @@
827
831
  "description": "Error message shown to the user when enrollment update request fails.",
828
832
  "message": "An error occurred while updating the enrollment. Please retry later."
829
833
  },
830
- "hooks.useProduct.errorNotFound": {
831
- "description": "Error message shown to the user when no product matches.",
832
- "message": "Impossible de trouver le produit"
834
+ "hooks.useOrders.errorGet": {
835
+ "description": "Error message shown to the user when orders fetch request fails.",
836
+ "message": "An error occurred while fetching orders. Please retry later."
837
+ },
838
+ "hooks.useOrders.errorNotFound": {
839
+ "description": "Error message shown to the user when no orders matches.",
840
+ "message": "Cannot find the orders."
833
841
  },
834
- "hooks.useProduct.errorSelect": {
842
+ "hooks.useProduct.errorGet": {
835
843
  "description": "Error message shown to the user when product fetch request fails.",
836
- "message": "Une erreur s'est produite lors de la récupération du produit. Veuillez réessayer plus tard."
844
+ "message": "An error occurred while fetching product. Please retry later."
845
+ },
846
+ "hooks.useProduct.errorNotFound": {
847
+ "description": "Error message shown to the user when no product matches.",
848
+ "message": "Cannot find the product."
837
849
  },
838
850
  "hooks.useResources.errorCreate": {
839
851
  "description": "Error message shown to the user when resource creation request fails.",
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "16uca+": {
3
3
  "description": "",
4
- "message": "Sub \"{value}\""
4
+ "message": "Sous \"{value}\""
5
5
  },
6
6
  "9vqPaF": {
7
7
  "description": "",
8
- "message": "Root"
8
+ "message": "Racine"
9
9
  },
10
10
  "components.AddressesManagement.actionPromotion": {
11
11
  "description": "Action name for address promotion.",
@@ -175,6 +175,10 @@
175
175
  "description": "Accessible text for the initial loading spinner displayed when product is fetching",
176
176
  "message": "Chargement des informations produit..."
177
177
  },
178
+ "components.CourseProductItem.pending": {
179
+ "description": "Message displayed when authenticated user has purchased the product but order is still pending",
180
+ "message": "En attente"
181
+ },
178
182
  "components.CourseProductsList.end": {
179
183
  "description": "End label displayed in the header of course run dates section",
180
184
  "message": "Fin"
@@ -827,14 +831,22 @@
827
831
  "description": "Error message shown to the user when enrollment update request fails.",
828
832
  "message": "Une erreur s'est produite lors de la modification de votre inscription. Veuillez réessayer plus tard."
829
833
  },
830
- "hooks.useProduct.errorNotFound": {
831
- "description": "Error message shown to the user when no product matches.",
832
- "message": "Produit introuvable"
834
+ "hooks.useOrders.errorGet": {
835
+ "description": "Error message shown to the user when orders fetch request fails.",
836
+ "message": "Une erreur s'est produite lors de la récupération de vos commandes. Veuillez réessayer plus tard."
837
+ },
838
+ "hooks.useOrders.errorNotFound": {
839
+ "description": "Error message shown to the user when no orders matches.",
840
+ "message": "Commandes introuvables."
833
841
  },
834
- "hooks.useProduct.errorSelect": {
842
+ "hooks.useProduct.errorGet": {
835
843
  "description": "Error message shown to the user when product fetch request fails.",
836
844
  "message": "Une erreur s'est produite lors de la récupération du produit. Veuillez réessayer plus tard."
837
845
  },
846
+ "hooks.useProduct.errorNotFound": {
847
+ "description": "Error message shown to the user when no product matches.",
848
+ "message": "Produit introuvable."
849
+ },
838
850
  "hooks.useResources.errorCreate": {
839
851
  "description": "Error message shown to the user when resource creation request fails.",
840
852
  "message": "Une erreur s'est produite lors de la création de la ressource. Veuillez réessayer plus tard."
@@ -175,6 +175,10 @@
175
175
  "description": "Accessible text for the initial loading spinner displayed when product is fetching",
176
176
  "message": "Loading product information..."
177
177
  },
178
+ "components.CourseProductItem.pending": {
179
+ "description": "Message displayed when authenticated user has purchased the product but order is still pending",
180
+ "message": "Pending"
181
+ },
178
182
  "components.CourseProductsList.end": {
179
183
  "description": "End label displayed in the header of course run dates section",
180
184
  "message": "End"
@@ -827,14 +831,22 @@
827
831
  "description": "Error message shown to the user when enrollment update request fails.",
828
832
  "message": "An error occurred while updating the enrollment. Please retry later."
829
833
  },
830
- "hooks.useProduct.errorNotFound": {
831
- "description": "Error message shown to the user when no product matches.",
832
- "message": "Cannot find the product"
834
+ "hooks.useOrders.errorGet": {
835
+ "description": "Error message shown to the user when orders fetch request fails.",
836
+ "message": "An error occurred while fetching orders. Please retry later."
837
+ },
838
+ "hooks.useOrders.errorNotFound": {
839
+ "description": "Error message shown to the user when no orders matches.",
840
+ "message": "Cannot find the orders."
833
841
  },
834
- "hooks.useProduct.errorSelect": {
842
+ "hooks.useProduct.errorGet": {
835
843
  "description": "Error message shown to the user when product fetch request fails.",
836
844
  "message": "An error occurred while fetching product. Please retry later."
837
845
  },
846
+ "hooks.useProduct.errorNotFound": {
847
+ "description": "Error message shown to the user when no product matches.",
848
+ "message": "Cannot find the product."
849
+ },
838
850
  "hooks.useResources.errorCreate": {
839
851
  "description": "Error message shown to the user when resource creation request fails.",
840
852
  "message": "An error occurred while creating a resource. Please retry later."
@@ -175,6 +175,10 @@
175
175
  "description": "Accessible text for the initial loading spinner displayed when product is fetching",
176
176
  "message": "A carregar informação do produto..."
177
177
  },
178
+ "components.CourseProductItem.pending": {
179
+ "description": "Message displayed when authenticated user has purchased the product but order is still pending",
180
+ "message": "Pending"
181
+ },
178
182
  "components.CourseProductsList.end": {
179
183
  "description": "End label displayed in the header of course run dates section",
180
184
  "message": "Fim"
@@ -827,13 +831,21 @@
827
831
  "description": "Error message shown to the user when enrollment update request fails.",
828
832
  "message": "Ocorreu um erro durante a atualização das inscrições. Por favor, tente mais tarde."
829
833
  },
830
- "hooks.useProduct.errorNotFound": {
831
- "description": "Error message shown to the user when no product matches.",
832
- "message": "Não foi possível localizar o produto"
834
+ "hooks.useOrders.errorGet": {
835
+ "description": "Error message shown to the user when orders fetch request fails.",
836
+ "message": "Ocorreu um erro durante o processamento das encomendas. Por favor, tente mais tarde."
837
+ },
838
+ "hooks.useOrders.errorNotFound": {
839
+ "description": "Error message shown to the user when no orders matches.",
840
+ "message": "Não foi possível encontrar encomendas."
833
841
  },
834
- "hooks.useProduct.errorSelect": {
842
+ "hooks.useProduct.errorGet": {
835
843
  "description": "Error message shown to the user when product fetch request fails.",
836
- "message": "Ocorreu um erro durante o processamento do produto. Tente novamente mais tarde."
844
+ "message": "Ocorreu um erro durante o processamento do produto. Por favor, tente mais tarde."
845
+ },
846
+ "hooks.useProduct.errorNotFound": {
847
+ "description": "Error message shown to the user when no product matches.",
848
+ "message": "Não foi possível encontrar o produto."
837
849
  },
838
850
  "hooks.useResources.errorCreate": {
839
851
  "description": "Error message shown to the user when resource creation request fails.",
@@ -175,6 +175,10 @@
175
175
  "description": "Accessible text for the initial loading spinner displayed when product is fetching",
176
176
  "message": "Loading product information..."
177
177
  },
178
+ "components.CourseProductItem.pending": {
179
+ "description": "Message displayed when authenticated user has purchased the product but order is still pending",
180
+ "message": "Pending"
181
+ },
178
182
  "components.CourseProductsList.end": {
179
183
  "description": "End label displayed in the header of course run dates section",
180
184
  "message": "Окончание"
@@ -827,14 +831,22 @@
827
831
  "description": "Error message shown to the user when enrollment update request fails.",
828
832
  "message": "An error occurred while updating the enrollment. Please retry later."
829
833
  },
830
- "hooks.useProduct.errorNotFound": {
831
- "description": "Error message shown to the user when no product matches.",
832
- "message": "Cannot find the product"
834
+ "hooks.useOrders.errorGet": {
835
+ "description": "Error message shown to the user when orders fetch request fails.",
836
+ "message": "An error occurred while fetching orders. Please retry later."
837
+ },
838
+ "hooks.useOrders.errorNotFound": {
839
+ "description": "Error message shown to the user when no orders matches.",
840
+ "message": "Cannot find the orders."
833
841
  },
834
- "hooks.useProduct.errorSelect": {
842
+ "hooks.useProduct.errorGet": {
835
843
  "description": "Error message shown to the user when product fetch request fails.",
836
844
  "message": "An error occurred while fetching product. Please retry later."
837
845
  },
846
+ "hooks.useProduct.errorNotFound": {
847
+ "description": "Error message shown to the user when no product matches.",
848
+ "message": "Cannot find the product."
849
+ },
838
850
  "hooks.useResources.errorCreate": {
839
851
  "description": "Error message shown to the user when resource creation request fails.",
840
852
  "message": "An error occurred while creating a resource. Please retry later."
@@ -0,0 +1,154 @@
1
+ import { act, fireEvent, render, screen } from '@testing-library/react';
2
+ import fetchMock from 'fetch-mock';
3
+ import { IntlProvider } from 'react-intl';
4
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
5
+ import { ContextFactory as mockContextFactory, ProductFactory } from 'utils/test/factories';
6
+ import { SessionProvider } from 'data/SessionProvider';
7
+ import { createTestQueryClient } from 'utils/test/createTestQueryClient';
8
+ import PurchaseButton from './PurchaseButton';
9
+
10
+ jest.mock('utils/context', () => ({
11
+ __esModule: true,
12
+ default: mockContextFactory({
13
+ authentication: { backend: 'fonzie', endpoint: 'https://auth.endpoint.test' },
14
+ joanie_backend: { endpoint: 'https://joanie.test' },
15
+ }).generate(),
16
+ }));
17
+
18
+ describe('PurchaseButton', () => {
19
+ beforeAll(() => {
20
+ // As dialog is rendered through a Portal, we have to add the DOM element in which the dialog will be rendered.
21
+ const modalExclude = document.createElement('div');
22
+ modalExclude.setAttribute('id', 'modal-exclude');
23
+ document.body.appendChild(modalExclude);
24
+ });
25
+
26
+ afterEach(() => {
27
+ fetchMock.restore();
28
+ });
29
+
30
+ const Wrapper = ({ client, children }: React.PropsWithChildren<{ client: QueryClient }>) => (
31
+ <IntlProvider locale="en">
32
+ <QueryClientProvider client={client}>
33
+ <SessionProvider>{children}</SessionProvider>
34
+ </QueryClientProvider>
35
+ </IntlProvider>
36
+ );
37
+
38
+ it('shows a login button if user is not authenticated', async () => {
39
+ const product = ProductFactory.generate();
40
+
41
+ await act(async () => {
42
+ render(
43
+ <Wrapper client={createTestQueryClient({ user: null })}>
44
+ <PurchaseButton product={product} disabled={false} />
45
+ </Wrapper>,
46
+ );
47
+ });
48
+
49
+ await screen.findByRole('button', { name: `Login to purchase "${product.title}"` });
50
+ });
51
+
52
+ it('shows cta to open sale tunnel when user is authenticated', async () => {
53
+ const product = ProductFactory.generate();
54
+ fetchMock
55
+ .get('https://joanie.test/api/v1.0/addresses/', [])
56
+ .get('https://joanie.test/api/v1.0/credit-cards/', [])
57
+ .get('https://joanie.test/api/v1.0/orders/', []);
58
+
59
+ await act(async () => {
60
+ render(
61
+ <Wrapper client={createTestQueryClient({ user: true })}>
62
+ <PurchaseButton product={product} disabled={false} />
63
+ </Wrapper>,
64
+ );
65
+ });
66
+
67
+ fetchMock.resetHistory();
68
+
69
+ // Only CTA is displayed
70
+ const button = screen.getByRole('button', { name: product.call_to_action });
71
+
72
+ // - SaleTunnel should not be opened
73
+ expect(screen.queryByTestId('SaleTunnel__modal')).toBeNull();
74
+
75
+ // Then user can enter into the sale tunnel and follow its 3 steps
76
+ await act(async () => {
77
+ fireEvent.click(button);
78
+ });
79
+
80
+ // - SaleTunnel should have been opened
81
+ screen.getByTestId('SaleTunnel__modal');
82
+ });
83
+
84
+ it('renders a disabled CTA if one target course has no course runs', async () => {
85
+ const product = ProductFactory.generate();
86
+ product.target_courses[0].course_runs = [];
87
+ fetchMock
88
+ .get('https://joanie.test/api/v1.0/addresses/', [])
89
+ .get('https://joanie.test/api/v1.0/credit-cards/', [])
90
+ .get('https://joanie.test/api/v1.0/orders/', []);
91
+
92
+ await act(async () => {
93
+ render(
94
+ <Wrapper client={createTestQueryClient({ user: true })}>
95
+ <PurchaseButton product={product} disabled={false} />
96
+ </Wrapper>,
97
+ );
98
+ });
99
+
100
+ // CTA is displayed but disabled
101
+ const button: HTMLButtonElement = screen.getByRole('button', { name: product.call_to_action });
102
+ expect(button.disabled).toBe(true);
103
+
104
+ // Further, a message is displayed to explain why the CTA is disabled
105
+ screen.findByText(
106
+ 'At least one course has no course runs, this product is not currently available for sale',
107
+ );
108
+ });
109
+
110
+ it('renders a disabled CTA if product has no target courses', async () => {
111
+ const product = ProductFactory.generate();
112
+ product.target_courses = [];
113
+ fetchMock
114
+ .get('https://joanie.test/api/v1.0/addresses/', [])
115
+ .get('https://joanie.test/api/v1.0/credit-cards/', [])
116
+ .get('https://joanie.test/api/v1.0/orders/', []);
117
+
118
+ await act(async () => {
119
+ render(
120
+ <Wrapper client={createTestQueryClient({ user: true })}>
121
+ <PurchaseButton product={product} disabled={false} />
122
+ </Wrapper>,
123
+ );
124
+ });
125
+
126
+ // CTA is displayed but disabled
127
+ const button: HTMLButtonElement = screen.getByRole('button', { name: product.call_to_action });
128
+ expect(button.disabled).toBe(true);
129
+
130
+ // Further, a message is displayed to explain why the CTA is disabled
131
+ screen.findByText(
132
+ 'At least one course has no course runs, this product is not currently available for sale',
133
+ );
134
+ });
135
+
136
+ it('does not render CTA if disabled property is false', async () => {
137
+ const product = ProductFactory.generate();
138
+ fetchMock
139
+ .get('https://joanie.test/api/v1.0/addresses/', [])
140
+ .get('https://joanie.test/api/v1.0/credit-cards/', [])
141
+ .get('https://joanie.test/api/v1.0/orders/', []);
142
+
143
+ await act(async () => {
144
+ render(
145
+ <Wrapper client={createTestQueryClient({ user: true })}>
146
+ <PurchaseButton product={product} disabled={true} />
147
+ </Wrapper>,
148
+ );
149
+ });
150
+
151
+ // CTA is not displayed
152
+ expect(screen.queryByRole('button', { name: product.call_to_action })).not.toBeInTheDocument();
153
+ });
154
+ });
@@ -0,0 +1,93 @@
1
+ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
2
+ import { useMemo, useState } from 'react';
3
+ import { useSession } from 'data/SessionProvider';
4
+ import * as Joanie from 'types/Joanie';
5
+ import { Priority } from 'types';
6
+ import SaleTunnel from 'components/SaleTunnel';
7
+
8
+ const messages = defineMessages({
9
+ loginToPurchase: {
10
+ defaultMessage: 'Login to purchase {product}',
11
+ description: "Label displayed inside the product's CTA when user is not logged in",
12
+ id: 'components.SaleTunnel.loginToPurchase',
13
+ },
14
+ noCourseRunToPurchase: {
15
+ defaultMessage:
16
+ 'At least one course has no course runs, this product is not currently available for sale',
17
+ description: "Label displayed inside the product's when there is no courseRun",
18
+ id: 'components.SaleTunnel.noCourseRunToPurchase',
19
+ },
20
+ callToActionDescription: {
21
+ defaultMessage: 'Purchase {product}',
22
+ description:
23
+ 'Additional description announced by screen readers when focusing the call to action buying button',
24
+ id: 'components.SaleTunnel.callToActionDescription',
25
+ },
26
+ });
27
+
28
+ interface PurchaseButtonProps {
29
+ product: Joanie.Product;
30
+ disabled: boolean;
31
+ }
32
+
33
+ const PurchaseButton = ({ product, disabled }: PurchaseButtonProps) => {
34
+ const intl = useIntl();
35
+ const { user, login } = useSession();
36
+ const [isSaleTunnelOpen, setIsSaleTunnelOpen] = useState(false);
37
+
38
+ const isOpenedCourseRun = (courseRun: Joanie.CourseRun) =>
39
+ courseRun.state.priority <= Priority.FUTURE_NOT_YET_OPEN;
40
+
41
+ const hasAtLeastOneCourseRun = useMemo(() => {
42
+ return (
43
+ product.target_courses.length > 0 &&
44
+ !product.target_courses.some(({ course_runs }) => !course_runs.some(isOpenedCourseRun))
45
+ );
46
+ }, [product]);
47
+
48
+ if (!user) {
49
+ return (
50
+ <button className="product-item__cta" onClick={login}>
51
+ <FormattedMessage
52
+ {...messages.loginToPurchase}
53
+ values={{ product: <span className="offscreen">&quot;{product.title}&quot;</span> }}
54
+ />
55
+ </button>
56
+ );
57
+ }
58
+
59
+ return (
60
+ <>
61
+ {!disabled && (
62
+ <>
63
+ <button
64
+ data-testid="PurchaseButton__cta"
65
+ className="product-item__cta"
66
+ onClick={() => hasAtLeastOneCourseRun && setIsSaleTunnelOpen(true)}
67
+ // so that the button is explicit on its own, we add a description that doesn't
68
+ // rely on the text coming from the CMS
69
+ // eslint-disable-next-line jsx-a11y/aria-props
70
+ aria-description={intl.formatMessage(messages.callToActionDescription, {
71
+ product: product.title,
72
+ })}
73
+ disabled={!hasAtLeastOneCourseRun}
74
+ >
75
+ {product.call_to_action}
76
+ </button>
77
+ {!hasAtLeastOneCourseRun && (
78
+ <p className="product-item__no-course-run">
79
+ <FormattedMessage {...messages.noCourseRunToPurchase} />
80
+ </p>
81
+ )}
82
+ </>
83
+ )}
84
+ <SaleTunnel
85
+ isOpen={isSaleTunnelOpen}
86
+ product={product}
87
+ onClose={() => setIsSaleTunnelOpen(false)}
88
+ />
89
+ </>
90
+ );
91
+ };
92
+
93
+ export default PurchaseButton;
@@ -8,6 +8,20 @@
8
8
  min-height: 100px;
9
9
  overflow: hidden;
10
10
 
11
+ &--has-error {
12
+ align-items: center;
13
+ border-color: r-theme-val(product-item, feedback-color);
14
+ display: flex;
15
+
16
+ & > .product-widget__content {
17
+ color: r-theme-val(product-item, feedback-color);
18
+ display: flex;
19
+ flex-direction: row;
20
+ gap: 1rem;
21
+ margin-bottom: 0;
22
+ }
23
+ }
24
+
11
25
  &:not(:last-child) {
12
26
  margin-bottom: 1.5rem;
13
27
  }