richie-education 3.4.0 → 3.4.1-dev14

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 (44) hide show
  1. package/.storybook/main.js +11 -12
  2. package/js/api/joanie.ts +20 -0
  3. package/js/api/lms/index.spec.ts +33 -0
  4. package/js/api/lms/index.ts +1 -1
  5. package/js/api/lms/openedx-hawthorn.spec.ts +49 -0
  6. package/js/api/lms/openedx-hawthorn.ts +5 -2
  7. package/js/api/utils.ts +4 -3
  8. package/js/components/DownloadAgreementButton/index.tsx +51 -0
  9. package/js/components/DownloadBatchOrderSeatsButton/index.spec.tsx +46 -0
  10. package/js/components/DownloadBatchOrderSeatsButton/index.tsx +80 -0
  11. package/js/components/SaleTunnel/SaleTunnelInformation/SaleTunnelInformationGroup.tsx +1 -1
  12. package/js/components/SaleTunnel/SaleTunnelSuccess/index.tsx +1 -1
  13. package/js/components/SaleTunnel/SubscriptionButton/index.tsx +2 -1
  14. package/js/components/SaleTunnel/index.full-process-b2b.spec.tsx +5 -5
  15. package/js/components/SaleTunnel/index.full-process-b2c.spec.tsx +1 -1
  16. package/js/hooks/useBatchOrder/index.tsx +21 -1
  17. package/js/hooks/useDownloadAgreement/index.spec.tsx +136 -0
  18. package/js/hooks/useDownloadAgreement/index.tsx +25 -0
  19. package/js/hooks/useDownloadBatchOrderSeats/index.spec.tsx +132 -0
  20. package/js/hooks/useDownloadBatchOrderSeats/index.tsx +24 -0
  21. package/js/pages/DashboardBatchOrderLayout/index.spec.tsx +19 -2
  22. package/js/pages/TeacherDashboardOrganizationQuotes/BatchOrderSeatInfoQuote.tsx +112 -0
  23. package/js/pages/TeacherDashboardOrganizationQuotes/_styles.scss +17 -0
  24. package/js/pages/TeacherDashboardOrganizationQuotes/index.full-process.spec.tsx +5 -2
  25. package/js/pages/TeacherDashboardOrganizationQuotes/index.spec.tsx +7 -3
  26. package/js/pages/TeacherDashboardOrganizationQuotes/index.tsx +38 -26
  27. package/js/types/Joanie.ts +21 -1
  28. package/js/types/api.ts +1 -0
  29. package/js/types/commonDataProps.ts +2 -0
  30. package/js/utils/download.ts +3 -1
  31. package/js/utils/test/factories/joanie.ts +15 -1
  32. package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/BatchOrderAgreementInfo.tsx +72 -0
  33. package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/BatchOrderSeatInfo.spec.tsx +114 -0
  34. package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/BatchOrderSeatInfo.tsx +133 -0
  35. package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/DashboardBatchOrderSubItems.tsx +17 -1
  36. package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/batchOrderSeatInfoMessages.ts +24 -0
  37. package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/index.tsx +16 -3
  38. package/js/widgets/Dashboard/components/DashboardItem/_styles.scss +6 -2
  39. package/js/widgets/Dashboard/utils/teacherDashboardPaths.tsx +2 -2
  40. package/js/widgets/Slider/index.tsx +7 -6
  41. package/package.json +2 -7
  42. package/scss/components/templates/richie/slider/_slider.scss +1 -1
  43. package/scss/objects/_course_glimpses.scss +1 -0
  44. package/scss/objects/_dashboard.scss +77 -0
@@ -1,12 +1,17 @@
1
1
  import { dirname, join } from "path";
2
+ import { createRequire } from "module";
3
+ import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin';
2
4
 
