richie-education 3.2.1-dev9 → 3.2.2-dev26

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 (98) hide show
  1. package/i18n/locales/ar-SA.json +29 -1
  2. package/i18n/locales/es-ES.json +29 -1
  3. package/i18n/locales/fa-IR.json +29 -1
  4. package/i18n/locales/fr-CA.json +29 -1
  5. package/i18n/locales/fr-FR.json +29 -1
  6. package/i18n/locales/ko-KR.json +29 -1
  7. package/i18n/locales/pt-PT.json +29 -1
  8. package/i18n/locales/ru-RU.json +29 -1
  9. package/i18n/locales/vi-VN.json +29 -1
  10. package/js/api/joanie.ts +144 -0
  11. package/js/components/PaymentInterfaces/types.ts +7 -0
  12. package/js/components/PaymentScheduleGrid/index.tsx +4 -2
  13. package/js/components/SaleTunnel/AddressSelector/index.spec.tsx +9 -2
  14. package/js/components/SaleTunnel/GenericSaleTunnel.tsx +33 -0
  15. package/js/components/SaleTunnel/SaleTunnelInformation/SaleTunnelInformationGroup.tsx +253 -0
  16. package/js/components/SaleTunnel/SaleTunnelInformation/SaleTunnelInformationSingular.tsx +314 -0
  17. package/js/components/SaleTunnel/SaleTunnelInformation/StepContent.tsx +528 -0
  18. package/js/components/SaleTunnel/SaleTunnelInformation/index.tsx +47 -261
  19. package/js/components/SaleTunnel/SaleTunnelSuccess/index.tsx +25 -11
  20. package/js/components/SaleTunnel/SubscriptionButton/index.tsx +54 -6
  21. package/js/components/SaleTunnel/_styles.scss +55 -0
  22. package/js/components/SaleTunnel/index.full-process-b2b.spec.tsx +356 -0
  23. package/js/components/SaleTunnel/{index.full-process.spec.tsx → index.full-process-b2c.spec.tsx} +4 -1
  24. package/js/components/SaleTunnel/index.spec.tsx +130 -1
  25. package/js/hooks/useBatchOrder/index.tsx +36 -0
  26. package/js/hooks/useContractArchive/index.ts +2 -0
  27. package/js/hooks/useOfferingOrganizations/index.tsx +38 -0
  28. package/js/hooks/useOrganizationAgreements.tsx/index.tsx +66 -0
  29. package/js/hooks/useOrganizationQuotes/index.tsx +56 -0
  30. package/js/hooks/usePaymentPlan.tsx +2 -1
  31. package/js/hooks/useTeacherPendingAgreementsCount/index.ts +34 -0
  32. package/js/pages/DashboardBatchOrderLayout/_styles.scss +5 -0
  33. package/js/pages/DashboardBatchOrderLayout/index.spec.tsx +78 -0
  34. package/js/pages/DashboardBatchOrderLayout/index.tsx +45 -0
  35. package/js/pages/DashboardBatchOrders/index.spec.tsx +237 -0
  36. package/js/pages/DashboardBatchOrders/index.tsx +84 -0
  37. package/js/pages/TeacherDashboardContractsLayout/TeacherDashboardCourseContractsLayout/index.tsx +0 -1
  38. package/js/pages/TeacherDashboardContractsLayout/hooks/useDownloadContractArchive/index.tsx +3 -1
  39. package/js/pages/TeacherDashboardOrganizationAgreements/AgreementActionsBar.tsx +49 -0
  40. package/js/pages/TeacherDashboardOrganizationAgreements/BulkAgreementContractButton.tsx +79 -0
  41. package/js/pages/TeacherDashboardOrganizationAgreements/OrganizationAgreementFrame.tsx +71 -0
  42. package/js/pages/TeacherDashboardOrganizationAgreements/SignOrganizationAgreementButton.tsx +60 -0
  43. package/js/pages/TeacherDashboardOrganizationAgreements/hooks/useAgreementsAbilities.tsx +8 -0
  44. package/js/pages/TeacherDashboardOrganizationAgreements/hooks/useHasAgreementToDownload.tsx +27 -0
  45. package/js/pages/TeacherDashboardOrganizationAgreements/hooks/useTeacherAgreementsToSign.tsx +32 -0
  46. package/js/pages/TeacherDashboardOrganizationAgreements/index.spec.tsx +433 -0
  47. package/js/pages/TeacherDashboardOrganizationAgreements/index.tsx +130 -0
  48. package/js/pages/TeacherDashboardOrganizationAgreementsLayout/index.tsx +25 -0
  49. package/js/pages/TeacherDashboardOrganizationCourseLoader/index.spec.tsx +9 -0
  50. package/js/pages/TeacherDashboardOrganizationQuotes/_styles.scss +40 -0
  51. package/js/pages/TeacherDashboardOrganizationQuotes/index.full-process.spec.tsx +194 -0
  52. package/js/pages/TeacherDashboardOrganizationQuotes/index.spec.tsx +144 -0
  53. package/js/pages/TeacherDashboardOrganizationQuotes/index.tsx +521 -0
  54. package/js/pages/TeacherDashboardOrganizationQuotesLayout/index.tsx +26 -0
  55. package/js/translations/ar-SA.json +1 -1
  56. package/js/translations/es-ES.json +1 -1
  57. package/js/translations/fa-IR.json +1 -1
  58. package/js/translations/fr-CA.json +1 -1
  59. package/js/translations/fr-FR.json +1 -1
  60. package/js/translations/ko-KR.json +1 -1
  61. package/js/translations/pt-PT.json +1 -1
  62. package/js/translations/ru-RU.json +1 -1
  63. package/js/translations/vi-VN.json +1 -1
  64. package/js/types/Joanie.ts +216 -1
  65. package/js/utils/AbilitiesHelper/agreementAbilities.ts +14 -0
  66. package/js/utils/AbilitiesHelper/index.ts +7 -0
  67. package/js/utils/AbilitiesHelper/types.ts +12 -3
  68. package/js/utils/ObjectHelper/index.ts +20 -0
  69. package/js/utils/OrderHelper/index.ts +10 -0
  70. package/js/utils/errors/HttpError.ts +1 -0
  71. package/js/utils/test/factories/joanie.ts +156 -1
  72. package/js/widgets/Dashboard/components/DashboardBatchOrderLoader/_styles.scss +14 -0
  73. package/js/widgets/Dashboard/components/DashboardBatchOrderLoader/index.tsx +32 -0
  74. package/js/widgets/Dashboard/components/DashboardCard/index.spec.tsx +18 -0
  75. package/js/widgets/Dashboard/components/DashboardCard/index.stories.tsx +25 -2
  76. package/js/widgets/Dashboard/components/DashboardCard/index.tsx +4 -2
  77. package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/BatchOrderPaymentModal/BatchOrderPaymentManager.tsx +88 -0
  78. package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/BatchOrderPaymentModal/index.tsx +216 -0
  79. package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/DashboardBatchOrderSubItems.tsx +316 -0
  80. package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/index.spec.tsx +27 -0
  81. package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/index.tsx +175 -0
  82. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.tsx +5 -2
  83. package/js/widgets/Dashboard/components/DashboardItem/Order/OrganizationBlock/index.tsx +4 -1
  84. package/js/widgets/Dashboard/components/DashboardItem/Order/_styles.scss +5 -0
  85. package/js/widgets/Dashboard/components/DashboardItem/_styles.scss +43 -0
  86. package/js/widgets/Dashboard/components/DashboardSidebar/components/AgreementNavLink/index.spec.tsx +214 -0
  87. package/js/widgets/Dashboard/components/DashboardSidebar/components/AgreementNavLink/index.tsx +47 -0
  88. package/js/widgets/Dashboard/components/LearnerDashboardSidebar/index.tsx +1 -0
  89. package/js/widgets/Dashboard/components/TeacherDashboardOrganizationSidebar/index.spec.tsx +21 -3
  90. package/js/widgets/Dashboard/components/TeacherDashboardOrganizationSidebar/index.tsx +9 -0
  91. package/js/widgets/Dashboard/utils/learnerRoutes.tsx +30 -0
  92. package/js/widgets/Dashboard/utils/learnerRoutesPaths.tsx +12 -0
  93. package/js/widgets/Dashboard/utils/teacherDashboardPaths.tsx +12 -0
  94. package/js/widgets/Dashboard/utils/teacherRoutes.tsx +17 -0
  95. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/index.spec.tsx +8 -2
  96. package/package.json +4 -1
  97. package/scss/colors/_theme.scss +1 -1
  98. package/scss/components/_index.scss +1 -0
