richie-education 2.28.2-dev26 → 2.28.2-dev39

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.
Files changed (31) hide show
  1. package/js/api/joanie.ts +30 -1
  2. package/js/components/{SaleTunnel/CreditCardSelector → CreditCardSelector}/index.spec.tsx +8 -38
  3. package/js/components/{SaleTunnel/CreditCardSelector → CreditCardSelector}/index.tsx +15 -22
  4. package/js/components/PaymentScheduleGrid/_styles.scss +13 -0
  5. package/js/components/PaymentScheduleGrid/index.tsx +50 -70
  6. package/js/components/PurchaseButton/index.spec.tsx +27 -12
  7. package/js/components/SaleTunnel/GenericPaymentButton/index.tsx +2 -7
  8. package/js/components/SaleTunnel/GenericSaleTunnel.tsx +22 -3
  9. package/js/components/SaleTunnel/SaleTunnelInformation/index.tsx +43 -17
  10. package/js/components/SaleTunnel/Sponsors/SaleTunnelSponsors.scss +4 -5
  11. package/js/components/SaleTunnel/Sponsors/SaleTunnelSponsors.tsx +34 -11
  12. package/js/components/SaleTunnel/_styles.scss +6 -4
  13. package/js/components/SaleTunnel/index.credential.spec.tsx +5 -7
  14. package/js/components/SaleTunnel/index.full-process.spec.tsx +7 -1
  15. package/js/components/SaleTunnel/index.spec.tsx +127 -61
  16. package/js/hooks/usePaymentSchedule.tsx +23 -0
  17. package/js/hooks/useResources/useResourcesRoot.ts +3 -3
  18. package/js/index.tsx +2 -0
  19. package/js/types/Joanie.ts +31 -0
  20. package/js/utils/OrderHelper/index.ts +13 -0
  21. package/js/utils/test/factories/joanie.ts +32 -19
  22. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.spec.tsx +110 -2
  23. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.tsx +90 -2
  24. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderPaymentDetailsModal/_styles.scss +7 -0
  25. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderPaymentDetailsModal/index.tsx +106 -0
  26. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderPaymentRetryModal/index.tsx +212 -0
  27. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateLearnerMessage/index.tsx +16 -0
  28. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateMessage/index.tsx +3 -0
  29. package/package.json +2 -1
  30. package/scss/components/_index.scss +2 -1
  31. /package/js/components/{SaleTunnel/CreditCardSelector → CreditCardSelector}/_styles.scss +0 -0
package/js/api/joanie.ts CHANGED
@@ -15,7 +15,8 @@ import context from 'utils/context';
15
15
  import { JOANIE_API_VERSION } from 'settings';
16
16
  import { ResourcesQuery } from 'hooks/useResources';
17
17
  import { ObjectHelper } from 'utils/ObjectHelper';
18
- import { Maybe } from 'types/utils';
18
+ import { Maybe, Nullable } from 'types/utils';
19
+ import { PaymentSchedule } from 'types/Joanie';
19
20
  import { checkStatus, getFileFromResponse } from './utils';
20
21
 