3
- const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
4
- module.exports = {
5
- stories: ['../stories/**/*.mdx', '../stories/**/*.stories.@(js|jsx|ts|tsx)', '../js/**/*.stories.@(js|jsx|ts|tsx)'],
5
+ const require = createRequire(import.meta.url);
6
+
7
+ function getAbsolutePath(value) {
8
+ return dirname(require.resolve(join(value, "package.json")));
9
+ }
10
+
11
+ export default {
12
+ stories: ['../stories/**/*.stories.@(js|jsx|ts|tsx)', '../js/**/*.stories.@(js|jsx|ts|tsx)'],
6
13
  addons: [
7
14
  getAbsolutePath('@storybook/addon-links'),
8
- getAbsolutePath('@storybook/addon-essentials'),
9
- getAbsolutePath('@storybook/addon-interactions'),
10
15
  getAbsolutePath('@storybook/addon-webpack5-compiler-babel'),
11
16
  ],
12
17
  framework: {
@@ -23,9 +28,7 @@ module.exports = {
23
28
  from: '../../richie/apps/core/static',
24
29
  to: '/static',
25
30
  }, '../../richie/apps/core/templates/richie'],
26
- webpackFinal: async (config, {
27
- configType,
28
- }) => {
31
+ webpackFinal: async (config, { configType }) => {
29
32
  config.resolve.plugins = [new TsconfigPathsPlugin()];
30
33
  return config;
31
34
  },
@@ -33,7 +36,3 @@ module.exports = {
33
36
  autodocs: false,
34
37
  },
35
38
  };
36
-
37
- function getAbsolutePath(value) {
38
- return dirname(require.resolve(join(value, "package.json")));
39
- }
package/js/api/joanie.ts CHANGED
@@ -107,6 +107,10 @@ export const getRoutes = () => {
107
107
  submit_for_payment: {
108
108
  create: `${baseUrl}/batch-orders/:id/submit-for-payment/`,
109
109
  },
110
+ seats: {
111
+ get: `${baseUrl}/batch-orders/:batch_order_id/seats/`,
112
+ },
113
+ seats_export: `${baseUrl}/batch-orders/:id/seats-export/`,
110
114
  },
111
115
  certificates: {
112
116
  download: `${baseUrl}/certificates/:id/download/`,
@@ -161,6 +165,7 @@ export const getRoutes = () => {
161
165
  },
162
166
  agreements: {
163
167
  get: `${baseUrl}/organizations/:organization_id/agreements/:id/`,
168
+ download: `${baseUrl}/organizations/:organization_id/agreements/:id/download/`,
164
169
  },
165
170
  },
166
171
  courses: {
@@ -354,6 +359,17 @@ const API = (): Joanie.API => {
354
359
  ).then(checkStatus);
355
360
  },
356
361
  },
362
+ seats: {
363
+ get: async (filters?: Joanie.BatchOrderSeatsQueryFilters) => {
364
+ return fetchWithJWT(buildApiUrl(ROUTES.user.batchOrders.seats.get, filters)).then(
365
+ checkStatus,
366
+ );
367
+ },
368
+ },
369
+ seats_export: async (id: string): Promise<File> =>
370
+ fetchWithJWT(ROUTES.user.batchOrders.seats_export.replace(':id', id))
371
+ .then(checkStatus)
372
+ .then(getFileFromResponse),
357
373
  },
358
374
  enrollments: {
359
375
  create: async (payload) =>
@@ -545,6 +561,10 @@ const API = (): Joanie.API => {
545
561
  method: 'GET',
546
562
  }).then(checkStatus);
547
563
  },
564
+ download: async (filters: { organization_id: string; id: string }): Promise<File> =>
565
+ fetchWithJWT(buildApiUrl(ROUTES.organizations.agreements.download, filters))
566
+ .then(checkStatus)
567
+ .then(getFileFromResponse),
548
568
  },
549
569
  },