@@ -0,0 +1,356 @@
1
+ import { screen, within } from '@testing-library/react';
2
+ import fetchMock from 'fetch-mock';
3
+ import queryString from 'query-string';
4
+ import userEvent from '@testing-library/user-event';
5
+ import {
6
+ RichieContextFactory as mockRichieContextFactory,
7
+ PacedCourseFactory,
8
+ UserFactory,
9
+ } from 'utils/test/factories/richie';
10
+ import { render } from 'utils/test/render';
11
+ import { setupJoanieSession } from 'utils/test/wrappers/JoanieAppWrapper';
12
+ import CourseProductItem from 'widgets/SyllabusCourseRunsList/components/CourseProductItem';
13
+ import {
14
+ OfferingFactory,
15
+ PaymentPlanFactory,
16
+ ProductFactory,
17
+ OfferingBatchOrderFactory,
18
+ BatchOrderReadFactory,
19
+ CredentialOrderFactory,
20
+ } from 'utils/test/factories/joanie';
21
+ import { CourseRun, NOT_CANCELED_ORDER_STATES, OrderState } from 'types/Joanie';
22
+ import { Priority } from 'types';
23
+ import { expectMenuToBeClosed, expectMenuToBeOpen } from 'utils/test/Cunningham';
24
+ import { OpenEdxApiProfileFactory } from 'utils/test/factories/openEdx';
25
+ import { User } from 'types/User';
26
+ import { OpenEdxApiProfile } from 'types/openEdx';
27
+ import { createTestQueryClient } from 'utils/test/createTestQueryClient';
28
+
29
+ jest.mock('utils/context', () => ({
30
+ __esModule: true,
31
+ default: mockRichieContextFactory({
32
+ authentication: { backend: 'fonzie', endpoint: 'https://auth.test' },
33
+ joanie_backend: { endpoint: 'https://joanie.endpoint' },
34
+ }).one(),
35
+ }));
36
+
37
+ jest.mock('utils/indirection/window', () => ({
38
+ matchMedia: () => ({
39
+ matches: true,
40
+ addListener: jest.fn(),
41
+ removeListener: jest.fn(),
42
+ }),
43
+ }));
44
+
45
+ jest.mock('../PaymentInterfaces');
46
+
47
+ describe('SaleTunnel', () => {
48
+ let richieUser: User;
49
+ let openApiEdxProfile: OpenEdxApiProfile;
50
+ setupJoanieSession();
51
+
52
+ const formatPrice = (currency: string, price: number) =>
53
+ new Intl.NumberFormat('en', { currency, style: 'currency' })
54
+ .format(price)
55
+ .replace(/(\u202F|\u00a0)/g, ' ');
56
+
57
+ beforeEach(() => {
58
+ richieUser = UserFactory().one();
59
+ openApiEdxProfile = OpenEdxApiProfileFactory({
60
+ username: richieUser.username,
61
+ email: richieUser.email,
62
+ name: richieUser.full_name,
63
+ }).one();
64
+
65
+ const { 'pref-lang': prefLang, ...openEdxAccount } = openApiEdxProfile;
66
+
67
+ fetchMock.get(`https://auth.test/api/user/v1/accounts/${richieUser.username}`, openEdxAccount);
68
+ fetchMock.patch(
69
+ `https://auth.test/api/user/v1/accounts/${richieUser.username}`,
70
+ openEdxAccount,
71
+ );
72
+ fetchMock.get(`https://auth.test/api/user/v1/preferences/${richieUser.username}`, {
73
+ 'pref-lang': prefLang,
74
+ });
75
+ fetchMock.get(`https://auth.test/api/v1.0/user/me`, richieUser);
76
+ });
77
+
78
+ afterEach(() => fetchMock.reset());
79
+
80
+ it('tests the entire process of subscribing to a batch order', async () => {
81
+ const course = PacedCourseFactory().one();
82
+ const product = ProductFactory().one();
83
+ const offering = OfferingFactory({ course, product, is_withdrawable: false }).one();
84
+ const paymentPlan = PaymentPlanFactory().one();
85
+ const offeringOrganization = OfferingBatchOrderFactory({
86
+ product: { id: product.id, title: product.title },
87
+ }).one();
88
+
89
+ fetchMock.get(
90
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/`,
91
+ offering,
92
+ );
93
+ fetchMock.get(
94
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
95
+ paymentPlan,
96
+ );
97
+ fetchMock.get(`https://joanie.endpoint/api/v1.0/enrollments/`, []);
98
+ fetchMock.get(
99
+ `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify({
100
+ product_id: product.id,
101
+ course_code: course.code,
102
+ state: NOT_CANCELED_ORDER_STATES,
103
+ })}`,
104
+ [],
105
+ );
106
+ fetchMock.get(
107
+ `https://joanie.endpoint/api/v1.0/offerings/${offering.id}/get-organizations/`,
108
+ offeringOrganization,
109
+ );
110
+
111
+ render(<CourseProductItem productId={product.id} course={course} />, {
112
+ queryOptions: { client: createTestQueryClient({ user: richieUser }) },
113
+ });
114
+
115
+ // Verify product info
116
+ await screen.findByRole('heading', { level: 3, name: product.title });
117
+ await screen.findByText(formatPrice(product.price_currency, product.price));
118
+ expect(screen.queryByText('Purchased')).not.toBeInTheDocument();
119
+
120
+ const user = userEvent.setup();
121
+ const buyButton = screen.getByRole('button', { name: product.call_to_action });
122
+
123
+ expect(screen.queryByTestId('generic-sale-tunnel-payment-step')).not.toBeInTheDocument();
124
+ await user.click(buyButton);
125
+ await screen.findByTestId('generic-sale-tunnel-payment-step');
126
+
127
+ // Verify learning path
128
+ await screen.findByText('Your learning path');
129
+ const targetCourses = await screen.findAllByTestId('product-target-course');
130
+ expect(targetCourses).toHaveLength(product.target_courses.length);
131
+ targetCourses.forEach((targetCourse, index) => {
132
+ const courseItem = product.target_courses[index];
133
+ const courseDetail = within(targetCourse).getByTestId(
134
+ `target-course-detail-${courseItem.code}`,
135
+ );
136
+ const summary = courseDetail.querySelector('summary')!;
137
+ expect(summary).toHaveTextContent(courseItem.title);
138
+
139
+ const courseRuns = targetCourse.querySelectorAll(
140
+ '.product-detail-row__course-run-dates__item',
141
+ );
142
+ const openedCourseRuns = courseItem.course_runs.filter(
143
+ (cr: CourseRun) => cr.state.priority <= Priority.FUTURE_NOT_YET_OPEN,
144
+ );
145
+ expect(courseRuns).toHaveLength(openedCourseRuns.length);
146
+ });
147
+
148
+ // Select group buy form
149
+ await screen.findByText('Purchase type');
150
+ const formTypeSelect = screen.getByRole('combobox', { name: 'Purchase type' });
151
+ const menu: HTMLDivElement = screen.getByRole('listbox', { name: 'Purchase type' });
152
+ expectMenuToBeClosed(menu);
153
+ await user.click(formTypeSelect);
154
+ expectMenuToBeOpen(menu);
155
+ await user.click(screen.getByText('Group purchase (B2B)'));
156
+
157
+ // Company step
158
+ const $companyName = await screen.findByRole('textbox', { name: 'Company name' });
159
+ const $idNumber = screen.getByRole('textbox', { name: /Identification number/ });
160
+ const $address = screen.getByRole('textbox', { name: 'Address' });
161
+ const $postCode = screen.getByRole('textbox', { name: 'Post code' });
162
+ const $city = screen.getByRole('textbox', { name: 'City' });
163
+ const $country = screen.getByRole('combobox', { name: 'Country' });
164
+
165
+ await user.type($companyName, 'GIP-FUN');
166
+ await user.type($idNumber, '789 242 229 01694');
167
+ await user.type($address, '61 Bis Rue de la Glaciere');
168
+ await user.type($postCode, '75013');
169
+ await user.type($city, 'Paris');
170
+
171
+ const countryMenu: HTMLDivElement = screen.getByRole('listbox', { name: 'Country' });
172
+ await user.click($country);
173
+ expectMenuToBeOpen(countryMenu);
174
+ await user.click(screen.getByText('France'));
175
+
176
+ expect($companyName).toHaveValue('GIP-FUN');
177
+ const visibleValue = $country.querySelector('.c__select__inner__value span');
178
+ expect(visibleValue!.textContent).toBe('France');
179
+
180
+ // Follow-up step
181
+ await user.click(screen.getByRole('button', { name: 'Next' }));
182
+ const $lastName = await screen.findByRole('textbox', { name: 'Last name' });
183
+ const $firstName = screen.getByRole('textbox', { name: 'First name' });
184
+ const $role = screen.getByRole('textbox', { name: 'Role' });
185
+ const $email = screen.getByRole('textbox', { name: 'Email' });
186
+ const $phone = screen.getByRole('textbox', { name: 'Phone' });
187
+
188
+ await user.type($lastName, 'Doe');
189
+ await user.type($firstName, 'John');
190
+ await user.type($role, 'HR');
191
+ await user.type($email, 'john.doe@fun-mooc.com');
192
+ await user.type($phone, '+338203920103');
193
+
194
+ expect($lastName).toHaveValue('Doe');
195
+ expect($email).toHaveValue('john.doe@fun-mooc.com');
196
+
197
+ // Signatory step
198
+ await user.click(screen.getByRole('button', { name: 'Next' }));
199
+ const $signatoryLastName = await screen.findByRole('textbox', { name: 'Last name' });
200
+ const $signatoryFirstName = screen.getByRole('textbox', { name: 'First name' });
201
+ const $signatoryRole = screen.getByRole('textbox', { name: 'Role' });
202
+ const $signatoryEmail = screen.getByRole('textbox', { name: 'Email' });
203
+ const $signatoryPhone = screen.getByRole('textbox', { name: 'Phone' });
204
+
205
+ await user.type($signatoryLastName, 'Doe');
206
+ await user.type($signatoryFirstName, 'John');
207
+ await user.type($signatoryRole, 'CEO');
208
+ await user.type($signatoryEmail, 'john.doe@fun-mooc.com');
209
+ await user.type($signatoryPhone, '+338203920103');
210
+
211
+ // Participants step
212
+ await user.click(screen.getByRole('button', { name: 'Next' }));
213
+ const $nbParticipants = await screen.findByLabelText('How many participants ?');
214
+ await user.type($nbParticipants, '13');
215
+ expect($nbParticipants).toHaveValue(13);
216
+
217
+ // Submit the batch order
218
+ const batchOrderRead = BatchOrderReadFactory().one();
219
+ fetchMock.post('https://joanie.endpoint/api/v1.0/batch-orders/', batchOrderRead);
220
+ const $subscribebutton = screen.getByRole('button', {
221
+ name: `Subscribe`,
222
+ }) as HTMLButtonElement;
223
+ await user.click($subscribebutton);
224
+ await screen.findByTestId('generic-sale-tunnel-success-step');
225
+ screen.getByText('Subscription confirmed!');
226
+ screen.getByText('Your order has been successfully registered.');
227
+ const $dashboardLink = screen.getByRole('link', { name: 'Close' });
228
+ expect($dashboardLink).toHaveAttribute(
229
+ 'href',
230
+ `/en/dashboard/batch-orders/${batchOrderRead.id}`,
231
+ );
232
+ }, 30000);
233
+
234
+ it('tests the entire process of subscribing with a voucher from a batch order', async () => {
235
+ /**
236
+ * Initialization.
237
+ */
238
+ const course = PacedCourseFactory().one();
239
+ const product = ProductFactory().one();
240
+ const offering = OfferingFactory({
241
+ course,
242
+ product,
243
+ is_withdrawable: false,
244
+ }).one();
245
+ const paymentPlan = PaymentPlanFactory().one();
246
+
247
+ fetchMock.get(
248
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/`,
249
+ offering,
250
+ );
251
+ fetchMock.get(
252
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
253
+ paymentPlan,
254
+ );
255
+ fetchMock.get(`https://joanie.endpoint/api/v1.0/enrollments/`, []);
256
+ const orderQueryParameters = {
257
+ product_id: product.id,
258
+ course_code: course.code,
259
+ state: NOT_CANCELED_ORDER_STATES,
260
+ };
261
+ fetchMock.get(
262
+ `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(orderQueryParameters)}`,
263
+ [],
264
+ );
265
+
266
+ render(<CourseProductItem productId={product.id} course={course} />, {
267
+ queryOptions: { client: createTestQueryClient({ user: richieUser }) },
268
+ });
269
+
270
+ // Wait for product information to be fetched
271
+ await screen.findByRole('heading', { level: 3, name: product.title });
272
+ // Price is displayed, meaning the product is not bought yet.
273
+ screen.getByText(
274
+ // the price formatter generates non-breaking spaces and getByText doesn't seem to handle that well, replace it
275
+ // with a regular space. We replace NNBSP (\u202F) and NBSP (\u00a0) with a regular space
276
+ formatPrice(product.price_currency, product.price),
277
+ );
278
+ expect(screen.queryByText('Purchased')).not.toBeInTheDocument();
279
+
280
+ /**
281
+ * Purchase.
282
+ */
283
+ const user = userEvent.setup();
284
+ const buyButton = screen.getByRole('button', { name: product.call_to_action });
285
+
286
+ // The SaleTunnel should not be displayed.
287
+ expect(screen.queryByTestId('generic-sale-tunnel-payment-step')).not.toBeInTheDocument();
288
+
289
+ await user.click(buyButton);
290
+
291
+ // The SaleTunnel should be displayed.
292
+ screen.getByTestId('generic-sale-tunnel-payment-step');
293
+
294
+ /**
295
+ * Submit voucher and check price
296
+ */
297
+ const paymentPlanVoucher = PaymentPlanFactory({
298
+ discounted_price: 0,
299
+ discount: '-100%',
300
+ payment_schedule: undefined,
301
+ from_batch_order: true,
302
+ }).one();
303
+ fetchMock.get(
304
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/?voucher_code=DISCOUNT100`,
305
+ paymentPlanVoucher,
306
+ { overwriteRoutes: true },
307
+ );
308
+ await user.type(screen.getByLabelText('Voucher code'), 'DISCOUNT100');
309
+ await user.click(screen.getByRole('button', { name: 'Validate' }));
310
+ expect(screen.queryByRole('heading', { name: 'Payment schedule' })).not.toBeInTheDocument();
311
+ await screen.findByTestId('sale-tunnel__total__amount');
312
+ const $totalAmountVoucher = screen.getByTestId('sale-tunnel__total__amount');
313
+ expect($totalAmountVoucher).toHaveTextContent(
314
+ 'Total' +
315
+ formatPrice(product.price_currency, paymentPlanVoucher.price!) +
316
+ formatPrice(product.price_currency, paymentPlanVoucher.discounted_price!),
317
+ );
318
+
319
+ /**
320
+ * Make sure the checkbox to waive withdrawal right is not displayed
321
+ */
322
+ expect(screen.queryByTestId('withdraw-right-checkbox')).not.toBeInTheDocument();
323
+
324
+ /**
325
+ * Subscribe
326
+ */
327
+ const order = CredentialOrderFactory({
328
+ state: OrderState.COMPLETED,
329
+ payment_schedule: undefined,
330
+ }).one();
331
+ fetchMock
332
+ .post('https://joanie.endpoint/api/v1.0/orders/', order)
333
+ .get(
334
+ `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(orderQueryParameters)}`,
335
+ [order],
336
+ { overwriteRoutes: true },
337
+ );
338
+
339
+ const $button = screen.getByRole('button', {
340
+ name: `Subscribe`,
341
+ }) as HTMLButtonElement;
342
+ await user.click($button);
343
+
344
+ /**
345
+ * No withdrawal error should be displayed.
346
+ */
347
+ expect(
348
+ await screen.queryByText('You must waive your withdrawal right.'),
349
+ ).not.toBeInTheDocument();
350
+
351
+ // // Make sure the success step is shown.
352
+ await screen.findByTestId('generic-sale-tunnel-success-step');
353
+
354
+ screen.getByText('Subscription confirmed!');
355
+ }, 10000);
356
+ });
@@ -394,6 +394,9 @@ describe('SaleTunnel', () => {
394
394
  // Make sure the success step is shown.
395
395
  await screen.findByTestId('generic-sale-tunnel-success-step');
396
396
  screen.getByText('Subscription confirmed!');
397
+ screen.getByText(
398
+ /your order has been successfully registered\.you will be able to start your training once the first installment will be paid\./i,
399
+ );
397
400
  screen.getByRole('link', { name: 'Close' });
398
401
 
399
402
  /**
@@ -403,5 +406,5 @@ describe('SaleTunnel', () => {
403
406
  // This way we make sure the cache is updated.
404
407
  await screen.findByText('Purchased');
405
408
  expect(screen.queryByRole('button', { name: product.call_to_action })).not.toBeInTheDocument();
406
- }, 15000);
409
+ }, 30000);
407
410
  });
@@ -690,9 +690,13 @@ describe.each([
690
690
  });
691
691
  });
692
692
 
693
- it('should show an error when submit an invalid voucher', async () => {
693
+ it('should show appropriate error messages for invalid vouchers', async () => {
694
694
  const user = userEvent.setup({ delay: null });
695
695
  const paymentPlan = PaymentPlanFactory().one();
696
+ const paymentPlanVoucher = PaymentPlanFactory({
697
+ discounted_price: 70,
698
+ discount: '-30%',
699
+ }).one();
696
700
  const product = ProductFactory().one();
697
701
  fetchMock
698
702
  .get(
@@ -711,6 +715,19 @@ describe.each([
711
715
  detail: 'No Voucher matches the given query.',
712
716
  },
713
717
  },
718
+ )
719
+ .get(
720
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/?voucher_code=DISCOUNT29`,
721
+ {
722
+ status: 429,
723
+ body: {
724
+ detail: 'Too many attempts. Please try again later.',
725
+ },
726
+ },
727
+ )
728
+ .get(
729
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/?voucher_code=DISCOUNT10`,
730
+ paymentPlanVoucher,
714
731
  );
715
732
  render(<Wrapper paymentPlan={paymentPlan} product={product} isWithdrawable={true} />, {
716
733
  queryOptions: { client: createTestQueryClient({ user: richieUser }) },
@@ -719,5 +736,117 @@ describe.each([
719
736
  await user.type(screen.getByLabelText('Voucher code'), 'DISCOUNT30');
720
737
  await user.click(screen.getByRole('button', { name: 'Validate' }));
721
738
  expect(await screen.findByText('The submitted voucher code is not valid.')).toBeInTheDocument();
739
+ await user.type(screen.getByLabelText('Voucher code'), 'DISCOUNT29');
740
+ await user.click(screen.getByRole('button', { name: 'Validate' }));
741
+ expect(
742
+ await screen.findByText('Too many attempts. Please try again later.'),
743
+ ).toBeInTheDocument();
744
+ await user.type(screen.getByLabelText('Voucher code'), 'DISCOUNT10');
745
+ await user.click(screen.getByRole('button', { name: 'Validate' }));
746
+ expect(await screen.findByText('Discount applied')).toBeInTheDocument();
747
+ });
748
+
749
+ it('should hide billing informations for a voucher from a batch order', async () => {
750
+ const user = userEvent.setup({ delay: null });
751
+ const paymentPlan = PaymentPlanFactory().one();
752
+ const paymentPlanVoucher = PaymentPlanFactory({
753
+ discounted_price: 0.0,
754
+ discount: '-100%',
755
+ from_batch_order: true,
756
+ }).one();
757
+ const product = ProductFactory().one();
758
+ fetchMock
759
+ .get(
760
+ `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
761
+ [],
762
+ )
763
+ .get(
764
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
765
+ paymentPlan,
766
+ )
767
+ .get(
768
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/?voucher_code=DISCOUNT100`,
769
+ paymentPlanVoucher,
770
+ );
771
+ render(<Wrapper paymentPlan={paymentPlan} product={product} isWithdrawable={true} />, {
772
+ queryOptions: { client: createTestQueryClient({ user: richieUser }) },
773
+ });
774
+ expect(
775
+ await screen.queryByText('Those information will be used for billing'),
776
+ ).toBeInTheDocument();
777
+
778
+ await user.type(screen.getByLabelText('Voucher code'), 'DISCOUNT100');
779
+ await user.click(screen.getByRole('button', { name: 'Validate' }));
780
+
781
+ expect(await screen.findByText('Discount applied')).toBeInTheDocument();
782
+ await waitFor(async () =>
783
+ expect(
784
+ await screen.queryByText('Those information will be used for billing'),
785
+ ).not.toBeInTheDocument(),
786
+ );
787
+ expect(await screen.queryByTestId('withdraw-right-checkbox')).not.toBeInTheDocument();
788
+ });
789
+
790
+ it('should hide voucher code input when one is already used', async () => {
791
+ const user = userEvent.setup({ delay: null });
792
+ const paymentPlan = PaymentPlanFactory().one();
793
+ const paymentPlanVoucher = PaymentPlanFactory({
794
+ discounted_price: 70,
795
+ discount: '-30%',
796
+ }).one();
797
+ const product = ProductFactory().one();
798
+ fetchMock
799
+ .get(
800
+ `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
801
+ [],
802
+ )
803
+ .get(
804
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
805
+ paymentPlan,
806
+ )
807
+ .get(
808
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/?voucher_code=DISCOUNT30`,
809
+ paymentPlanVoucher,
810
+ );
811
+ render(<Wrapper paymentPlan={paymentPlan} product={product} isWithdrawable={true} />, {
812
+ queryOptions: { client: createTestQueryClient({ user: richieUser }) },
813
+ });
814
+
815
+ // Initially, voucher input should be visible
816
+ expect(screen.getByLabelText('Voucher code')).toBeInTheDocument();
817
+ expect(screen.getByRole('button', { name: 'Validate' })).toBeInTheDocument();
818
+ expect(
819
+ screen.getByText('If you have a voucher code, please enter it in the field below.'),
820
+ ).toBeInTheDocument();
821
+
822
+ // Apply a voucher code
823
+ await user.type(screen.getByLabelText('Voucher code'), 'DISCOUNT30');
824
+ await user.click(screen.getByRole('button', { name: 'Validate' }));
825
+
826
+ // Wait for voucher to be applied
827
+ expect(await screen.findByText('Discount applied')).toBeInTheDocument();
828
+
829
+ // Voucher input should be hidden
830
+ expect(screen.queryByLabelText('Voucher code')).not.toBeInTheDocument();
831
+ expect(screen.queryByRole('button', { name: 'Validate' })).not.toBeInTheDocument();
832
+ expect(
833
+ screen.queryByText('If you have a voucher code, please enter it in the field below.'),
834
+ ).not.toBeInTheDocument();
835
+
836
+ // Voucher tag should be visible with the applied code
837
+ expect(screen.getByText('DISCOUNT30')).toBeInTheDocument();
838
+
839
+ // Remove the voucher
840
+ await user.click(screen.getByTitle('Delete this voucher'));
841
+
842
+ // Voucher input should be visible again
843
+ expect(screen.getByLabelText('Voucher code')).toBeInTheDocument();
844
+ expect(screen.getByRole('button', { name: 'Validate' })).toBeInTheDocument();
845
+ expect(
846
+ screen.getByText('If you have a voucher code, please enter it in the field below.'),
847
+ ).toBeInTheDocument();
848
+
849
+ // Voucher tag should be hidden
850
+ expect(screen.queryByText('DISCOUNT30')).not.toBeInTheDocument();
722
851
  });
723
852
  });
@@ -0,0 +1,36 @@
1
+ import { defineMessages } from 'react-intl';
2
+ import { useJoanieApi } from 'contexts/JoanieApiContext';
3
+ import { ResourcesQuery, useResource, useResources, UseResourcesProps } from 'hooks/useResources';
4
+ import { API, BatchOrderQueryFilters, BatchOrderRead } from 'types/Joanie';
5
+
6
+ const messages = defineMessages({
7
+ errorCreate: {
8
+ id: 'hooks.useBatchOrders.errorCreate',
9
+ defaultMessage: 'An error occurred while creating the batch order.',
10
+ description: 'Error message shown when batch order creation fails.',
11
+ },
12
+ });
13
+
14
+ const props: UseResourcesProps<BatchOrderRead, BatchOrderQueryFilters, API['user']['batchOrders']> =
15
+ {
16
+ queryKey: ['batchOrders'],
17
+ apiInterface: () => useJoanieApi().user.batchOrders,
18
+ session: true,
19
+ messages,
20
+ };
21
+
22
+ export const useBatchOrders = useResources<BatchOrderRead, BatchOrderQueryFilters>(props);
23
+ export const useBatchOrder = useResource<BatchOrderRead, BatchOrderQueryFilters>(props);
24
+
25
+ export const useBatchOrdersActions = () => {
26
+ const { user } = useJoanieApi();
27
+ const api = user.batchOrders;
28
+
29
+ const submitForPayment = async (filters: ResourcesQuery) => {
30
+ return api.submit_for_payment.create(filters);
31
+ };
32
+
33
+ return {
34
+ submitForPayment,
35
+ };
36
+ };
@@ -54,10 +54,12 @@ const useContractArchive = () => {
54
54
  create: async (
55
55
  organizationId?: Organization['id'],
56
56
  offeringId?: Offering['id'],
57
+ fromBatchOrder?: boolean,
57
58
  ): Promise<string> => {
58
59
  const response = await api.user.contracts.zip_archive.create({
59
60
  organization_id: organizationId,
60
61
  offering_id: offeringId,
62
+ from_batch_order: fromBatchOrder,
61
63
  });
62
64
 
63
65
  return extractArchiveId(response.url);
@@ -0,0 +1,38 @@
1
+ import { defineMessages } from 'react-intl';
2
+
3
+ import { API, Organization, OrganizationResourceQuery } from 'types/Joanie';
4
+ import { useJoanieApi } from 'contexts/JoanieApiContext';
5
+ import { useResource, useResources, UseResourcesProps } from '../useResources';
6
+
7
+ const messages = defineMessages({
8
+ errorGet: {
9
+ id: 'hooks.useOfferingOrganizations.errorSelect',
10
+ description: 'Error message shown to the user when organizations fetch request fails.',
11
+ defaultMessage: 'An error occurred while fetching organizations. Please retry later.',
12
+ },
13
+ errorNotFound: {
14
+ id: 'hooks.useOfferingOrganizations.errorNotFound',
15
+ description: 'Error message shown to the user when no organizations matches.',
16
+ defaultMessage: 'Cannot find the organization',
17
+ },
18
+ });
19
+
20
+ /**
21
+ * Joanie Api hook to retrieve organizations
22
+ * owned by the authenticated user.
23
+ */
24
+ const props: UseResourcesProps<
25
+ Organization,
26
+ OrganizationResourceQuery,
27
+ API['offerings']['organizations']
28
+ > = {
29
+ queryKey: ['offeringOrganizations'],
30
+ apiInterface: () => useJoanieApi().offerings.organizations,
31
+ session: true,
32
+ messages,
33
+ };
34
+
35
+ export const useOfferingOrganizations = useResources<Organization, OrganizationResourceQuery>(
36
+ props,
37
+ );
38
+ export const useOfferingOrganization = useResource<Organization, OrganizationResourceQuery>(props);
@@ -0,0 +1,66 @@
1
+ import { defineMessages } from 'react-intl';
2
+ import { useJoanieApi } from 'contexts/JoanieApiContext';
3
+ import { QueryOptions, useResource, useResources, UseResourcesProps } from 'hooks/useResources';
4
+ import { Agreement, API, ContractResourceQuery } from 'types/Joanie';
5
+
6
+ const messages = defineMessages({
7
+ errorGet: {
8
+ id: 'hooks.useContracts.errorSelect',
9
+ description: 'Error message shown to the user when contracts fetch request fails.',
10
+ defaultMessage: 'An error occurred while fetching contracts. Please retry later.',
11
+ },
12
+ errorNotFound: {
13
+ id: 'hooks.useContracts.errorNotFound',
14
+ description: 'Error message shown to the user when no contract matches.',
15
+ defaultMessage: 'Cannot find the contract',
16
+ },
17
+ });
18
+
19
+ const props: UseResourcesProps<
20
+ Agreement,
21
+ ContractResourceQuery,
22
+ API['organizations']['agreements']
23
+ > = {
24
+ queryKey: ['organizationAgreements'],
25
+ apiInterface: () => useJoanieApi().organizations.agreements,
26
+ session: true,
27
+ messages,
28
+ };
29
+
30
+ /**
31
+ * Joanie Api hook to retrieve/update a contracts related to a course.
32
+ */
33
+ const organizationProps: UseResourcesProps<
34
+ Agreement,
35
+ ContractResourceQuery,
36
+ API['organizations']['agreements']
37
+ > = {
38
+ ...props,
39
+ queryKey: ['organizationAgreements'],
40
+ apiInterface: () => useJoanieApi().organizations.agreements,
41
+ };
42
+
43
+ export const useOrganizationAgreement = (
44
+ id: string,
45
+ filters: ContractResourceQuery,
46
+ queryOptions?: QueryOptions<Agreement>,
47
+ ) => {
48
+ return useResource(organizationProps)(id, filters, {
49
+ ...queryOptions,
50
+ enabled:
51
+ !!id &&
52
+ !!filters?.organization_id &&
53
+ (queryOptions?.enabled === undefined || queryOptions.enabled),
54
+ });
55
+ };
56
+
57
+ export const useOrganizationAgreements = (
58
+ filters: ContractResourceQuery,
59
+ queryOptions?: QueryOptions<Agreement>,
60
+ ) => {
61
+ return useResources(organizationProps)(filters, {
62
+ ...queryOptions,
63
+ enabled:
64
+ !!filters?.organization_id && (queryOptions?.enabled === undefined || queryOptions.enabled),
65
+ });
66
+ };