richie-education 3.3.1-dev9 → 3.3.2-dev4

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 (50) hide show
  1. package/i18n/locales/ar-SA.json +29 -13
  2. package/i18n/locales/es-ES.json +29 -13
  3. package/i18n/locales/fa-IR.json +29 -13
  4. package/i18n/locales/fr-CA.json +29 -13
  5. package/i18n/locales/fr-FR.json +29 -13
  6. package/i18n/locales/ko-KR.json +29 -13
  7. package/i18n/locales/pt-PT.json +29 -13
  8. package/i18n/locales/ru-RU.json +29 -13
  9. package/i18n/locales/vi-VN.json +29 -13
  10. package/js/api/auth/keycloak.spec.ts +1 -0
  11. package/js/api/auth/keycloak.ts +5 -1
  12. package/js/api/joanie.ts +20 -0
  13. package/js/api/lms/openedx-fonzie-keycloak.spec.ts +35 -2
  14. package/js/api/lms/openedx-fonzie-keycloak.ts +26 -0
  15. package/js/components/PurchaseButton/index.spec.tsx +12 -0
  16. package/js/components/SaleTunnel/AddressSelector/index.spec.tsx +3 -0
  17. package/js/components/SaleTunnel/GenericSaleTunnel.tsx +11 -0
  18. package/js/components/SaleTunnel/SaleTunnelInformation/SaleTunnelInformationSingular.tsx +141 -52
  19. package/js/components/SaleTunnel/SaleTunnelInformation/index.tsx +3 -2
  20. package/js/components/SaleTunnel/SubscriptionButton/index.tsx +6 -1
  21. package/js/components/SaleTunnel/index.credential.spec.tsx +108 -1
  22. package/js/components/SaleTunnel/index.full-process-b2b.spec.tsx +5 -1
  23. package/js/components/SaleTunnel/index.full-process-b2c.spec.tsx +9 -0
  24. package/js/components/SaleTunnel/index.spec.tsx +122 -3
  25. package/js/hooks/useDeepLink.tsx +21 -0
  26. package/js/pages/DashboardBatchOrders/index.spec.tsx +103 -0
  27. package/js/pages/DashboardKeycloakProfile/index.spec.tsx +77 -0
  28. package/js/pages/DashboardKeycloakProfile/index.tsx +93 -0
  29. package/js/pages/DashboardPreferences/index.spec.tsx +141 -0
  30. package/js/pages/DashboardPreferences/index.tsx +7 -1
  31. package/js/translations/ar-SA.json +1 -1
  32. package/js/translations/es-ES.json +1 -1
  33. package/js/translations/fa-IR.json +1 -1
  34. package/js/translations/fr-CA.json +1 -1
  35. package/js/translations/fr-FR.json +1 -1
  36. package/js/translations/ko-KR.json +1 -1
  37. package/js/translations/pt-PT.json +1 -1
  38. package/js/translations/ru-RU.json +1 -1
  39. package/js/translations/vi-VN.json +1 -1
  40. package/js/types/Joanie.ts +8 -1
  41. package/js/utils/test/factories/joanie.ts +8 -2
  42. package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/BatchOrderPaymentModal/BatchOrderPaymentManager.tsx +15 -27
  43. package/js/widgets/Dashboard/components/LearnerDashboardSidebar/index.tsx +7 -12
  44. package/js/widgets/Dashboard/index.spec.tsx +4 -3
  45. package/js/widgets/SyllabusCourseRunsList/components/SyllabusAsideList/index.tsx +8 -27
  46. package/js/widgets/SyllabusCourseRunsList/components/SyllabusCourseRunCompacted/index.tsx +41 -17
  47. package/js/widgets/SyllabusCourseRunsList/index.spec.tsx +37 -4
  48. package/js/widgets/cunningham-fr-FR-locale.json +80 -0
  49. package/js/widgets/index.tsx +6 -1
  50. package/package.json +1 -1
package/js/api/joanie.ts CHANGED
@@ -173,6 +173,9 @@ export const getRoutes = () => {
173
173
  paymentPlan: {
174
174
  get: `${baseUrl}/courses/:course_id/products/:id/payment-plan/`,
175
175
  },
176
+ deepLink: {
177
+ get: `${baseUrl}/courses/:course_id/products/:id/deep-link/`,
178
+ },
176
179
  },