550
570
  courses: {
@@ -1,7 +1,14 @@
1
1
  import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
2
2
  import { handle } from 'utils/errors/handle';
3
+ import { location } from 'utils/indirection/window';
3
4
  import LMSHandler from '.';
4
5
 
6
+ jest.mock('utils/indirection/window', () => ({
7
+ location: {
8
+ pathname: '/courses/a-test-course/',
9
+ assign: jest.fn(),
10
+ },
11
+ }));
5
12
  jest.mock('utils/context', () => ({
6
13
  __esModule: true,
7
14
  default: mockRichieContextFactory({
@@ -16,6 +23,12 @@ jest.mock('utils/context', () => ({
16
23
  endpoint: 'https://edx.endpoint/api',
17
24
  course_regexp: '.*edx.org/.*',
18
25
  },
26
+ {
27
+ backend: 'openedx-hawthorn',
28
+ endpoint: 'https://nau.endpoint/api',
29
+ course_regexp: '.*nau.org/.*',
30
+ next_url: 'richie-nau',
31
+ },
19
32
  ],
20
33
  }).one(),
21
34
  }));
@@ -24,6 +37,10 @@ const mockHandle: jest.Mock<typeof handle> = handle as any;
24
37
  jest.mock('utils/errors/handle');
25
38
 
26
39
  describe('API LMS', () => {
40
+ beforeEach(() => {
41
+ jest.clearAllMocks();
42
+ });
43
+
27
44
  it('returns OpenEdX API if url that match edx selector is provided', () => {
28
45
  const api = LMSHandler('https://edx.org/courses/a-test-course');
29
46
  expect(api).toBeDefined();
@@ -42,4 +59,20 @@ describe('API LMS', () => {
42
59
  new Error('No LMS Backend found for https://unknown.org/course/a-test-course.'),
43
60
  );
44
61
  });
62
+
63
+ it('uses default "richie" next prefix for openedx-hawthorn without next_url configured', () => {
64
+ const api = LMSHandler('https://edx.org/courses/a-test-course');
65
+ api.user.login();
66
+ expect(location.assign).toHaveBeenCalledWith(
67
+ `https://edx.endpoint/api/login?next=richie${location.pathname}`,
68
+ );
69
+ });
70
+
71
+ it('uses configured next_url prefix for openedx-hawthorn with next_url set', () => {
72
+ const api = LMSHandler('https://nau.org/courses/a-test-course');
73
+ api.user.login();
74
+ expect(location.assign).toHaveBeenCalledWith(
75
+ `https://nau.endpoint/api/login?next=richie-nau${location.pathname}`,
76
+ );
77
+ });
45
78
  });
@@ -15,7 +15,7 @@ const LmsAPIHandler = (url: string): APILms => {
15
15
  case APIBackend.OPENEDX_DOGWOOD:
16
16
  return OpenEdxDogwoodApiInterface(api);
17
17
  case APIBackend.OPENEDX_HAWTHORN:
18
- return OpenEdxHawthornApiInterface(api);
18
+ return OpenEdxHawthornApiInterface(api, { routes: {}, nextURL: api.next_url });
19
19
  }
20
20
 
21
21
  const error = new Error(`No LMS Backend found for ${url}.`);
@@ -3,10 +3,17 @@ import { faker } from '@faker-js/faker';
3
3
  import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
4
4
  import { handle } from 'utils/errors/handle';
5
5
  import { HttpError, HttpStatusCode } from 'utils/errors/HttpError';
6
+ import { location } from 'utils/indirection/window';
6
7
  import context from 'utils/context';
7
8
  import API from './openedx-hawthorn';
8
9
 
9
10
  jest.mock('utils/errors/handle');
11
+ jest.mock('utils/indirection/window', () => ({
12
+ location: {
13
+ pathname: '/courses/a-test-course/',
14
+ assign: jest.fn(),
15
+ },
16
+ }));
10
17
  jest.mock('utils/context', () => ({
11
18
  __esModule: true,
12
19
  default: mockRichieContextFactory({
@@ -45,6 +52,48 @@ describe('OpenEdX Hawthorn API', () => {
45
52
  });
46
53
  });
47
54
 
55
+ describe('user', () => {
56
+ beforeEach(() => {
57
+ jest.clearAllMocks();
58
+ });
59
+
60
+ describe('login', () => {
61
+ it('redirects to login with default "richie" next prefix when nextURL is not set', () => {
62
+ const api = API(LMSConf);
63
+ api.user.login();
64
+ expect(location.assign).toHaveBeenCalledWith(
65
+ `${EDX_ENDPOINT}/login?next=richie${location.pathname}`,
66
+ );
67
+ });
68
+
69
+ it('redirects to login with custom next prefix when nextURL option is provided', () => {
70
+ const api = API(LMSConf, { routes: {}, nextURL: 'richie-nau' });
71
+ api.user.login();
72
+ expect(location.assign).toHaveBeenCalledWith(
73
+ `${EDX_ENDPOINT}/login?next=richie-nau${location.pathname}`,
74
+ );
75
+ });
76
+ });
77
+
78
+ describe('register', () => {
79
+ it('redirects to register with default "richie" next prefix when nextURL is not set', () => {
80
+ const api = API(LMSConf);
81
+ api.user.register();
82
+ expect(location.assign).toHaveBeenCalledWith(
83
+ `${EDX_ENDPOINT}/register?next=richie${location.pathname}`,
84
+ );
85
+ });
86
+
87
+ it('redirects to register with custom next prefix when nextURL option is provided', () => {
88
+ const api = API(LMSConf, { routes: {}, nextURL: 'richie-ap' });
89
+ api.user.register();
90
+ expect(location.assign).toHaveBeenCalledWith(
91
+ `${EDX_ENDPOINT}/register?next=richie-ap${location.pathname}`,
92
+ );
93
+ });
94
+ });
95
+ });
96
+
48
97
  describe('enrollment', () => {
49
98
  beforeEach(() => {
50
99
  courseId = faker.string.uuid();
@@ -20,6 +20,8 @@ import { HttpError, HttpStatusCode } from 'utils/errors/HttpError';
20
20
  */
21
21
 
22
22
  const API = (APIConf: AuthenticationBackend | LMSBackend, options?: APIOptions): APILms => {
23
+ const nextURL = options?.nextURL ?? 'richie';
24
+
23
25
  const extractCourseIdFromUrl = (url: string): Maybe<Nullable<string>> => {
24
26
  const matches = url.match((APIConf as LMSBackend).course_regexp);
25
27
  return matches && matches[1] ? matches[1] : null;
@@ -61,8 +63,9 @@ const API = (APIConf: AuthenticationBackend | LMSBackend, options?: APIOptions):
61
63
  / ! \ Prefix next param with richie.
62
64
  In this way, OpenEdX Nginx conf knows that we want to go back to richie app after login/redirect
63
65
  */
64
- login: () => location.assign(`${ROUTES.user.login}?next=richie${location.pathname}`),
65
- register: () => location.assign(`${ROUTES.user.register}?next=richie${location.pathname}`),
66
+ login: () => location.assign(`${ROUTES.user.login}?next=${nextURL}${location.pathname}`),
67
+ register: () =>
68
+ location.assign(`${ROUTES.user.register}?next=${nextURL}${location.pathname}`),
66
69
  logout: async () => {
67
70
  await fetch(ROUTES.user.logout, {
68
71
  mode: 'no-cors',
package/js/api/utils.ts CHANGED
@@ -16,11 +16,12 @@ export async function getFileFromResponse(response: Response): Promise<File> {
16
16
  }
17
17
 
18
18
  export function getResponseBody(response: Response) {
19
- if (response.headers.get('Content-Type') === 'application/json') {
19
+ const contentType = (response.headers.get('Content-Type') || '').split(';')[0].trim();
20
+ if (contentType === 'application/json') {
20
21
  return response.json();
21
22
  }
22
- const fileType = ['application/pdf', 'application/zip'];
23
- if (fileType.includes(response.headers.get('Content-Type') || '')) {
23
+ const fileType = ['application/pdf', 'application/zip', 'text/csv'];
24
+ if (fileType.includes(contentType)) {
24
25
  return new Promise((resolve) => resolve(response));
25
26
  }
26
27
  return response.text();
@@ -0,0 +1,51 @@
1
+ import { useId } from 'react';
2
+ import { Button } from '@openfun/cunningham-react';
3
+ import { FormattedMessage, defineMessages } from 'react-intl';
4
+ import { Spinner } from 'components/Spinner';
5
+ import { useDownloadAgreement } from 'hooks/useDownloadAgreement';
6
+
7
+ const messages = defineMessages({
8
+ download: {
9
+ defaultMessage: 'Download agreement',
10
+ description: 'Label for the button to download a signed agreement PDF',
11
+ id: 'components.DownloadAgreementButton.download',
12
+ },
13
+ generating: {
14
+ defaultMessage: 'Downloading...',
15
+ description: 'Accessible label displayed while agreement PDF is being downloaded.',
16
+ id: 'components.DownloadAgreementButton.generating',
17
+ },
18
+ });
19
+
20
+ interface DownloadAgreementButtonProps {
21
+ organizationId: string;
22
+ agreementId: string;
23
+ }
24
+
25
+ const DownloadAgreementButton = ({ organizationId, agreementId }: DownloadAgreementButtonProps) => {
26
+ const { download, loading } = useDownloadAgreement();
27
+ const labelId = useId();
28
+
29
+ return (
30
+ <Button
31
+ size="small"
32
+ color="brand"
33
+ variant="primary"
34
+ className="dashboard-item__action-button"
35
+ disabled={loading}
36
+ onClick={() => download(organizationId, agreementId)}
37
+ >
38
+ {loading ? (
39
+ <Spinner theme="primary" aria-labelledby={labelId}>
40
+ <span id={labelId}>
41
+ <FormattedMessage {...messages.generating} />
42
+ </span>
43
+ </Spinner>
44
+ ) : (
45
+ <FormattedMessage {...messages.download} />
46
+ )}
47
+ </Button>
48
+ );
49
+ };
50
+
51
+ export default DownloadAgreementButton;
@@ -0,0 +1,46 @@
1
+ import { buildFilename, sanitizeForFilename } from '.';
2
+
3
+ describe('sanitizeForFilename', () => {
4
+ it('replaces spaces with underscores', () => {
5
+ expect(sanitizeForFilename('Formation React')).toBe('Formation_React');
6
+ });
7
+
8
+ it('removes diacritics', () => {
9
+ expect(sanitizeForFilename('Développement web')).toBe('Developpement_web');
10
+ });
11
+
12
+ it('removes special characters', () => {
13
+ expect(sanitizeForFilename('C++ / Python')).toBe('C_Python');
14
+ });
15
+
16
+ it('preserves hyphens', () => {
17
+ expect(sanitizeForFilename('Formation React - Advanced')).toBe('Formation_React_-_Advanced');
18
+ });
19
+
20
+ it('trims leading and trailing spaces', () => {
21
+ expect(sanitizeForFilename(' Formation ')).toBe('Formation');
22
+ });
23
+ });
24
+
25
+ describe('buildFilename', () => {
26
+ beforeEach(() => {
27
+ jest.useFakeTimers();
28
+ jest.setSystemTime(new Date('2026-04-15T09:30:00Z'));
29
+ });
30
+
31
+ afterEach(() => {
32
+ jest.useRealTimers();
33
+ });
34
+
35
+ it('builds the expected filename', () => {
36
+ expect(buildFilename('seats', 'Formation React')).toBe(
37
+ 'seats_Formation_React_2026-04-15_09-30.csv',
38
+ );
39
+ });
40
+
41
+ it('sanitizes the product title in the filename', () => {
42
+ expect(buildFilename('seats', 'Développement web avancé')).toBe(
43
+ 'seats_Developpement_web_avance_2026-04-15_09-30.csv',
44
+ );
45
+ });
46
+ });
@@ -0,0 +1,80 @@
1
+ import { useId } from 'react';
2
+ import { Button } from '@openfun/cunningham-react';
3
+ import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
4
+ import { Spinner } from 'components/Spinner';
5
+ import { useDownloadBatchOrderSeats } from 'hooks/useDownloadBatchOrderSeats';
6
+
7
+ const messages = defineMessages({
8
+ download: {
9
+ defaultMessage: 'Export CSV',
10
+ description: 'Label for the button to export batch order seats as CSV',
11
+ id: 'components.DownloadBatchOrderSeatsButton.download',
12
+ },
13
+ generating: {
14
+ defaultMessage: 'Generating export...',
15
+ description: 'Accessible label displayed while CSV export is being generated.',
16
+ id: 'components.DownloadBatchOrderSeatsButton.generating',
17
+ },
18
+ seats: {
19
+ defaultMessage: 'Seats',
20
+ description: 'Text displayed for seats value in batch order',
21
+ id: 'batchOrder.seats',
22
+ },
23
+ });
24
+
25
+ export const sanitizeForFilename = (str: string) =>
26
+ str
27
+ .normalize('NFD')
28
+ .replace(/[\u0300-\u036f]/g, '')
29
+ .replace(/[^a-zA-Z0-9\s-]/g, '')
30
+ .trim()
31
+ .replace(/\s+/g, '_');
32
+
33
+ export const buildFilename = (prefix: string, productTitle: string) => {
34
+ const now = new Date();
35
+ const date = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, '0')}-${String(now.getUTCDate()).padStart(2, '0')}`;
36
+ const time = `${String(now.getUTCHours()).padStart(2, '0')}-${String(now.getUTCMinutes()).padStart(2, '0')}`;
37
+ return `${prefix}_${sanitizeForFilename(productTitle)}_${date}_${time}.csv`;
38
+ };
39
+
40
+ interface DownloadBatchOrderSeatsButtonProps {
41
+ batchOrderId: string;
42
+ productTitle: string;
43
+ }
44
+
45
+ const DownloadBatchOrderSeatsButton = ({
46
+ batchOrderId,
47
+ productTitle,
48
+ }: DownloadBatchOrderSeatsButtonProps) => {
49
+ const { download, loading } = useDownloadBatchOrderSeats();
50
+ const labelId = useId();
51
+ const intl = useIntl();
52
+
53
+ const handleClick = () => {
54
+ const prefix = intl.formatMessage(messages.seats);
55
+ download(batchOrderId, buildFilename(prefix, productTitle));
56
+ };
57
+
58
+ return (
59
+ <Button
60
+ size="small"
61
+ color="brand"
62
+ variant="primary"
63
+ className="dashboard-item__action-button"
64
+ disabled={loading}
65
+ onClick={handleClick}
66
+ >
67
+ {loading ? (
68
+ <Spinner theme="primary" aria-labelledby={labelId}>
69
+ <span id={labelId}>
70
+ <FormattedMessage {...messages.generating} />
71
+ </span>
72
+ </Spinner>
73
+ ) : (
74
+ <FormattedMessage {...messages.download} />
75
+ )}
76
+ </Button>
77
+ );
78
+ };
79
+
80
+ export default DownloadBatchOrderSeatsButton;
@@ -96,7 +96,7 @@ export const SaleTunnelInformationGroup = () => {
96
96
  );
97
97
  };
98
98
 
99
- const EMAIL_REGEX = /^[\w.-]+@([\w-]+\.)+[\w-]{2,4}$/;
99
+ const EMAIL_REGEX = /^[\w.+\-]+@([\w-]+\.)+[\w-]{2,4}$/;
100
100
 
101
101
  export const validationSchema = Yup.object().shape({
102
102
  offering_id: Yup.string().required(),
@@ -20,7 +20,7 @@ const messages = defineMessages({
20
20
  },
21
21
  successDetailMessage: {
22
22
  defaultMessage:
23
- 'You will be able to start your training once the first installment will be paid.',
23
+ 'You’ll be able to start your training once the first installment has been paid, or once access opens if no payment is required.',
24
24
  description: 'Text to explain when the user will be able to start its training.',
25
25
  id: 'components.SaleTunnelSuccess.successDetailMessage',
26
26
  },
@@ -203,6 +203,7 @@ const SubscriptionButton = ({ buildOrderPayload }: Props) => {
203
203
  };
204
204
 
205
205
  const walkthroughMessages = useMemo(() => {
206
+ if (batchOrder) return;
206
207
  if (product.contract_definition && product.price > 0) {
207
208
  return messages.walkthroughToSignAndSavePayment;
208
209
  } else if (product.contract_definition && product.price === 0) {
@@ -210,7 +211,7 @@ const SubscriptionButton = ({ buildOrderPayload }: Props) => {
210
211
  } else if (!product.contract_definition && product.price > 0 && needsPayment) {
211
212
  return messages.walkthroughToSavePayment;
212
213
  }
213
- }, [product, creditCard, needsPayment]);
214
+ }, [product, creditCard, needsPayment, batchOrder]);
214
215
 
215
216
  useEffect(() => {
216
217
  if (order) nextStep();
@@ -210,11 +210,11 @@ describe('SaleTunnel', () => {
210
210
  await user.type($lastName, 'Doe');
211
211
  await user.type($firstName, 'John');
212
212
  await user.type($role, 'HR');
213
- await user.type($email, 'john.doe@fun-mooc.com');
213
+ await user.type($email, 'john.doe+admin@fun-mooc.com');
214
214
  await user.type($phone, '+338203920103');
215
215
 
216
216
  expect($lastName).toHaveValue('Doe');
217
- expect($email).toHaveValue('john.doe@fun-mooc.com');
217
+ expect($email).toHaveValue('john.doe+admin@fun-mooc.com');
218
218
 
219
219
  // Signatory step
220
220
  await user.click(screen.getByRole('button', { name: 'Next' }));
@@ -227,7 +227,7 @@ describe('SaleTunnel', () => {
227
227
  await user.type($signatoryLastName, 'Doe');
228
228
  await user.type($signatoryFirstName, 'John');
229
229
  await user.type($signatoryRole, 'CEO');
230
- await user.type($signatoryEmail, 'john.doe@fun-mooc.com');
230
+ await user.type($signatoryEmail, 'john.doe+ceo@fun-mooc.com');
231
231
  await user.type($signatoryPhone, '+338203920103');
232
232
 
233
233
  // Participants step
@@ -298,12 +298,12 @@ describe('SaleTunnel', () => {
298
298
  administrative_lastname: 'Doe',
299
299
  administrative_firstname: 'John',
300
300
  administrative_profession: 'HR',
301
- administrative_email: 'john.doe@fun-mooc.com',
301
+ administrative_email: 'john.doe+admin@fun-mooc.com',
302
302
  administrative_telephone: '+338203920103',
303
303
  signatory_lastname: 'Doe',
304
304
  signatory_firstname: 'John',
305
305
  signatory_profession: 'CEO',
306
- signatory_email: 'john.doe@fun-mooc.com',
306
+ signatory_email: 'john.doe+ceo@fun-mooc.com',
307
307
  signatory_telephone: '+338203920103',
308
308
  nb_seats: '13',
309
309
  payment_method: PaymentMethod.PURCHASE_ORDER,
@@ -404,7 +404,7 @@ describe('SaleTunnel', () => {
404
404
  await screen.findByTestId('generic-sale-tunnel-success-step');
405
405
  screen.getByText('Subscription confirmed!');
406
406
  screen.getByText(
407
- /your order has been successfully registered\.you will be able to start your training once the first installment will be paid\./i,
407
+ /you’ll be able to start your training once the first installment has been paid, or once access opens if no payment is required\./i,
408
408
  );
409
409
  screen.getByRole('link', { name: 'Close' });
410
410
 
@@ -1,7 +1,13 @@
1
1
  import { defineMessages } from 'react-intl';
2
2
  import { useJoanieApi } from 'contexts/JoanieApiContext';
3
3
  import { ResourcesQuery, useResource, useResources, UseResourcesProps } from 'hooks/useResources';
4
- import { API, BatchOrderQueryFilters, BatchOrderRead } from 'types/Joanie';
4
+ import {
5
+ API,
6
+ BatchOrderQueryFilters,
7
+ BatchOrderRead,
8
+ BatchOrderSeat,
9
+ BatchOrderSeatsQueryFilters,
10
+ } from 'types/Joanie';
5
11
 
6
12
  const messages = defineMessages({
7
13
  errorCreate: {
@@ -34,3 +40,17 @@ export const useBatchOrdersActions = () => {
34
40
  submitForPayment,
35
41
  };
36
42
  };
43
+
44
+ const seatsProps: UseResourcesProps<
45
+ BatchOrderSeat,
46
+ BatchOrderSeatsQueryFilters,
47
+ API['user']['batchOrders']['seats']
48
+ > = {
49
+ queryKey: ['batchOrders', 'seats'],
50
+ apiInterface: () => useJoanieApi().user.batchOrders.seats,
51
+ session: true,
52
+ };
53
+
54
+ export const useBatchOrderSeats = useResources<BatchOrderSeat, BatchOrderSeatsQueryFilters>(
55
+ seatsProps,
56
+ );