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.
- package/.storybook/main.js +11 -12
- package/js/api/joanie.ts +20 -0
- package/js/api/lms/index.spec.ts +33 -0
- package/js/api/lms/index.ts +1 -1
- package/js/api/lms/openedx-hawthorn.spec.ts +49 -0
- package/js/api/lms/openedx-hawthorn.ts +5 -2
- package/js/api/utils.ts +4 -3
- package/js/components/DownloadAgreementButton/index.tsx +51 -0
- package/js/components/DownloadBatchOrderSeatsButton/index.spec.tsx +46 -0
- package/js/components/DownloadBatchOrderSeatsButton/index.tsx +80 -0
- package/js/components/SaleTunnel/SaleTunnelInformation/SaleTunnelInformationGroup.tsx +1 -1
- package/js/components/SaleTunnel/SaleTunnelSuccess/index.tsx +1 -1
- package/js/components/SaleTunnel/SubscriptionButton/index.tsx +2 -1
- package/js/components/SaleTunnel/index.full-process-b2b.spec.tsx +5 -5
- package/js/components/SaleTunnel/index.full-process-b2c.spec.tsx +1 -1
- package/js/hooks/useBatchOrder/index.tsx +21 -1
- package/js/hooks/useDownloadAgreement/index.spec.tsx +136 -0
- package/js/hooks/useDownloadAgreement/index.tsx +25 -0
- package/js/hooks/useDownloadBatchOrderSeats/index.spec.tsx +132 -0
- package/js/hooks/useDownloadBatchOrderSeats/index.tsx +24 -0
- package/js/pages/DashboardBatchOrderLayout/index.spec.tsx +19 -2
- package/js/pages/TeacherDashboardOrganizationQuotes/BatchOrderSeatInfoQuote.tsx +112 -0
- package/js/pages/TeacherDashboardOrganizationQuotes/_styles.scss +17 -0
- package/js/pages/TeacherDashboardOrganizationQuotes/index.full-process.spec.tsx +5 -2
- package/js/pages/TeacherDashboardOrganizationQuotes/index.spec.tsx +7 -3
- package/js/pages/TeacherDashboardOrganizationQuotes/index.tsx +38 -26
- package/js/types/Joanie.ts +21 -1
- package/js/types/api.ts +1 -0
- package/js/types/commonDataProps.ts +2 -0
- package/js/utils/download.ts +3 -1
- package/js/utils/test/factories/joanie.ts +15 -1
- package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/BatchOrderAgreementInfo.tsx +72 -0
- package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/BatchOrderSeatInfo.spec.tsx +114 -0
- package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/BatchOrderSeatInfo.tsx +133 -0
- package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/DashboardBatchOrderSubItems.tsx +17 -1
- package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/batchOrderSeatInfoMessages.ts +24 -0
- package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/index.tsx +16 -3
- package/js/widgets/Dashboard/components/DashboardItem/_styles.scss +6 -2
- package/js/widgets/Dashboard/utils/teacherDashboardPaths.tsx +2 -2
- package/js/widgets/Slider/index.tsx +7 -6
- package/package.json +2 -7
- package/scss/components/templates/richie/slider/_slider.scss +1 -1
- package/scss/objects/_course_glimpses.scss +1 -0
- package/scss/objects/_dashboard.scss +77 -0
package/.storybook/main.js
CHANGED
|
@@ -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
|
|
4
|
-
|
|
5
|
-
|
|
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: {
|
package/js/api/lms/index.spec.ts
CHANGED
|
@@ -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
|
});
|
package/js/api/lms/index.ts
CHANGED
|
@@ -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
|
|
65
|
-
register: () =>
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
/
|
|
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 {
|
|
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
|
+
);
|