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