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.
- package/i18n/locales/ar-SA.json +29 -1
- package/i18n/locales/es-ES.json +29 -1
- package/i18n/locales/fa-IR.json +29 -1
- package/i18n/locales/fr-CA.json +29 -1
- package/i18n/locales/fr-FR.json +29 -1
- package/i18n/locales/ko-KR.json +29 -1
- package/i18n/locales/pt-PT.json +29 -1
- package/i18n/locales/ru-RU.json +29 -1
- package/i18n/locales/vi-VN.json +29 -1
- package/js/api/joanie.ts +144 -0
- package/js/components/PaymentInterfaces/types.ts +7 -0
- package/js/components/PaymentScheduleGrid/index.tsx +4 -2
- package/js/components/SaleTunnel/AddressSelector/index.spec.tsx +9 -2
- package/js/components/SaleTunnel/GenericSaleTunnel.tsx +33 -0
- package/js/components/SaleTunnel/SaleTunnelInformation/SaleTunnelInformationGroup.tsx +253 -0
- package/js/components/SaleTunnel/SaleTunnelInformation/SaleTunnelInformationSingular.tsx +314 -0
- package/js/components/SaleTunnel/SaleTunnelInformation/StepContent.tsx +528 -0
- package/js/components/SaleTunnel/SaleTunnelInformation/index.tsx +47 -261
- package/js/components/SaleTunnel/SaleTunnelSuccess/index.tsx +25 -11
- package/js/components/SaleTunnel/SubscriptionButton/index.tsx +54 -6
- package/js/components/SaleTunnel/_styles.scss +55 -0
- package/js/components/SaleTunnel/index.full-process-b2b.spec.tsx +356 -0
- package/js/components/SaleTunnel/{index.full-process.spec.tsx → index.full-process-b2c.spec.tsx} +4 -1
- package/js/components/SaleTunnel/index.spec.tsx +130 -1
- package/js/hooks/useBatchOrder/index.tsx +36 -0
- package/js/hooks/useContractArchive/index.ts +2 -0
- package/js/hooks/useOfferingOrganizations/index.tsx +38 -0
- package/js/hooks/useOrganizationAgreements.tsx/index.tsx +66 -0
- package/js/hooks/useOrganizationQuotes/index.tsx +56 -0
- package/js/hooks/usePaymentPlan.tsx +2 -1
- package/js/hooks/useTeacherPendingAgreementsCount/index.ts +34 -0
- package/js/pages/DashboardBatchOrderLayout/_styles.scss +5 -0
- package/js/pages/DashboardBatchOrderLayout/index.spec.tsx +78 -0
- package/js/pages/DashboardBatchOrderLayout/index.tsx +45 -0
- package/js/pages/DashboardBatchOrders/index.spec.tsx +237 -0
- package/js/pages/DashboardBatchOrders/index.tsx +84 -0
- package/js/pages/TeacherDashboardContractsLayout/TeacherDashboardCourseContractsLayout/index.tsx +0 -1
- package/js/pages/TeacherDashboardContractsLayout/hooks/useDownloadContractArchive/index.tsx +3 -1
- package/js/pages/TeacherDashboardOrganizationAgreements/AgreementActionsBar.tsx +49 -0
- package/js/pages/TeacherDashboardOrganizationAgreements/BulkAgreementContractButton.tsx +79 -0
- package/js/pages/TeacherDashboardOrganizationAgreements/OrganizationAgreementFrame.tsx +71 -0
- package/js/pages/TeacherDashboardOrganizationAgreements/SignOrganizationAgreementButton.tsx +60 -0
- package/js/pages/TeacherDashboardOrganizationAgreements/hooks/useAgreementsAbilities.tsx +8 -0
- package/js/pages/TeacherDashboardOrganizationAgreements/hooks/useHasAgreementToDownload.tsx +27 -0
- package/js/pages/TeacherDashboardOrganizationAgreements/hooks/useTeacherAgreementsToSign.tsx +32 -0
- package/js/pages/TeacherDashboardOrganizationAgreements/index.spec.tsx +433 -0
- package/js/pages/TeacherDashboardOrganizationAgreements/index.tsx +130 -0
- package/js/pages/TeacherDashboardOrganizationAgreementsLayout/index.tsx +25 -0
- package/js/pages/TeacherDashboardOrganizationCourseLoader/index.spec.tsx +9 -0
- package/js/pages/TeacherDashboardOrganizationQuotes/_styles.scss +40 -0
- package/js/pages/TeacherDashboardOrganizationQuotes/index.full-process.spec.tsx +194 -0
- package/js/pages/TeacherDashboardOrganizationQuotes/index.spec.tsx +144 -0
- package/js/pages/TeacherDashboardOrganizationQuotes/index.tsx +521 -0
- package/js/pages/TeacherDashboardOrganizationQuotesLayout/index.tsx +26 -0
- package/js/translations/ar-SA.json +1 -1
- package/js/translations/es-ES.json +1 -1
- package/js/translations/fa-IR.json +1 -1
- package/js/translations/fr-CA.json +1 -1
- package/js/translations/fr-FR.json +1 -1
- package/js/translations/ko-KR.json +1 -1
- package/js/translations/pt-PT.json +1 -1
- package/js/translations/ru-RU.json +1 -1
- package/js/translations/vi-VN.json +1 -1
- package/js/types/Joanie.ts +216 -1
- package/js/utils/AbilitiesHelper/agreementAbilities.ts +14 -0
- package/js/utils/AbilitiesHelper/index.ts +7 -0
- package/js/utils/AbilitiesHelper/types.ts +12 -3
- package/js/utils/ObjectHelper/index.ts +20 -0
- package/js/utils/OrderHelper/index.ts +10 -0
- package/js/utils/errors/HttpError.ts +1 -0
- package/js/utils/test/factories/joanie.ts +156 -1
- package/js/widgets/Dashboard/components/DashboardBatchOrderLoader/_styles.scss +14 -0
- package/js/widgets/Dashboard/components/DashboardBatchOrderLoader/index.tsx +32 -0
- package/js/widgets/Dashboard/components/DashboardCard/index.spec.tsx +18 -0
- package/js/widgets/Dashboard/components/DashboardCard/index.stories.tsx +25 -2
- package/js/widgets/Dashboard/components/DashboardCard/index.tsx +4 -2
- package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/BatchOrderPaymentModal/BatchOrderPaymentManager.tsx +88 -0
- package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/BatchOrderPaymentModal/index.tsx +216 -0
- package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/DashboardBatchOrderSubItems.tsx +316 -0
- package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/index.spec.tsx +27 -0
- package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/index.tsx +175 -0
- package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.tsx +5 -2
- package/js/widgets/Dashboard/components/DashboardItem/Order/OrganizationBlock/index.tsx +4 -1
- package/js/widgets/Dashboard/components/DashboardItem/Order/_styles.scss +5 -0
- package/js/widgets/Dashboard/components/DashboardItem/_styles.scss +43 -0
- package/js/widgets/Dashboard/components/DashboardSidebar/components/AgreementNavLink/index.spec.tsx +214 -0
- package/js/widgets/Dashboard/components/DashboardSidebar/components/AgreementNavLink/index.tsx +47 -0
- package/js/widgets/Dashboard/components/LearnerDashboardSidebar/index.tsx +1 -0
- package/js/widgets/Dashboard/components/TeacherDashboardOrganizationSidebar/index.spec.tsx +21 -3
- package/js/widgets/Dashboard/components/TeacherDashboardOrganizationSidebar/index.tsx +9 -0
- package/js/widgets/Dashboard/utils/learnerRoutes.tsx +30 -0
- package/js/widgets/Dashboard/utils/learnerRoutesPaths.tsx +12 -0
- package/js/widgets/Dashboard/utils/teacherDashboardPaths.tsx +12 -0
- package/js/widgets/Dashboard/utils/teacherRoutes.tsx +17 -0
- package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/index.spec.tsx +8 -2
- package/package.json +4 -1
- package/scss/colors/_theme.scss +1 -1
- 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
|
+
});
|
package/js/components/SaleTunnel/{index.full-process.spec.tsx → index.full-process-b2c.spec.tsx}
RENAMED
|
@@ -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
|
-
},
|
|
409
|
+
}, 30000);
|
|
407
410
|
});
|
|
@@ -690,9 +690,13 @@ describe.each([
|
|
|
690
690
|
});
|
|
691
691
|
});
|
|
692
692
|
|
|
693
|
-
it('should show
|
|
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
|
+
};
|