21
22
  /*
@@ -97,6 +98,7 @@ export const getRoutes = () => {
97
98
  download: `${baseUrl}/orders/:id/invoice/`,
98
99
  },
99
100
  submit_for_signature: `${baseUrl}/orders/:id/submit_for_signature/`,
101
+ submit_installment_payment: `${baseUrl}/orders/:id/submit_installment_payment/`,
100
102
  },
101
103
  certificates: {
102
104
  download: `${baseUrl}/certificates/:id/download/`,
@@ -141,6 +143,9 @@ export const getRoutes = () => {
141
143
  },
142
144
  products: {
143
145
  get: `${baseUrl}/courses/:course_id/products/:id/`,
146
+ paymentSchedule: {
147
+ get: `${baseUrl}/courses/:course_id/products/:id/payment-schedule/`,
148
+ },
144
149
  },
145
150
  orders: {
146
151
  get: `${baseUrl}/courses/:course_id/orders/:id/`,
@@ -291,6 +296,11 @@ const API = (): Joanie.API => {
291
296
  fetchWithJWT(ROUTES.user.orders.submit_for_signature.replace(':id', id), {
292
297
  method: 'POST',
293
298
  }).then(checkStatus),
299
+ submit_installment_payment: async (id, payload) =>
300
+ fetchWithJWT(ROUTES.user.orders.submit_installment_payment.replace(':id', id), {
301
+ method: 'POST',
302
+ body: JSON.stringify(payload),
303
+ }).then(checkStatus),
294
304
  },
295
305
  enrollments: {
296
306
  create: async (payload) =>
@@ -418,6 +428,25 @@ const API = (): Joanie.API => {
418
428
 
419
429
  return fetchWithJWT(buildApiUrl(ROUTES.courses.products.get, filters)).then(checkStatus);
420
430
  },
431
+ paymentSchedule: {
432
+ get: async (
433
+ filters?: Joanie.CourseProductQueryFilters,
434
+ ): Promise<Nullable<PaymentSchedule>> => {
435
+ if (!filters) {
436
+ throw new Error(
437
+ 'A course code and a product id are required to fetch a course product',
438
+ );
439
+ } else if (!filters.course_id) {
440
+ throw new Error('A course code is required to fetch a course product');
441
+ } else if (!filters.id) {
442
+ throw new Error('A product id is required to fetch a course product');
443
+ }
444
+
445
+ return fetchWithJWT(
446
+ buildApiUrl(ROUTES.courses.products.paymentSchedule.get, filters),
447
+ ).then(checkStatus);
448
+ },
449
+ },
421
450
  },
422
451
  orders: {
423
452
  get: async (filters?: Joanie.CourseOrderResourceQuery) => {
@@ -1,21 +1,16 @@
1
1
  import { screen, within } from '@testing-library/react';
2
- import { useMemo, useState } from 'react';
2
+ import { useState, useEffect } from 'react';
3
3
  import fetchMock from 'fetch-mock';
4
4
  import { faker } from '@faker-js/faker';
5
5
  import userEvent from '@testing-library/user-event';
6
6
  import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
7
- import { CreditCardSelector } from 'components/SaleTunnel/CreditCardSelector/index';
8
7
  import { render } from 'utils/test/render';
9
8
  import { CreditCard } from 'types/Joanie';
10
- import {
11
- CredentialOrderFactory,
12
- CreditCardFactory,
13
- ProductFactory,
14
- } from 'utils/test/factories/joanie';
15
- import { SaleTunnelProps } from 'components/SaleTunnel/index';
9
+ import { CreditCardFactory } from 'utils/test/factories/joanie';
16
10
  import { setupJoanieSession } from 'utils/test/wrappers/JoanieAppWrapper';
17
11
  import { expectNoSpinner, expectSpinner } from 'utils/test/expectSpinner';
18
- import { SaleTunnelStep, SaleTunnelContext, SaleTunnelContextType } from '../GenericSaleTunnel';
12
+ import { SaleTunnelContextType } from 'components/SaleTunnel/GenericSaleTunnel';
13
+ import { CreditCardSelector } from 'components/CreditCardSelector/index';
19
14
 
20
15
  jest.mock('utils/context', () => ({
21
16
  __esModule: true,
@@ -43,30 +38,10 @@ describe('CreditCardSelector', () => {
43
38
 
44
39
  const Wrapper = () => {
45
40
  const [creditCard, setCreditCard] = useState<CreditCard>();
46
- const context: SaleTunnelContextType = useMemo(
47
- () => ({
48
- webAnalyticsEventKey: 'eventKey',
49
- order: CredentialOrderFactory().one(),
50
- product: ProductFactory().one(),
51
- props: {} as SaleTunnelProps,
52
- setBillingAddress: jest.fn(),
53
- creditCard,
54
- setCreditCard,
55
- onPaymentSuccess: jest.fn(),
56
- step: SaleTunnelStep.PAYMENT,
57
- registerSubmitCallback: jest.fn(),
58
- unregisterSubmitCallback: jest.fn(),
59
- runSubmitCallbacks: jest.fn(),
60
- }),
61
- [creditCard],
62
- );
63
- contextRef.current = context;
64
-
65
- return (
66
- <SaleTunnelContext.Provider value={context}>
67
- <CreditCardSelector />
68
- </SaleTunnelContext.Provider>
69
- );
41
+ useEffect(() => {
42
+ contextRef.current.creditCard = creditCard;
43
+ }, [creditCard]);
44
+ return <CreditCardSelector creditCard={creditCard} setCreditCard={setCreditCard} />;
70
45
  };
71
46
 
72
47
  return { contextRef, Wrapper };
@@ -77,11 +52,6 @@ describe('CreditCardSelector', () => {
77
52
  const { Wrapper } = buildWrapper();
78
53
  render(<Wrapper />);
79
54
 
80
- screen.getByRole('heading', {
81
- name: 'Payment method',
82
- });
83
- screen.getByText('Choose your payment method or add a new one during the payment.');
84
-
85
55
  // During loading state, the spinner should be displayed and the current selected card should not be displayed.
86
56
  expect(screen.queryByText('Add new credit card during payment')).not.toBeInTheDocument();
87
57
  await expectSpinner();
@@ -13,7 +13,6 @@ import { CreditCardBrandLogo } from 'pages/DashboardCreditCardsManagement/Credit
13
13
  import { CreditCard } from 'types/Joanie';
14
14
  import { useCreditCardsManagement } from 'hooks/useCreditCardsManagement';
15
15
  import { Spinner } from 'components/Spinner';
16
- import { useSaleTunnelContext } from 'components/SaleTunnel/GenericSaleTunnel';
17
16
  import { CreditCardExpirationStatus, CreditCardHelper } from 'utils/CreditCardHelper';
18
17
  import { useMatchMediaLg } from 'hooks/useMatchMedia';
19
18
 
@@ -33,16 +32,6 @@ const messages = defineMessages({
33
32
  description: 'Text to show the credit card expired date',
34
33
  defaultMessage: 'Expired since {month}/{year}',
35
34
  },
36
- title: {
37
- id: 'components.SaleTunnel.CreditCardSelector.title',
38
- description: 'Title for the credit card section',
39
- defaultMessage: 'Payment method',
40
- },
41
- description: {
42
- id: 'components.SaleTunnel.CreditCardSelector.description',
43
- description: 'Description for the credit card section',
44
- defaultMessage: 'Choose your payment method or add a new one during the payment.',
45
- },
46
35
  creditCardEmptyInlineDescription: {
47
36
  id: 'components.SaleTunnel.CreditCardSelector.creditCardEmptyInlineDescription',
48
37
  description: 'Description for the empty credit card inline',
@@ -71,7 +60,19 @@ const messages = defineMessages({
71
60
  },
72
61
  });
73
62
 
74
- export const CreditCardSelector = () => {
63
+ export interface CreditCardSelectorProps {
64
+ creditCard?: CreditCard;
65
+ setCreditCard: (creditCard?: CreditCard) => void;
66
+ quickRemove?: boolean;
67
+ allowEdit?: boolean;
68
+ }
69
+
70
+ export const CreditCardSelector = ({
71
+ creditCard,
72
+ setCreditCard,
73
+ allowEdit = true,
74
+ quickRemove = true,
75
+ }: CreditCardSelectorProps) => {
75
76
  const intl = useIntl();
76
77
  const modal = useModal();
77
78
  const isMobile = useMatchMediaLg();
@@ -81,8 +82,6 @@ export const CreditCardSelector = () => {
81
82
  items: creditCards,
82
83
  } = useCreditCardsManagement();
83
84
 
84
- const { creditCard, setCreditCard } = useSaleTunnelContext();
85
-
86
85
  const getDefaultCreditCard = () => {
87
86
  if (creditCards.length === 0) {
88
87
  return;
@@ -102,12 +101,6 @@ export const CreditCardSelector = () => {
102
101
 
103
102
  return (
104
103
  <div className="credit-card-selector">
105
- <h4 className="block-title mb-t">
106
- <FormattedMessage {...messages.title} />
107
- </h4>
108
- <div className="description mb-s">
109
- <FormattedMessage {...messages.description} />
110
- </div>
111
104
  {fetching ? (
112
105
  <Spinner />
113
106
  ) : (
@@ -115,7 +108,7 @@ export const CreditCardSelector = () => {
115
108
  <div className="credit-card-selector__content">
116
109
  {creditCard ? <CreditCardInline creditCard={creditCard} /> : <CreditCardEmptyInline />}
117
110
 
118
- {creditCards?.length > 0 && (
111
+ {allowEdit && creditCards?.length > 0 && (
119
112
  <Button
120
113
  icon={<span className="material-icons">edit</span>}
121
114
  color="tertiary-text"
@@ -125,7 +118,7 @@ export const CreditCardSelector = () => {
125
118
  />
126
119
  )}
127
120
  </div>
128
- {creditCard && (
121
+ {creditCard && quickRemove && (
129
122
  <Button
130
123
  onClick={() => setCreditCard(undefined)}
131
124
  size="small"
@@ -1,3 +1,16 @@
1
+ .payment-schedule__grid {
2
+ .payment-schedule__cell {
3
+ &--wrapped {
4
+ white-space: normal;
5
+ }
6
+
7
+ &--alignRight {
8
+ display: flex;
9
+ justify-content: flex-end;
10
+ }
11
+ }
12
+ }
13
+
1
14
  .status-pill {
2
15
  display: inline-flex;
3
16
  align-items: center;
@@ -1,91 +1,71 @@
1
- import { DataList } from '@openfun/cunningham-react';
1
+ import { DataGrid } from '@openfun/cunningham-react';
2
+ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
2
3
  import { StringHelper } from 'utils/StringHelper';
4
+ import { PaymentSchedule, PaymentScheduleState } from 'types/Joanie';
5
+ import useDateFormat from 'hooks/useDateFormat';
6
+
7
+ type Props = {
8
+ schedule: PaymentSchedule;
9
+ };
10
+
11
+ const messages = defineMessages({
12
+ withdrawnAt: {
13
+ id: 'components.PaymentScheduleGrid.withdrawnAt',
14
+ defaultMessage: 'Withdrawn on {date}',
15
+ description: 'Label displayed to explain when the installment will be withdrawn.',
16
+ },
17
+ });
18
+
19
+ export const PaymentScheduleGrid = ({ schedule }: Props) => {
20
+ const intl = useIntl();
21
+ const formatDate = useDateFormat();
3
22
 
4
- export const PaymentScheduleGrid = () => {
5
23
  return (
6
24
  <div className="payment-schedule__grid">
7
- <DataList
25
+ <DataGrid
26
+ displayHeader={false}
8
27
  columns={[
28
+ { field: 'index', size: 10 },
29
+ { field: 'amount', size: 90 },
9
30
  {
10
- id: 'date',
11
- renderCell: (context) =>
12
- context.row.id === 'total' ? <strong>{context.row.date}</strong> : context.row.date,
31
+ field: 'date',
32
+ renderCell: ({ row }) => (
33
+ <span className="payment-schedule__cell--wrapped">
34
+ <FormattedMessage {...messages.withdrawnAt} values={{ date: row.date }} />
35
+ </span>
36
+ ),
13
37
  },
14
38
  {
15
- id: 'amount',
16
- renderCell: (context) =>
17
- context.row.id === 'total' ? (
18
- <strong>{context.row.amount}</strong>
39
+ id: 'state',
40
+ renderCell: ({ row }) =>
41
+ row.state ? (
42
+ <div className="payment-schedule__cell--alignRight">
43
+ <StatusPill state={row.state} />
44
+ </div>
19
45
  ) : (
20
- context.row.amount
46
+ ''
21
47
  ),
22
48
  },
23
- {
24
- id: 'status',
25
- renderCell: (context) =>
26
- context.row.status ? <StatusPill status={context.row.status} /> : '',
27
- },
28
- { field: 'message' },
29
- ]}
30
- rows={[
31
- {
32
- id: '1',
33
- date: '2023-03-15',
34
- amount: '€ 100.00',
35
- status: PaymentScheduleStatus.PAID,
36
- message: 'First payment (30%)',
37
- },
38
- {
39
- id: '2',
40
- date: '2023-04-15',
41
- amount: '€ 100.00',
42
- status: PaymentScheduleStatus.REQUIRE_PAYMENT,
43
- message: 'Periodic',
44
- },
45
- {
46
- id: '3',
47
- date: '2023-05-15',
48
- amount: '€ 100.00',
49
- status: PaymentScheduleStatus.FAILED,
50
- message: 'Periodic',
51
- },
52
- {
53
- id: '4',
54
- date: '2023-06-15',
55
- amount: '€ 100.00',
56
- status: PaymentScheduleStatus.INCOMING,
57
- message: 'Periodic',
58
- },
59
- {
60
- id: '5',
61
- date: '2023-06-15',
62
- amount: '€ 100.00',
63
- status: PaymentScheduleStatus.PENDING,
64
- message: 'Periodic',
65
- },
66
- {
67
- id: 'total',
68
- date: 'Total',
69
- amount: '€ 1150.00',
70
- },
71
49
  ]}
50
+ rows={schedule.map((installment, index) => ({
51
+ id: installment.id,
52
+ index: index + 1,
53
+ date: formatDate(installment.due_date),
54
+ amount: intl.formatNumber(installment.amount, {
55
+ style: 'currency',
56
+ currency: installment.currency,
57
+ }),
58
+ state: installment.state,
59
+ }))}
72
60
  />
73
61
  </div>
74
62
  );
75
63
  };
76
64
 
77
- export enum PaymentScheduleStatus {
78
- INCOMING = 'incoming',
79
- PENDING = 'pending',
80
- PAID = 'paid',
81
- FAILED = 'failed',
82
- REQUIRE_PAYMENT = 'require_payment',
83
- }
84
-
85
- export const StatusPill = ({ status }: { status: PaymentScheduleStatus }) => {
65
+ export const StatusPill = ({ state }: { state: PaymentScheduleState }) => {
86
66
  return (
87
- <span className={`status-pill status-pill--${status}`}>
88
- {StringHelper.capitalizeFirst(status.replace('_', ' '))}
67
+ <span className={`status-pill status-pill--${state}`}>
68
+ {StringHelper.capitalizeFirst(state.replace('_', ' '))}
89
69
  </span>
90
70
  );
91
71
  };
@@ -102,10 +102,15 @@ describe('PurchaseButton', () => {
102
102
  it('shows cta to open sale tunnel when user is authenticated', async () => {
103
103
  const courseCode = '00000';
104
104
  const product = ProductFactory().one();
105
- fetchMock.get(
106
- `https://joanie.endpoint/api/v1.0/orders/?course_code=${courseCode}&product_id=${product.id}&state=pending&state=validated&state=submitted`,
107
- {},
108
- );
105
+ fetchMock
106
+ .get(
107
+ `https://joanie.endpoint/api/v1.0/orders/?course_code=${courseCode}&product_id=${product.id}&state=pending&state=validated&state=submitted`,
108
+ {},
109
+ )
110
+ .get(
111
+ `https://joanie.endpoint/api/v1.0/courses/${courseCode}/products/${product.id}/payment-schedule/`,
112
+ [],
113
+ );
109
114
 
110
115
  render(
111
116
  <Wrapper client={createTestQueryClient({ user: richieUser })}>
@@ -137,10 +142,15 @@ describe('PurchaseButton', () => {
137
142
  const product = ProductFactory({ remaining_order_count: null }).one();
138
143
  fetchMock.get(`https://demo.endpoint/api/user/v1/accounts/${user.username}`, {});
139
144
  fetchMock.get(`https://demo.endpoint/api/user/v1/preferences/${user.username}`, {});
140
- fetchMock.get(
141
- `https://joanie.endpoint/api/v1.0/orders/?course_code=${courseCode}&product_id=${product.id}&state=pending&state=validated&state=submitted`,
142
- {},
143
- );
145
+ fetchMock
146
+ .get(
147
+ `https://joanie.endpoint/api/v1.0/orders/?course_code=${courseCode}&product_id=${product.id}&state=pending&state=validated&state=submitted`,
148
+ {},
149
+ )
150
+ .get(
151
+ `https://joanie.endpoint/api/v1.0/courses/${courseCode}/products/${product.id}/payment-schedule/`,
152
+ [],
153
+ );
144
154
  render(
145
155
  <Wrapper client={createTestQueryClient({ user })}>
146
156
  <PurchaseButton
@@ -170,10 +180,15 @@ describe('PurchaseButton', () => {
170
180
  it('shows cta to open sale tunnel when remaining orders is undefined', async () => {
171
181
  const courseCode = '00000';
172
182
  const product = ProductFactory().one();
173
- fetchMock.get(
174
- `https://joanie.endpoint/api/v1.0/orders/?course_code=${courseCode}&product_id=${product.id}&state=pending&state=validated&state=submitted`,
175
- {},
176
- );
183
+ fetchMock
184
+ .get(
185
+ `https://joanie.endpoint/api/v1.0/orders/?course_code=${courseCode}&product_id=${product.id}&state=pending&state=validated&state=submitted`,
186
+ {},
187
+ )
188
+ .get(
189
+ `https://joanie.endpoint/api/v1.0/courses/${courseCode}/products/${product.id}/payment-schedule/`,
190
+ [],
191
+ );
177
192
  delete product.remaining_order_count;
178
193
 
179
194
  render(
@@ -50,15 +50,10 @@ const messages = defineMessages({
50
50
  id: 'components.PaymentButton.errorTerms',
51
51
  },
52
52
  pay: {
53
- defaultMessage: 'Pay {price}',
53
+ defaultMessage: 'Subscribe',
54
54
  description: 'CTA label to proceed to the payment of the product',
55
55
  id: 'components.PaymentButton.pay',
56
56
  },
57
- payInOneClick: {
58
- defaultMessage: 'Pay in one click {price}',
59
- description: 'CTA label to proceed to the one click payment of the product',
60
- id: 'components.PaymentButton.payInOneClick',
61
- },
62
57
  paymentInProgress: {
63
58
  defaultMessage: 'Payment in progress',
64
59
  description: 'Label for screen reader when a payment is in progress.',
@@ -309,7 +304,7 @@ export const GenericPaymentButton = ({ buildOrderPayload }: Props) => {
309
304
  </Spinner>
310
305
  ) : (
311
306
  <FormattedMessage
312
- {...(creditCard ? messages.payInOneClick : messages.pay)}
307
+ {...messages.pay}
313
308
  values={{
314
309
  price: intl.formatNumber(product.price, {
315
310
  style: 'currency',
@@ -1,5 +1,6 @@
1
- import { Modal, ModalSize } from '@openfun/cunningham-react';
1
+ import { Alert, Modal, ModalSize, VariantType } from '@openfun/cunningham-react';
2
2
  import { createContext, ReactNode, useContext, useEffect, useMemo, useState } from 'react';
3
+ import { FormattedMessage, defineMessages } from 'react-intl';
3
4
  import { SaleTunnelSponsors } from 'components/SaleTunnel/Sponsors/SaleTunnelSponsors';
4
5
  import { SaleTunnelProps } from 'components/SaleTunnel/index';
5
6
  import { Address, CreditCard, Order, Product } from 'types/Joanie';
@@ -12,6 +13,15 @@ import { SaleTunnelInformation } from 'components/SaleTunnel/SaleTunnelInformati
12
13
  import { useEnrollments } from 'hooks/useEnrollments';
13
14
  import SaleTunnelNotValidated from './SaleTunnelNotValidated';
14
15
 
16
+ const messages = defineMessages({
17
+ walkthrough: {
18
+ id: 'components.SaleTunnel.GenericSaleTunnel.walkthrough',
19
+ defaultMessage:
20
+ 'To enroll in the training, you will first be invited to sign the training agreement and then to define a payment method. You will not be charged during this step; the first payment will take place on the date mentioned in the payment schedule above.',
21
+ description: 'Message explaining the credential sale tunnel process',
22
+ },
23
+ });
24
+
15
25
  export interface SaleTunnelContextType {
16
26
  props: SaleTunnelProps;
17
27
  order?: Order;
@@ -177,15 +187,24 @@ export const GenericSaleTunnelPaymentStep = (props: GenericSaleTunnelProps) => {
177
187
  <Modal {...props} size={ModalSize.EXTRA_LARGE} title={props.product.title} closeOnEsc={false}>
178
188
  <div className="sale-tunnel" data-testid="generic-sale-tunnel-payment-step">
179
189
  <div className="sale-tunnel__main">
180
- <div className="sale-tunnel__main__left">{props.asideNode}</div>
190
+ <div className="sale-tunnel__main__column sale-tunnel__main__left ">
191
+ <div>{props.asideNode}</div>
192
+ <div>
193
+ <SaleTunnelSponsors />
194
+ </div>
195
+ </div>
181
196
  <div className="sale-tunnel__main__separator" />
182
197
  <div className="sale-tunnel__main__right">
183
198
  <SaleTunnelInformation />
184
199
  </div>
185
200
  </div>
186
201
  <div className="sale-tunnel__footer">
202
+ <div style={{ maxWidth: '680px' }} className="mb-s" data-testid="walkthrough-banner">
203
+ <Alert type={VariantType.INFO}>
204
+ <FormattedMessage {...messages.walkthrough} />
205
+ </Alert>
206
+ </div>
187
207
  {props.paymentNode}
188
- <SaleTunnelSponsors />
189
208
  </div>
190
209
  </div>
191
210
  </Modal>
@@ -1,12 +1,13 @@
1
- import { Alert, VariantType } from '@openfun/cunningham-react';
2
1
  import { defineMessages, FormattedMessage, FormattedNumber } from 'react-intl';
3
2
  import { AddressSelector } from 'components/SaleTunnel/AddressSelector';
4
- import { CreditCardSelector } from 'components/SaleTunnel/CreditCardSelector';
3
+ import { CreditCardSelector } from 'components/CreditCardSelector';
5
4
  import { PaymentScheduleGrid } from 'components/PaymentScheduleGrid';
6
5
  import { useSaleTunnelContext } from 'components/SaleTunnel/GenericSaleTunnel';
7
6
  import OpenEdxFullNameForm from 'components/OpenEdxFullNameForm';
8
7
  import { useSession } from 'contexts/SessionContext';
9
8
  import useOpenEdxProfile from 'hooks/useOpenEdxProfile';
9
+ import { usePaymentSchedule } from 'hooks/usePaymentSchedule';
10
+ import { Spinner } from 'components/Spinner';
10
11
 
11
12
  const messages = defineMessages({
12
13
  title: {
@@ -24,6 +25,16 @@ const messages = defineMessages({
24
25
  description: 'Label for the full name input',
25
26
  defaultMessage: 'Full name',
26
27
  },
28
+ paymentMethodTitle: {
29
+ id: 'components.SaleTunnel.CreditCardSelector.title',
30
+ description: 'Title for the credit card section',
31
+ defaultMessage: 'Payment method',
32
+ },
33
+ paymentMethodDescription: {
34
+ id: 'components.SaleTunnel.CreditCardSelector.description',
35
+ description: 'Description for the credit card section',
36
+ defaultMessage: 'Choose your payment method or add a new one during the payment.',
37
+ },
27
38
  totalInfo: {
28
39
  id: 'components.SaleTunnel.Information.total.info',
29
40
  description: 'Information about the total amount',
@@ -49,7 +60,7 @@ const messages = defineMessages({
49
60
 
50
61
  export const SaleTunnelInformation = () => {
51
62
  return (
52
- <div className="sale-tunnel__information">
63
+ <div className="sale-tunnel__main__column sale-tunnel__information">
53
64
  <div>
54
65
  <h3 className="block-title mb-t">
55
66
  <FormattedMessage {...messages.title} />
@@ -64,15 +75,31 @@ export const SaleTunnelInformation = () => {
64
75
  </div>
65
76
  </div>
66
77
  <div>
67
- <CreditCardSelector />
78
+ <CreditCardSelectorWrapper />
68
79
  </div>
69
80
  <div>
81
+ <PaymentScheduleBlock />
70
82
  <Total />
71
83
  </div>
72
84
  </div>
73
85
  );
74
86
  };
75
87
 
88
+ const CreditCardSelectorWrapper = () => {
89
+ const { creditCard, setCreditCard } = useSaleTunnelContext();
90
+ return (
91
+ <>
92
+ <h4 className="block-title mb-t">
93
+ <FormattedMessage {...messages.paymentMethodTitle} />
94
+ </h4>
95
+ <div className="description mb-s">
96
+ <FormattedMessage {...messages.paymentMethodDescription} />
97
+ </div>
98
+ <CreditCardSelector creditCard={creditCard} setCreditCard={setCreditCard} />
99
+ </>
100
+ );
101
+ };
102
+
76
103
  const Email = () => {
77
104
  const { user } = useSession();
78
105
  const { data: openEdxProfileData } = useOpenEdxProfile({
@@ -98,9 +125,6 @@ const Total = () => {
98
125
  const { product } = useSaleTunnelContext();
99
126
  return (
100
127
  <div className="sale-tunnel__total">
101
- <Alert type={VariantType.INFO}>
102
- <FormattedMessage {...messages.totalInfo} />
103
- </Alert>
104
128
  <div className="sale-tunnel__total__amount mt-t" data-testid="sale-tunnel__total__amount">
105
129
  <div className="block-title">
106
130
  <FormattedMessage {...messages.totalLabel} />
@@ -117,20 +141,22 @@ const Total = () => {
117
141
  );
118
142
  };
119
143
 
120
- /**
121
- * Ready for V2.
122
- */
123
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
124
144
  const PaymentScheduleBlock = () => {
125
- return null;
145
+ const { props } = useSaleTunnelContext();
146
+ const query = usePaymentSchedule({
147
+ course_code: props.course?.code || props.enrollment!.course_run.course.code,
148
+ product_id: props.product.id,
149
+ });
150
+
151
+ if (!query.data || query.isLoading) {
152
+ return <Spinner size="large" />;
153
+ }
154
+
126
155
  return (
127
156
  <div className="payment-schedule">
128
- <h4 className="block-title mb-t">Schedule</h4>
129
- <Alert type={VariantType.INFO}>
130
- The first payment occurs in 14 days, you will be notified to pay the first 30%.
131
- </Alert>
157
+ <h4 className="block-title mb-t">Payment schedule</h4>
132
158
  <div className="mt-t">
133
- <PaymentScheduleGrid />
159
+ <PaymentScheduleGrid schedule={query.data} />
134
160
  </div>
135
161
  </div>
136
162
  );
@@ -1,15 +1,14 @@
1
1
  .sale-tunnel__sponsors {
2
2
  margin-top: 8px;
3
3
  display: flex;
4
- gap: 8px;
5
- justify-content: center;
4
+ gap: 16px;
5
+ justify-content: flex-start;
6
+ align-items: center;
6
7
  overflow: hidden;
7
8
  flex-wrap: wrap;
8
9
 
9
10
  img {
10
- height: 50px;
11
- padding: 8px;
12
- width: 130px;
11
+ width: 90px;
13
12
  object-fit: contain;
14
13
  }
15
14
  }