177
180
  orders: {
178
181
  get: `${baseUrl}/courses/:course_id/orders/:id/`,
@@ -585,6 +588,23 @@ const API = (): Joanie.API => {
585
588
  );
586
589
  },
587
590
  },
591
+ deepLink: {
592
+ get: async (
593
+ filters?: Joanie.CourseProductQueryFilters,
594
+ ): Promise<Joanie.OfferingDeepLink> => {
595
+ if (!filters) {
596
+ throw new Error('A course code and a product id are required to fetch a deep link');
597
+ } else if (!filters.course_id) {
598
+ throw new Error('A course code is required to fetch a deep link');
599
+ } else if (!filters.id) {
600
+ throw new Error('A product id is required to fetch a deep link');
601
+ }
602
+
603
+ return fetchWithJWT(buildApiUrl(ROUTES.courses.products.deepLink.get, filters)).then(
604
+ checkStatus,
605
+ );
606
+ },
607
+ },
588
608
  },
589
609
  orders: {
590
610
  get: async (filters?: Joanie.CourseOrderResourceQuery) => {
@@ -31,12 +31,36 @@ describe('Fonzie Keycloak API', () => {
31
31
  jest.clearAllMocks();
32
32
  });
33
33
 
34
- it('uses its own route to get user information', async () => {
34
+ it('uses its own route to get user information and enriches with keycloak account', async () => {
35
35
  const user = {
36
- username: faker.internet.username(),
36
+ username: 'test-richie-ncl',
37
+ full_name: 'n c',
38
+ };
39
+ const keycloakAccount = {
40
+ firstName: 'John',
41
+ lastName: 'Doe',
42
+ email: 'john.doe@example.com',
37
43
  };
38
44
 
39
45
  fetchMock.get('https://demo.endpoint.api/api/v1.0/user/me', user);
46
+ fetchMock.get('https://keycloak.test/auth/realms/richie-realm/account/', keycloakAccount);
47
+
48
+ const api = FonzieKeycloakAPIInterface(configuration);
49
+ await expect(api.user.me()).resolves.toEqual({
50
+ ...user,
51
+ full_name: 'John Doe',
52
+ email: 'john.doe@example.com',
53
+ });
54
+ });
55
+
56
+ it('falls back to fonzie data when keycloak account call fails', async () => {
57
+ const user = {
58
+ username: 'test-richie-ncl',
59
+ full_name: 'n c',
60
+ };
61
+
62
+ fetchMock.get('https://demo.endpoint.api/api/v1.0/user/me', user);
63
+ fetchMock.get('https://keycloak.test/auth/realms/richie-realm/account/', 500);
40
64
 
41
65
  const api = FonzieKeycloakAPIInterface(configuration);
42
66
  await expect(api.user.me()).resolves.toEqual(user);
@@ -66,6 +90,15 @@ describe('Fonzie Keycloak API', () => {
66
90
  expect(fetchMock.calls()).toHaveLength(2);
67
91
  });
68
92
 
93
+ it('provides an updateUrl pointing to the keycloak account page', () => {
94
+ const api = FonzieKeycloakAPIInterface(configuration);
95
+ const { account } = api.user;
96
+ expect(typeof (account as any).updateUrl).toBe('function');
97
+ expect((account as any).updateUrl()).toBe(
98
+ 'https://keycloak.test/auth/realms/richie-realm/account/',
99
+ );
100
+ });
101
+
69
102
  it('is able to retrieve access token within the session storage', () => {
70
103
  const accessToken = faker.string.uuid();
71
104
  sessionStorage.setItem(RICHIE_USER_TOKEN, accessToken);
@@ -43,6 +43,31 @@ const API = (APIConf: AuthenticationBackend): APILms => {
43
43
  ...ApiInterface,
44
44
  user: {
45
45
  ...ApiInterface.user,
46
+ me: async () => {
47
+ const user = await ApiInterface.user.me();
48
+ if (!user) return null;
49
+ try {
50
+ const keycloakAccount = await fetch(APIOptions.routes.user.account, {
51
+ credentials: 'include',
52
+ headers: {
53
+ Accept: 'application/json',
54
+ Authorization: `Bearer ${user.access_token}`,
55
+ },
56
+ }).then((res) => {
57
+ if (!res.ok) throw new Error(`Keycloak account fetch failed: ${res.status}`);
58
+ return res.json();
59
+ });
60
+ return {
61
+ ...user,
62
+ full_name:
63
+ [keycloakAccount.firstName, keycloakAccount.lastName].filter(Boolean).join(' ') ||
64
+ user.full_name,
65
+ email: keycloakAccount.email || user.email,
66
+ };
67
+ } catch {
68
+ return user;
69
+ }
70
+ },
46
71
  login: () => {
47
72
  const next = encodeURIComponent(location.href);
48
73
  location.assign(`${APIOptions.routes.user.login}?next=${next}`);
@@ -51,6 +76,7 @@ const API = (APIConf: AuthenticationBackend): APILms => {
51
76
  return sessionStorage.getItem(RICHIE_USER_TOKEN);
52
77
  },
53
78
  account: {
79
+ updateUrl: () => APIOptions.routes.user.account,
54
80
  get: async (username: string) => {
55
81
  const options: RequestInit = {
56
82
  credentials: 'include',
@@ -115,6 +115,10 @@ describe('PurchaseButton', () => {
115
115
  .get(
116
116
  `https://joanie.endpoint/api/v1.0/courses/${courseCode}/products/${product.id}/payment-plan/`,
117
117
  [],
118
+ )
119
+ .get(
120
+ `https://joanie.endpoint/api/v1.0/courses/${courseCode}/products/${product.id}/deep-link/`,
121
+ {},
118
122
  );
119
123
 
120
124
  render(
@@ -159,6 +163,10 @@ describe('PurchaseButton', () => {
159
163
  .get(
160
164
  `https://joanie.endpoint/api/v1.0/courses/${courseCode}/products/${product.id}/payment-plan/`,
161
165
  [],
166
+ )
167
+ .get(
168
+ `https://joanie.endpoint/api/v1.0/courses/${courseCode}/products/${product.id}/deep-link/`,
169
+ {},
162
170
  );
163
171
  render(
164
172
  <Wrapper client={createTestQueryClient({ user })}>
@@ -201,6 +209,10 @@ describe('PurchaseButton', () => {
201
209
  .get(
202
210
  `https://joanie.endpoint/api/v1.0/courses/${courseCode}/products/${product.id}/payment-plan/`,
203
211
  [],
212
+ )
213
+ .get(
214
+ `https://joanie.endpoint/api/v1.0/courses/${courseCode}/products/${product.id}/deep-link/`,
215
+ {},
204
216
  );
205
217
  delete product.remaining_order_count;
206
218
 
@@ -9,6 +9,7 @@ import {
9
9
  SaleTunnelStep,
10
10
  SaleTunnelContext,
11
11
  SaleTunnelContextType,
12
+ PaymentMode,
12
13
  } from 'components/SaleTunnel/GenericSaleTunnel';
13
14
  import { Address, PaymentSchedule } from 'types/Joanie';
14
15
  import {
@@ -74,6 +75,8 @@ describe('AddressSelector', () => {
74
75
  setSchedule,
75
76
  needsPayment: false,
76
77
  setNeedsPayment: jest.fn(),
78
+ paymentMode: PaymentMode.CLASSIC,
79
+ setPaymentMode: jest.fn(),
77
80
  }),
78
81
  [billingAddress, voucherCode, schedule],
79
82
  );
@@ -65,6 +65,8 @@ export interface SaleTunnelContextType {
65
65
  setSchedule: (schedule?: PaymentSchedule) => void;
66
66
  needsPayment: boolean;
67
67
  setNeedsPayment: (needsPayment: boolean) => void;
68
+ paymentMode: PaymentMode;
69
+ setPaymentMode: (mode: PaymentMode) => void;
68
70
  }
69
71
 
70
72
  export const SaleTunnelContext = createContext<SaleTunnelContextType>({} as any);
@@ -86,6 +88,11 @@ export enum SaleTunnelStep {
86
88
  SUCCESS,
87
89
  }
88
90
 
91
+ export enum PaymentMode {
92
+ CLASSIC = 'classic',
93
+ CPF = 'cpf',
94
+ }
95
+
89
96
  interface GenericSaleTunnelProps extends SaleTunnelProps {
90
97
  eventKey: string;
91
98
 
@@ -111,6 +118,7 @@ export const GenericSaleTunnel = (props: GenericSaleTunnelProps) => {
111
118
  );
112
119
  const [voucherCode, setVoucherCode] = useState<string>();
113
120
  const [needsPayment, setNeedsPayment] = useState(true);
121
+ const [paymentMode, setPaymentMode] = useState<PaymentMode>(PaymentMode.CLASSIC);
114
122
 
115
123
  const nextStep = useCallback(() => {
116
124
  if (order)
@@ -184,6 +192,8 @@ export const GenericSaleTunnel = (props: GenericSaleTunnelProps) => {
184
192
  setSchedule,
185
193
  needsPayment,
186
194
  setNeedsPayment,
195
+ paymentMode,
196
+ setPaymentMode,
187
197
  }),
188
198
  [
189
199
  props,
@@ -197,6 +207,7 @@ export const GenericSaleTunnel = (props: GenericSaleTunnelProps) => {
197
207
  hasWaivedWithdrawalRight,
198
208
  voucherCode,
199
209
  needsPayment,
210
+ paymentMode,
200
211
  ],
201
212
  );
202
213
 
@@ -1,19 +1,21 @@
1
1
  import { ChangeEvent, useEffect, useState } from 'react';
2
2
  import { defineMessages, FormattedMessage, FormattedNumber, useIntl } from 'react-intl';
3
- import { Alert, Button, Input, VariantType } from '@openfun/cunningham-react';
3
+ import { Alert, Button, Input, Radio, RadioGroup, VariantType } from '@openfun/cunningham-react';
4
4
  import { AddressSelector } from 'components/SaleTunnel/AddressSelector';
5
5
  import { PaymentScheduleGrid } from 'components/PaymentScheduleGrid';
6
- import { useSaleTunnelContext } from 'components/SaleTunnel/GenericSaleTunnel';
6
+ import { PaymentMode, useSaleTunnelContext } from 'components/SaleTunnel/GenericSaleTunnel';
7
7
  import OpenEdxFullNameForm from 'components/OpenEdxFullNameForm';
8
8
  import { useSession } from 'contexts/SessionContext';
9
9
  import useOpenEdxProfile from 'hooks/useOpenEdxProfile';
10
10
  import WithdrawRightCheckbox from 'components/SaleTunnel/WithdrawRightCheckbox';
11
11
  import { PaymentSchedule, ProductType } from 'types/Joanie';
12
12
  import { usePaymentPlan } from 'hooks/usePaymentPlan';
13
+ import { useDeepLink } from 'hooks/useDeepLink';
13
14
  import { HttpError } from 'utils/errors/HttpError';
14
15
  import { APIBackend, KeycloakAccountApi } from 'types/api';
15
16
  import context from 'utils/context';
16
17
  import { AuthenticationApi } from 'api/authentication';
18
+ import { UserHelper } from 'utils/UserHelper';
17
19
 
18
20
  const messages = defineMessages({
19
21
  title: {
@@ -122,6 +124,32 @@ const messages = defineMessages({
122
124
  description: 'Message displayed when the order is part of a batch order',
123
125
  defaultMessage: 'No billing information required. This order is covered by your organization.',
124
126
  },
127
+ paymentModeTitle: {
128
+ id: 'components.SaleTunnel.Information.paymentMode.title',
129
+ description: 'Title for the payment mode selection section',
130
+ defaultMessage: 'Payment method',
131
+ },
132
+ paymentModeClassic: {
133
+ id: 'components.SaleTunnel.Information.paymentMode.classic',
134
+ description: 'Label for the classic card payment option',
135
+ defaultMessage: 'Credit card payment',
136
+ },
137
+ paymentModeCpf: {
138
+ id: 'components.SaleTunnel.Information.paymentMode.cpf',
139
+ description: 'Label for the CPF (Mon Compte Formation) payment option',
140
+ defaultMessage: 'My Training Account (CPF)',
141
+ },
142
+ cpfDescription: {
143
+ id: 'components.SaleTunnel.Information.cpf.description',
144
+ description: 'Explanatory text for the CPF payment option',
145
+ defaultMessage:
146
+ 'Pay for your training using your personal training account (CPF) on Mon Compte Formation.',
147
+ },
148
+ cpfButtonLabel: {
149
+ id: 'components.SaleTunnel.Information.cpf.buttonLabel',
150
+ description: 'Label for the button redirecting to Mon Compte Formation',
151
+ defaultMessage: 'Go to Mon Compte Formation',
152
+ },
125
153
  });
126
154
 
127
155
  export const SaleTunnelInformationSingular = () => {
@@ -133,6 +161,9 @@ export const SaleTunnelInformationSingular = () => {
133
161
  setSchedule,
134
162
  needsPayment,
135
163
  setNeedsPayment,
164
+ setHasWaivedWithdrawalRight,
165
+ paymentMode,
166
+ setPaymentMode,
136
167
  } = useSaleTunnelContext();
137
168
  const [voucherError, setVoucherError] = useState<HttpError | null>(null);
138
169
  const query = usePaymentPlan({
@@ -140,11 +171,17 @@ export const SaleTunnelInformationSingular = () => {
140
171
  product_id: props.product.id,
141
172
  ...(voucherCode ? { voucher_code: voucherCode } : {}),
142
173
  });
174
+ const deepLinkQuery = useDeepLink({
175
+ course_code: props.course?.code ?? props.enrollment!.course_run.course.code,
176
+ product_id: props.product.id,
177
+ });
143
178
  const schedule = query.data?.payment_schedule ?? props.paymentPlan?.payment_schedule;
144
179
  const price = query.data?.price ?? props.paymentPlan?.price;
145
180
  const discountedPrice = query.data?.discounted_price ?? props.paymentPlan?.discounted_price;
146
181
  const discount = query.data?.discount ?? props.paymentPlan?.discount;
147
- const fromBatchOrder = query.data?.from_batch_order ?? props.paymentPlan?.from_batch_order;
182
+ const skipContractInputs =
183
+ query.data?.skip_contract_inputs ?? props.paymentPlan?.skip_contract_inputs;
184
+ const deepLink = deepLinkQuery.data?.deep_link;
148
185
 
149
186
  const isCredentialWithPrice =
150
187
  product.type === ProductType.CREDENTIAL &&
@@ -164,66 +201,99 @@ export const SaleTunnelInformationSingular = () => {
164
201
  }, [query.error, voucherCode, setVoucherCode]);
165
202
 
166
203
  useEffect(() => {
167
- setNeedsPayment(!fromBatchOrder);
168
- }, [fromBatchOrder, setNeedsPayment]);
204
+ setNeedsPayment(!skipContractInputs);
205
+ if (skipContractInputs) {
206
+ setHasWaivedWithdrawalRight(false);
207
+ }
208
+ }, [skipContractInputs, setNeedsPayment, setHasWaivedWithdrawalRight]);
169
209
 
170
- const isKeycloakBackend = context?.authentication.backend === APIBackend.KEYCLOAK;
210
+ const intl = useIntl();
211
+ const isKeycloakBackend = [APIBackend.KEYCLOAK, APIBackend.FONZIE_KEYCLOAK].includes(
212
+ context?.authentication.backend as APIBackend,
213
+ );
171
214
 
172
215
  return (
173
216
  <>
174
- {needsPayment && (
175
- <div>
217
+ {deepLink && (
218
+ <div className="mb-s">
176
219
  <h3 className="block-title mb-t">
177
- <FormattedMessage {...messages.title} />
178
- </h3>
179
- <div className="description mb-s">
180
- <FormattedMessage {...messages.description} />
181
- </div>
182
- {isKeycloakBackend ? (
183
- <KeycloakAccountEdit />
184
- ) : (
185
- <>
186
- <OpenEdxFullNameForm />
187
- <div className="mt-s">
188
- <Email />
189
- </div>
190
- </>
191
- )}
192
- <AddressSelector />
193
- </div>
194
- )}
195
- {!needsPayment && (
196
- <div>
197
- <h3 className="block-title">
198
- <FormattedMessage {...messages.title} />
220
+ <FormattedMessage {...messages.paymentModeTitle} />
199
221
  </h3>
200
- <Alert type={VariantType.NEUTRAL}>
201
- <FormattedMessage {...messages.noBillingInformation} />
202
- </Alert>
222
+ <RadioGroup>
223
+ <Radio
224
+ label={intl.formatMessage(messages.paymentModeClassic)}
225
+ value={PaymentMode.CLASSIC}
226
+ checked={paymentMode === PaymentMode.CLASSIC}
227
+ onChange={() => setPaymentMode(PaymentMode.CLASSIC)}
228
+ />
229
+ <Radio
230
+ label={intl.formatMessage(messages.paymentModeCpf)}
231
+ value={PaymentMode.CPF}
232
+ checked={paymentMode === PaymentMode.CPF}
233
+ onChange={() => setPaymentMode(PaymentMode.CPF)}
234
+ />
235
+ </RadioGroup>
203
236
  </div>
204
237
  )}
205
- <div>
206
- {isCredentialWithPrice &&
207
- (schedule ? (
208
- <PaymentScheduleBlock schedule={schedule!} />
209
- ) : (
238
+ {paymentMode === PaymentMode.CPF ? (
239
+ <CpfPayment deepLink={deepLink!} />
240
+ ) : (
241
+ <>
242
+ {needsPayment && (
210
243
  <div>
211
- <h4 className="block-title">
212
- <FormattedMessage {...messages.paymentSchedule} />
213
- </h4>
244
+ <h3 className="block-title mb-t">
245
+ <FormattedMessage {...messages.title} />
246
+ </h3>
247
+ <div className="description mb-s">
248
+ <FormattedMessage {...messages.description} />
249
+ </div>
250
+ {isKeycloakBackend ? (
251
+ <KeycloakAccountEdit />
252
+ ) : (
253
+ <>
254
+ <OpenEdxFullNameForm />
255
+ <div className="mt-s">
256
+ <Email />
257
+ </div>
258
+ </>
259
+ )}
260
+ <AddressSelector />
261
+ </div>
262
+ )}
263
+ {!needsPayment && (
264
+ <div>
265
+ <h3 className="block-title">
266
+ <FormattedMessage {...messages.title} />
267
+ </h3>
214
268
  <Alert type={VariantType.NEUTRAL}>
215
- <FormattedMessage {...messages.noPaymentSchedule} />
269
+ <FormattedMessage {...messages.noBillingInformation} />
216
270
  </Alert>
217
271
  </div>
218
- ))}
219
- <Voucher
220
- discount={discount}
221
- voucherError={voucherError}
222
- setVoucherError={setVoucherError}
223
- />
224
- <Total price={price} discountedPrice={discountedPrice} />
225
- {needsPayment && <WithdrawRightCheckbox />}
226
- </div>
272
+ )}
273
+ <div>
274
+ {isCredentialWithPrice &&
275
+ (schedule ? (
276
+ <PaymentScheduleBlock schedule={schedule!} />
277
+ ) : (
278
+ <div>
279
+ <h4 className="block-title">
280
+ <FormattedMessage {...messages.paymentSchedule} />
281
+ </h4>
282
+ <Alert type={VariantType.NEUTRAL}>
283
+ <FormattedMessage {...messages.noPaymentSchedule} />
284
+ </Alert>
285
+ </div>
286
+ ))}
287
+ <Voucher
288
+ discount={discount}
289
+ voucherError={voucherError}
290
+ setVoucherError={setVoucherError}
291
+ />
292
+ <Total price={price} discountedPrice={discountedPrice} />
293
+ {needsPayment && <WithdrawRightCheckbox />}
294
+ </div>
295
+ </>
296
+ )}
227
297
  </>
228
298
  );
229
299
  };
@@ -240,7 +310,7 @@ const KeycloakAccountEdit = () => {
240
310
  <h4>
241
311
  <FormattedMessage {...messages.keycloakUsernameLabel} />
242
312
  </h4>
243
- <div className="fw-bold">{user?.username}</div>
313
+ <div className="fw-bold">{user ? UserHelper.getName(user) : ''}</div>
244
314
  </div>
245
315
  <div className="sale-tunnel__username__description">
246
316
  <FormattedMessage {...messages.keycloakUsernameInfo} />
@@ -422,3 +492,22 @@ const PaymentScheduleBlock = ({ schedule }: { schedule: PaymentSchedule }) => {
422
492
  </div>
423
493
  );
424
494
  };
495
+
496
+ const CpfPayment = ({ deepLink }: { deepLink: string }) => {
497
+ return (
498
+ <div className="sale-tunnel__cpf">
499
+ <p className="description mb-s">
500
+ <FormattedMessage {...messages.cpfDescription} />
501
+ </p>
502
+ <Button
503
+ color="primary"
504
+ fullWidth={true}
505
+ href={deepLink}
506
+ target="_blank"
507
+ rel="noopener noreferrer"
508
+ >
509
+ <FormattedMessage {...messages.cpfButtonLabel} />
510
+ </Button>
511
+ </div>
512
+ );
513
+ };
@@ -1,7 +1,7 @@
1
1
  import { useState } from 'react';
2
2
  import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
3
3
  import { Select } from '@openfun/cunningham-react';
4
- import { useSaleTunnelContext } from 'components/SaleTunnel/GenericSaleTunnel';
4
+ import { useSaleTunnelContext, PaymentMode } from 'components/SaleTunnel/GenericSaleTunnel';
5
5
  import { SaleTunnelInformationSingular } from 'components/SaleTunnel/SaleTunnelInformation/SaleTunnelInformationSingular';
6
6
  import { SaleTunnelInformationGroup } from 'components/SaleTunnel/SaleTunnelInformation/SaleTunnelInformationGroup';
7
7
  import { ProductType } from 'types/Joanie';
@@ -36,7 +36,7 @@ export enum FormType {
36
36
 
37
37
  export const SaleTunnelInformation = () => {
38
38
  const intl = useIntl();
39
- const { setBatchOrder, setSchedule, product } = useSaleTunnelContext();
39
+ const { setBatchOrder, setSchedule, setPaymentMode, product } = useSaleTunnelContext();
40
40
  const productType = product.type;
41
41
  const options = [
42
42
  { label: intl.formatMessage(messages.purchaseTypeOptionSingle), value: FormType.SINGULAR },
@@ -63,6 +63,7 @@ export const SaleTunnelInformation = () => {
63
63
  setPurchaseType(e.target.value as FormType);
64
64
  setBatchOrder(undefined);
65
65
  setSchedule(undefined);
66
+ setPaymentMode(PaymentMode.CLASSIC);
66
67
  }}
67
68
  />
68
69
  </div>
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useMemo, useState } from 'react';
2
2
  import { Alert, Button, VariantType } from '@openfun/cunningham-react';
3
3
  import { defineMessages, FormattedMessage } from 'react-intl';
4
- import { useSaleTunnelContext } from 'components/SaleTunnel/GenericSaleTunnel';
4
+ import { PaymentMode, useSaleTunnelContext } from 'components/SaleTunnel/GenericSaleTunnel';
5
5
  import { validationSchema } from 'components/SaleTunnel/SaleTunnelInformation/SaleTunnelInformationGroup';
6
6
  import { useOrders } from 'hooks/useOrders';
7
7
  import { useBatchOrder } from 'hooks/useBatchOrder';
@@ -107,7 +107,12 @@ const SubscriptionButton = ({ buildOrderPayload }: Props) => {
107
107
  props: saleTunnelProps,
108
108
  voucherCode,
109
109
  needsPayment,
110
+ paymentMode,
110
111
  } = useSaleTunnelContext();
112
+
113
+ if (paymentMode === PaymentMode.CPF) {
114
+ return null;
115
+ }
111
116
  const { methods: orderMethods } = useOrders(undefined, { enabled: false });
112
117
  const { methods: batchOrderMethods } = useBatchOrder();
113
118
  const [state, setState] = useState<ComponentStates>(ComponentStates.IDLE);
@@ -1,5 +1,6 @@
1
1
  import fetchMock from 'fetch-mock';
2
2
  import { screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
3
4
  import queryString from 'query-string';
4
5
  import {
5
6
  RichieContextFactory as mockRichieContextFactory,
@@ -99,7 +100,11 @@ describe('SaleTunnel / Credential', () => {
99
100
  .post('https://joanie.endpoint/api/v1.0/orders/', order)
100
101
  .get('https://joanie.endpoint/api/v1.0/addresses/', [billingAddress], {
101
102
  overwriteRoutes: true,
102
- });
103
+ })
104
+ .get(
105
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
106
+ {},
107
+ );
103
108
 
104
109
  render(<Wrapper product={product} course={course} />, {
105
110
  queryOptions: { client: createTestQueryClient({ user: richieUser }) },
@@ -115,4 +120,106 @@ describe('SaleTunnel / Credential', () => {
115
120
  // - Payment button should not be disabled.
116
121
  expect($button.disabled).toBe(false);
117
122
  });
123
+
124
+ it('should display CPF payment option and redirect to deepLink when deepLink is available', async () => {
125
+ const course = PacedCourseFactory().one();
126
+ const product = CredentialProductFactory().one();
127
+ const billingAddress: Joanie.Address = AddressFactory({ is_main: true }).one();
128
+ const deepLink = 'https://placeholder.com/course/1';
129
+ const orderQueryParameters = {
130
+ course_code: course.code,
131
+ product_id: product.id,
132
+ state: NOT_CANCELED_ORDER_STATES,
133
+ };
134
+
135
+ fetchMock
136
+ .get(
137
+ `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(orderQueryParameters)}`,
138
+ [],
139
+ )
140
+ .get(
141
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
142
+ [],
143
+ )
144
+ .get(
145
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
146
+ { deep_link: deepLink },
147
+ )
148
+ .get('https://joanie.endpoint/api/v1.0/addresses/', [billingAddress], {
149
+ overwriteRoutes: true,
150
+ });
151
+
152
+ window.open = jest.fn();
153
+ const user = userEvent.setup({ delay: null });
154
+
155
+ render(<Wrapper product={product} course={course} />, {
156
+ queryOptions: { client: createTestQueryClient({ user: richieUser }) },
157
+ });
158
+
159
+ await screen.findByRole('heading', { level: 3, name: /payment method/i });
160
+
161
+ // - By default, credit card payment should be selected.
162
+ expect(screen.getByRole('radio', { name: /credit card payment/i })).toBeChecked();
163
+ expect(screen.getByRole('radio', { name: /my training account \(cpf\)/i })).not.toBeChecked();
164
+
165
+ await user.click(screen.getByRole('radio', { name: /my training account \(cpf\)/i }));
166
+
167
+ // - CPF description and redirect button should be visible.
168
+ expect(
169
+ screen.getByText(/pay for your training using your personal training account/i),
170
+ ).toBeInTheDocument();
171
+ const cpfButton = screen.getByRole('link', { name: /go to mon compte formation/i });
172
+
173
+ await user.click(cpfButton);
174
+ });
175
+
176
+ it('should not display CPF payment option when deepLink is null', async () => {
177
+ const course = PacedCourseFactory().one();
178
+ const product = CredentialProductFactory().one();
179
+ const billingAddress: Joanie.Address = AddressFactory({ is_main: true }).one();
180
+ const orderQueryParameters = {
181
+ course_code: course.code,
182
+ product_id: product.id,
183
+ state: NOT_CANCELED_ORDER_STATES,
184
+ };
185
+
186
+ fetchMock
187
+ .get(
188
+ `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(orderQueryParameters)}`,
189
+ [],
190
+ )
191
+ .get(
192
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
193
+ [],
194
+ )
195
+ .get(
196
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
197
+ { deep_link: null },
198
+ )
199
+ .get('https://joanie.endpoint/api/v1.0/addresses/', [billingAddress], {
200
+ overwriteRoutes: true,
201
+ });
202
+
203
+ render(<Wrapper product={product} course={course} />, {
204
+ queryOptions: { client: createTestQueryClient({ user: richieUser }) },
205
+ });
206
+
207
+ // - wait for address to be loaded.
208
+ await screen.findByText(getAddressLabel(billingAddress));
209
+
210
+ // - Payment method section and CPF option should not be rendered.
211
+ expect(
212
+ screen.queryByRole('heading', { level: 3, name: /payment method/i }),
213
+ ).not.toBeInTheDocument();
214
+ expect(
215
+ screen.queryByRole('radio', { name: /my training account \(cpf\)/i }),
216
+ ).not.toBeInTheDocument();
217
+ expect(screen.queryByRole('radio', { name: /credit card payment/i })).not.toBeInTheDocument();
218
+ expect(
219
+ screen.queryByRole('link', { name: /go to mon compte formation/i }),
220
+ ).not.toBeInTheDocument();
221
+
222
+ // - Classic billing information section should be displayed.
223
+ expect(screen.getByText(/this information will be used for billing/i)).toBeInTheDocument();
224
+ });
118
225
  });