richie-education 3.3.1 → 3.3.2-dev11
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/js/api/joanie.ts +20 -0
- package/js/api/lms/openedx-hawthorn.spec.ts +34 -2
- package/js/api/lms/openedx-hawthorn.ts +4 -1
- 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 +136 -50
- 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 +73 -3
- package/js/hooks/useDeepLink.tsx +21 -0
- package/js/pages/TeacherDashboardOrganizationQuotes/index.full-process.spec.tsx +2 -2
- package/js/pages/TeacherDashboardOrganizationQuotes/index.spec.tsx +1 -1
- package/js/pages/TeacherDashboardOrganizationQuotes/index.tsx +1 -1
- package/js/types/Joanie.ts +9 -2
- package/js/utils/test/factories/joanie.ts +9 -3
- package/package.json +2 -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) => {
|
|
@@ -170,7 +170,7 @@ describe('OpenEdX Hawthorn API', () => {
|
|
|
170
170
|
);
|
|
171
171
|
});
|
|
172
172
|
|
|
173
|
-
it('throws HttpError.localizedMessage on enrollment failure', async () => {
|
|
173
|
+
it('throws HttpError.localizedMessage on enrollment failure for bad requests', async () => {
|
|
174
174
|
fetchMock.post(`${EDX_ENDPOINT}/api/enrollment/v1/enrollment`, {
|
|
175
175
|
status: HttpStatusCode.BAD_REQUEST,
|
|
176
176
|
body: { localizedMessage: 'You are not authorized to enroll in this course' },
|
|
@@ -189,7 +189,7 @@ describe('OpenEdX Hawthorn API', () => {
|
|
|
189
189
|
);
|
|
190
190
|
});
|
|
191
191
|
|
|
192
|
-
it('throws HttpError on enrollment failure when localizedMessage property is not present in the payload', async () => {
|
|
192
|
+
it('throws HttpError on enrollment failure when localizedMessage property is not present in the payload for bad requests', async () => {
|
|
193
193
|
fetchMock.post(`${EDX_ENDPOINT}/api/enrollment/v1/enrollment`, {
|
|
194
194
|
status: HttpStatusCode.BAD_REQUEST,
|
|
195
195
|
body: { message: 'Bad Request' },
|
|
@@ -202,6 +202,38 @@ describe('OpenEdX Hawthorn API', () => {
|
|
|
202
202
|
).rejects.toThrow(new HttpError(HttpStatusCode.BAD_REQUEST, 'Bad Request'));
|
|
203
203
|
});
|
|
204
204
|
|
|
205
|
+
it('throws HttpError.localizedMessage on enrollment failure for forbidden requests', async () => {
|
|
206
|
+
fetchMock.post(`${EDX_ENDPOINT}/api/enrollment/v1/enrollment`, {
|
|
207
|
+
status: HttpStatusCode.FORBIDDEN,
|
|
208
|
+
body: { localizedMessage: 'You are not authorized to enroll in this course' },
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
await expect(
|
|
212
|
+
HawthornApi.enrollment.set(`https://demo.endpoint/courses?course_id=${courseId}`, {
|
|
213
|
+
username,
|
|
214
|
+
}),
|
|
215
|
+
).rejects.toThrow(
|
|
216
|
+
new HttpError(
|
|
217
|
+
HttpStatusCode.FORBIDDEN,
|
|
218
|
+
'Forbidden',
|
|
219
|
+
'You are not authorized to enroll in this course',
|
|
220
|
+
),
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('throws HttpError on enrollment failure when localizedMessage property is not present in the payload for forbidden requests', async () => {
|
|
225
|
+
fetchMock.post(`${EDX_ENDPOINT}/api/enrollment/v1/enrollment`, {
|
|
226
|
+
status: HttpStatusCode.FORBIDDEN,
|
|
227
|
+
body: { message: 'Forbidden' },
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
await expect(
|
|
231
|
+
HawthornApi.enrollment.set(`https://demo.endpoint/courses?course_id=${courseId}`, {
|
|
232
|
+
username,
|
|
233
|
+
}),
|
|
234
|
+
).rejects.toThrow(new HttpError(HttpStatusCode.FORBIDDEN, 'Forbidden'));
|
|
235
|
+
});
|
|
236
|
+
|
|
205
237
|
it('throws HttpError when response has no json payload', async () => {
|
|
206
238
|
fetchMock.post(`${EDX_ENDPOINT}/api/enrollment/v1/enrollment`, HttpStatusCode.BAD_REQUEST);
|
|
207
239
|
|
|
@@ -115,7 +115,10 @@ const API = (APIConf: AuthenticationBackend | LMSBackend, options?: APIOptions):
|
|
|
115
115
|
})
|
|
116
116
|
.then(async (response) => {
|
|
117
117
|
if (response.ok) return response.json();
|
|
118
|
-
if (
|
|
118
|
+
if (
|
|
119
|
+
response.status === HttpStatusCode.BAD_REQUEST ||
|
|
120
|
+
response.status === HttpStatusCode.FORBIDDEN
|
|
121
|
+
) {
|
|
119
122
|
if (response.headers.get('Content-Type') === 'application/json') {
|
|
120
123
|
const { localizedMessage } = await response.json();
|
|
121
124
|
throw new HttpError(response.status, response.statusText, localizedMessage);
|
|
@@ -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,15 +1,16 @@
|
|
|
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';
|
|
@@ -123,6 +124,32 @@ const messages = defineMessages({
|
|
|
123
124
|
description: 'Message displayed when the order is part of a batch order',
|
|
124
125
|
defaultMessage: 'No billing information required. This order is covered by your organization.',
|
|
125
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
|
+
},
|
|
126
153
|
});
|
|
127
154
|
|
|
128
155
|
export const SaleTunnelInformationSingular = () => {
|
|
@@ -134,6 +161,9 @@ export const SaleTunnelInformationSingular = () => {
|
|
|
134
161
|
setSchedule,
|
|
135
162
|
needsPayment,
|
|
136
163
|
setNeedsPayment,
|
|
164
|
+
setHasWaivedWithdrawalRight,
|
|
165
|
+
paymentMode,
|
|
166
|
+
setPaymentMode,
|
|
137
167
|
} = useSaleTunnelContext();
|
|
138
168
|
const [voucherError, setVoucherError] = useState<HttpError | null>(null);
|
|
139
169
|
const query = usePaymentPlan({
|
|
@@ -141,11 +171,17 @@ export const SaleTunnelInformationSingular = () => {
|
|
|
141
171
|
product_id: props.product.id,
|
|
142
172
|
...(voucherCode ? { voucher_code: voucherCode } : {}),
|
|
143
173
|
});
|
|
174
|
+
const deepLinkQuery = useDeepLink({
|
|
175
|
+
course_code: props.course?.code ?? props.enrollment!.course_run.course.code,
|
|
176
|
+
product_id: props.product.id,
|
|
177
|
+
});
|
|
144
178
|
const schedule = query.data?.payment_schedule ?? props.paymentPlan?.payment_schedule;
|
|
145
179
|
const price = query.data?.price ?? props.paymentPlan?.price;
|
|
146
180
|
const discountedPrice = query.data?.discounted_price ?? props.paymentPlan?.discounted_price;
|
|
147
181
|
const discount = query.data?.discount ?? props.paymentPlan?.discount;
|
|
148
|
-
const
|
|
182
|
+
const skipContractInputs =
|
|
183
|
+
query.data?.skip_contract_inputs ?? props.paymentPlan?.skip_contract_inputs;
|
|
184
|
+
const deepLink = deepLinkQuery.data?.deep_link;
|
|
149
185
|
|
|
150
186
|
const isCredentialWithPrice =
|
|
151
187
|
product.type === ProductType.CREDENTIAL &&
|
|
@@ -165,68 +201,99 @@ export const SaleTunnelInformationSingular = () => {
|
|
|
165
201
|
}, [query.error, voucherCode, setVoucherCode]);
|
|
166
202
|
|
|
167
203
|
useEffect(() => {
|
|
168
|
-
setNeedsPayment(!
|
|
169
|
-
|
|
204
|
+
setNeedsPayment(!skipContractInputs);
|
|
205
|
+
if (skipContractInputs) {
|
|
206
|
+
setHasWaivedWithdrawalRight(false);
|
|
207
|
+
}
|
|
208
|
+
}, [skipContractInputs, setNeedsPayment, setHasWaivedWithdrawalRight]);
|
|
170
209
|
|
|
210
|
+
const intl = useIntl();
|
|
171
211
|
const isKeycloakBackend = [APIBackend.KEYCLOAK, APIBackend.FONZIE_KEYCLOAK].includes(
|
|
172
212
|
context?.authentication.backend as APIBackend,
|
|
173
213
|
);
|
|
174
214
|
|
|
175
215
|
return (
|
|
176
216
|
<>
|
|
177
|
-
{
|
|
178
|
-
<div>
|
|
217
|
+
{deepLink && (
|
|
218
|
+
<div className="mb-s">
|
|
179
219
|
<h3 className="block-title mb-t">
|
|
180
|
-
<FormattedMessage {...messages.
|
|
220
|
+
<FormattedMessage {...messages.paymentModeTitle} />
|
|
181
221
|
</h3>
|
|
182
|
-
<
|
|
183
|
-
<
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
</div>
|
|
197
|
-
)}
|
|
198
|
-
{!needsPayment && (
|
|
199
|
-
<div>
|
|
200
|
-
<h3 className="block-title">
|
|
201
|
-
<FormattedMessage {...messages.title} />
|
|
202
|
-
</h3>
|
|
203
|
-
<Alert type={VariantType.NEUTRAL}>
|
|
204
|
-
<FormattedMessage {...messages.noBillingInformation} />
|
|
205
|
-
</Alert>
|
|
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>
|
|
206
236
|
</div>
|
|
207
237
|
)}
|
|
208
|
-
|
|
209
|
-
{
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
238
|
+
{paymentMode === PaymentMode.CPF ? (
|
|
239
|
+
<CpfPayment deepLink={deepLink!} />
|
|
240
|
+
) : (
|
|
241
|
+
<>
|
|
242
|
+
{needsPayment && (
|
|
213
243
|
<div>
|
|
214
|
-
<
|
|
215
|
-
<FormattedMessage {...messages.
|
|
216
|
-
</
|
|
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>
|
|
217
268
|
<Alert type={VariantType.NEUTRAL}>
|
|
218
|
-
<FormattedMessage {...messages.
|
|
269
|
+
<FormattedMessage {...messages.noBillingInformation} />
|
|
219
270
|
</Alert>
|
|
220
271
|
</div>
|
|
221
|
-
)
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
+
)}
|
|
230
297
|
</>
|
|
231
298
|
);
|
|
232
299
|
};
|
|
@@ -425,3 +492,22 @@ const PaymentScheduleBlock = ({ schedule }: { schedule: PaymentSchedule }) => {
|
|
|
425
492
|
</div>
|
|
426
493
|
);
|
|
427
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
|
});
|
|
@@ -66,6 +66,10 @@ const setupBatchOrderMocks = (params: {
|
|
|
66
66
|
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
|
|
67
67
|
paymentPlan,
|
|
68
68
|
);
|
|
69
|
+
fetchMock.get(
|
|
70
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
|
|
71
|
+
{},
|
|
72
|
+
);
|
|
69
73
|
fetchMock.get(`https://joanie.endpoint/api/v1.0/enrollments/`, []);
|
|
70
74
|
fetchMock.get(
|
|
71
75
|
`https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify({
|
|
@@ -365,7 +369,7 @@ describe('SaleTunnel', () => {
|
|
|
365
369
|
discounted_price: 0,
|
|
366
370
|
discount: '-100%',
|
|
367
371
|
payment_schedule: undefined,
|
|
368
|
-
|
|
372
|
+
skip_contract_inputs: true,
|
|
369
373
|
}).one();
|
|
370
374
|
fetchMock.get(
|
|
371
375
|
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/?voucher_code=DISCOUNT100`,
|
|
@@ -116,6 +116,10 @@ describe('SaleTunnel', () => {
|
|
|
116
116
|
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
|
|
117
117
|
paymentPlan,
|
|
118
118
|
);
|
|
119
|
+
fetchMock.get(
|
|
120
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
|
|
121
|
+
{},
|
|
122
|
+
);
|
|
119
123
|
fetchMock.get(`https://joanie.endpoint/api/v1.0/enrollments/`, []);
|
|
120
124
|
const orderQueryParameters = {
|
|
121
125
|
product_id: product.id,
|
|
@@ -281,6 +285,11 @@ describe('SaleTunnel', () => {
|
|
|
281
285
|
paymentPlanVoucher,
|
|
282
286
|
{ overwriteRoutes: true },
|
|
283
287
|
);
|
|
288
|
+
fetchMock.get(
|
|
289
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
|
|
290
|
+
{},
|
|
291
|
+
{ overwriteRoutes: true },
|
|
292
|
+
);
|
|
284
293
|
await user.type(screen.getByLabelText('Voucher code'), 'DISCOUNT30');
|
|
285
294
|
await user.click(screen.getByRole('button', { name: 'Validate' }));
|
|
286
295
|
screen.getByRole('heading', { name: 'Payment schedule' });
|
|
@@ -175,6 +175,10 @@ describe.each([
|
|
|
175
175
|
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
|
|
176
176
|
paymentPlan,
|
|
177
177
|
)
|
|
178
|
+
.get(
|
|
179
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
|
|
180
|
+
{},
|
|
181
|
+
)
|
|
178
182
|
.post('https://joanie.endpoint/api/v1.0/orders/', order)
|
|
179
183
|
.get(`https://joanie.endpoint/api/v1.0/orders/${order.id}/`, order)
|
|
180
184
|
.get('https://joanie.endpoint/api/v1.0/addresses/', [billingAddress], {
|
|
@@ -188,6 +192,7 @@ describe.each([
|
|
|
188
192
|
nbApiCalls += 1; // get user account call.
|
|
189
193
|
nbApiCalls += 1; // get user preferences call.
|
|
190
194
|
nbApiCalls += 1; // product payment-schedule call
|
|
195
|
+
nbApiCalls += 1; // product deep-link call
|
|
191
196
|
await waitFor(() => expect(fetchMock.calls()).toHaveLength(nbApiCalls));
|
|
192
197
|
|
|
193
198
|
const user = userEvent.setup({ delay: null });
|
|
@@ -263,6 +268,10 @@ describe.each([
|
|
|
263
268
|
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
|
|
264
269
|
paymentPlan,
|
|
265
270
|
)
|
|
271
|
+
.get(
|
|
272
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
|
|
273
|
+
{},
|
|
274
|
+
)
|
|
266
275
|
.post('https://joanie.endpoint/api/v1.0/orders/', deferred.promise)
|
|
267
276
|
.get('https://joanie.endpoint/api/v1.0/addresses/', [billingAddress], {
|
|
268
277
|
overwriteRoutes: true,
|
|
@@ -275,6 +284,7 @@ describe.each([
|
|
|
275
284
|
nbApiCalls += 1; // get user account call.
|
|
276
285
|
nbApiCalls += 1; // get user preferences call.
|
|
277
286
|
nbApiCalls += 1; // get paymentPlan call.
|
|
287
|
+
nbApiCalls += 1; // get deep-link call.
|
|
278
288
|
await waitFor(() => expect(fetchMock.calls()).toHaveLength(nbApiCalls));
|
|
279
289
|
|
|
280
290
|
const user = userEvent.setup({ delay: null });
|
|
@@ -333,6 +343,10 @@ describe.each([
|
|
|
333
343
|
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
|
|
334
344
|
paymentPlan,
|
|
335
345
|
)
|
|
346
|
+
.get(
|
|
347
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
|
|
348
|
+
{},
|
|
349
|
+
)
|
|
336
350
|
.get('https://joanie.endpoint/api/v1.0/offerings/get-organizations/', []);
|
|
337
351
|
|
|
338
352
|
render(<Wrapper paymentPlan={paymentPlan} product={product} isWithdrawable={true} />, {
|
|
@@ -379,6 +393,10 @@ describe.each([
|
|
|
379
393
|
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
|
|
380
394
|
paymentPlan,
|
|
381
395
|
)
|
|
396
|
+
.get(
|
|
397
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
|
|
398
|
+
{},
|
|
399
|
+
)
|
|
382
400
|
.get('https://joanie.endpoint/api/v1.0/credit-cards/', [creditCard], {
|
|
383
401
|
overwriteRoutes: true,
|
|
384
402
|
})
|
|
@@ -390,7 +408,7 @@ describe.each([
|
|
|
390
408
|
queryOptions: { client: createTestQueryClient({ user: richieUser }) },
|
|
391
409
|
});
|
|
392
410
|
|
|
393
|
-
nbApiCalls +=
|
|
411
|
+
nbApiCalls += 4;
|
|
394
412
|
await waitFor(() => expect(fetchMock.calls()).toHaveLength(nbApiCalls));
|
|
395
413
|
|
|
396
414
|
await screen.findByTestId('sale-tunnel-save-payment-method-step');
|
|
@@ -415,6 +433,10 @@ describe.each([
|
|
|
415
433
|
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
|
|
416
434
|
paymentPlan,
|
|
417
435
|
)
|
|
436
|
+
.get(
|
|
437
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
|
|
438
|
+
{},
|
|
439
|
+
)
|
|
418
440
|
.get('https://joanie.endpoint/api/v1.0/credit-cards/', [creditCard], {
|
|
419
441
|
overwriteRoutes: true,
|
|
420
442
|
})
|
|
@@ -426,7 +448,7 @@ describe.each([
|
|
|
426
448
|
queryOptions: { client: createTestQueryClient({ user: richieUser }) },
|
|
427
449
|
});
|
|
428
450
|
|
|
429
|
-
nbApiCalls +=
|
|
451
|
+
nbApiCalls += 4;
|
|
430
452
|
await waitFor(() => expect(fetchMock.calls()).toHaveLength(nbApiCalls));
|
|
431
453
|
|
|
432
454
|
await screen.findByTestId('sale-tunnel-sign-step');
|
|
@@ -446,6 +468,10 @@ describe.each([
|
|
|
446
468
|
.get(
|
|
447
469
|
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
|
|
448
470
|
paymentPlan,
|
|
471
|
+
)
|
|
472
|
+
.get(
|
|
473
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
|
|
474
|
+
{},
|
|
449
475
|
);
|
|
450
476
|
|
|
451
477
|
render(<Wrapper paymentPlan={paymentPlan} product={product} isWithdrawable={true} />, {
|
|
@@ -537,6 +563,10 @@ describe.each([
|
|
|
537
563
|
.get(
|
|
538
564
|
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
|
|
539
565
|
paymentPlan,
|
|
566
|
+
)
|
|
567
|
+
.get(
|
|
568
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
|
|
569
|
+
{},
|
|
540
570
|
);
|
|
541
571
|
render(
|
|
542
572
|
<Wrapper
|
|
@@ -585,6 +615,10 @@ describe.each([
|
|
|
585
615
|
.get(
|
|
586
616
|
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
|
|
587
617
|
paymentPlan,
|
|
618
|
+
)
|
|
619
|
+
.get(
|
|
620
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
|
|
621
|
+
{},
|
|
588
622
|
);
|
|
589
623
|
|
|
590
624
|
render(
|
|
@@ -659,6 +693,10 @@ describe.each([
|
|
|
659
693
|
.get(
|
|
660
694
|
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
|
|
661
695
|
paymentPlan,
|
|
696
|
+
)
|
|
697
|
+
.get(
|
|
698
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
|
|
699
|
+
{},
|
|
662
700
|
);
|
|
663
701
|
|
|
664
702
|
render(<Wrapper paymentPlan={paymentPlan} product={product} isWithdrawable={true} />, {
|
|
@@ -679,6 +717,10 @@ describe.each([
|
|
|
679
717
|
.get(
|
|
680
718
|
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
|
|
681
719
|
paymentPlan,
|
|
720
|
+
)
|
|
721
|
+
.get(
|
|
722
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
|
|
723
|
+
{},
|
|
682
724
|
);
|
|
683
725
|
|
|
684
726
|
render(<Wrapper paymentPlan={paymentPlan} product={product} isWithdrawable={false} />, {
|
|
@@ -699,6 +741,10 @@ describe.each([
|
|
|
699
741
|
.get(
|
|
700
742
|
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
|
|
701
743
|
paymentPlan,
|
|
744
|
+
)
|
|
745
|
+
.get(
|
|
746
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
|
|
747
|
+
{},
|
|
702
748
|
);
|
|
703
749
|
|
|
704
750
|
render(<Wrapper paymentPlan={paymentPlan} product={product} isWithdrawable={true} />, {
|
|
@@ -719,6 +765,10 @@ describe.each([
|
|
|
719
765
|
.get(
|
|
720
766
|
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
|
|
721
767
|
paymentPlan,
|
|
768
|
+
)
|
|
769
|
+
.get(
|
|
770
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
|
|
771
|
+
{},
|
|
722
772
|
);
|
|
723
773
|
|
|
724
774
|
render(<Wrapper paymentPlan={paymentPlan} product={product} isWithdrawable={false} />, {
|
|
@@ -762,6 +812,10 @@ describe.each([
|
|
|
762
812
|
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
|
|
763
813
|
paymentPlan,
|
|
764
814
|
)
|
|
815
|
+
.get(
|
|
816
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
|
|
817
|
+
{},
|
|
818
|
+
)
|
|
765
819
|
.get(
|
|
766
820
|
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/?voucher_code=DISCOUNT30`,
|
|
767
821
|
{
|
|
@@ -807,7 +861,7 @@ describe.each([
|
|
|
807
861
|
const paymentPlanVoucher = PaymentPlanFactory({
|
|
808
862
|
discounted_price: 0.0,
|
|
809
863
|
discount: '-100%',
|
|
810
|
-
|
|
864
|
+
skip_contract_inputs: true,
|
|
811
865
|
}).one();
|
|
812
866
|
const product = ProductFactory().one();
|
|
813
867
|
fetchMock
|
|
@@ -819,6 +873,10 @@ describe.each([
|
|
|
819
873
|
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
|
|
820
874
|
paymentPlan,
|
|
821
875
|
)
|
|
876
|
+
.get(
|
|
877
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
|
|
878
|
+
{},
|
|
879
|
+
)
|
|
822
880
|
.get(
|
|
823
881
|
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/?voucher_code=DISCOUNT100`,
|
|
824
882
|
paymentPlanVoucher,
|
|
@@ -871,6 +929,10 @@ describe.each([
|
|
|
871
929
|
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
|
|
872
930
|
paymentPlan,
|
|
873
931
|
)
|
|
932
|
+
.get(
|
|
933
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
|
|
934
|
+
{},
|
|
935
|
+
)
|
|
874
936
|
.get(
|
|
875
937
|
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/?voucher_code=DISCOUNT30`,
|
|
876
938
|
paymentPlanVoucher,
|
|
@@ -1000,6 +1062,10 @@ describe('SaleTunnel with Keycloak backend', () => {
|
|
|
1000
1062
|
.get(
|
|
1001
1063
|
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
|
|
1002
1064
|
paymentPlan,
|
|
1065
|
+
)
|
|
1066
|
+
.get(
|
|
1067
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
|
|
1068
|
+
{},
|
|
1003
1069
|
);
|
|
1004
1070
|
|
|
1005
1071
|
render(<Wrapper product={product} isWithdrawable={true} paymentPlan={paymentPlan} />, {
|
|
@@ -1054,6 +1120,10 @@ describe('SaleTunnel with Keycloak backend', () => {
|
|
|
1054
1120
|
.get(
|
|
1055
1121
|
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
|
|
1056
1122
|
paymentPlan,
|
|
1123
|
+
)
|
|
1124
|
+
.get(
|
|
1125
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/deep-link/`,
|
|
1126
|
+
{},
|
|
1057
1127
|
);
|
|
1058
1128
|
|
|
1059
1129
|
render(<Wrapper product={product} isWithdrawable={true} paymentPlan={paymentPlan} />, {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useQuery } from '@tanstack/react-query';
|
|
2
|
+
import { useJoanieApi } from 'contexts/JoanieApiContext';
|
|
3
|
+
import { OfferingDeepLink } from 'types/Joanie';
|
|
4
|
+
import { HttpError } from 'utils/errors/HttpError';
|
|
5
|
+
|
|
6
|
+
type DeepLinkFilters = {
|
|
7
|
+
course_code: string;
|
|
8
|
+
product_id: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const useDeepLink = (filters: DeepLinkFilters) => {
|
|
12
|
+
const api = useJoanieApi();
|
|
13
|
+
return useQuery<OfferingDeepLink, HttpError>({
|
|
14
|
+
queryKey: ['courses-products', ...Object.values(filters), 'deep-link'],
|
|
15
|
+
queryFn: () =>
|
|
16
|
+
api.courses.products.deepLink.get({
|
|
17
|
+
id: filters.product_id,
|
|
18
|
+
course_id: filters.course_code,
|
|
19
|
+
}),
|
|
20
|
+
});
|
|
21
|
+
};
|
|
@@ -37,7 +37,7 @@ describe('full process for the organization quotes dashboard', () => {
|
|
|
37
37
|
|
|
38
38
|
const organization = OrganizationFactory({
|
|
39
39
|
abilities: {
|
|
40
|
-
|
|
40
|
+
can_manage_batch_order_agreement: true,
|
|
41
41
|
confirm_bank_transfer: true,
|
|
42
42
|
confirm_quote: true,
|
|
43
43
|
download_quote: true,
|
|
@@ -206,7 +206,7 @@ describe('full process for the organization quotes dashboard', () => {
|
|
|
206
206
|
fetchMock.get(`https://joanie.endpoint/api/v1.0/organizations/`, []);
|
|
207
207
|
const organization = OrganizationFactory({
|
|
208
208
|
abilities: {
|
|
209
|
-
|
|
209
|
+
can_manage_batch_order_agreement: true,
|
|
210
210
|
confirm_bank_transfer: true,
|
|
211
211
|
confirm_quote: true,
|
|
212
212
|
download_quote: true,
|
|
@@ -165,7 +165,7 @@ describe('pages/TeacherDashboardOrganizationQuotes', () => {
|
|
|
165
165
|
|
|
166
166
|
const organization = OrganizationFactory({
|
|
167
167
|
abilities: {
|
|
168
|
-
|
|
168
|
+
can_manage_batch_order_agreement: false,
|
|
169
169
|
confirm_bank_transfer: false,
|
|
170
170
|
confirm_quote: false,
|
|
171
171
|
download_quote: true,
|
|
@@ -415,7 +415,7 @@ const TeacherDashboardOrganizationQuotes = () => {
|
|
|
415
415
|
const submitForSignatureButton = (
|
|
416
416
|
<Button
|
|
417
417
|
size="small"
|
|
418
|
-
disabled={batchOrder.contract_submitted || !abilities?.
|
|
418
|
+
disabled={batchOrder.contract_submitted || !abilities?.can_manage_batch_order_agreement}
|
|
419
419
|
onClick={() =>
|
|
420
420
|
!batchOrder.contract_submitted && handleSubmitForSignature(quote.batch_order.id)
|
|
421
421
|
}
|
package/js/types/Joanie.ts
CHANGED
|
@@ -36,7 +36,7 @@ export interface Organization {
|
|
|
36
36
|
dpo_email: Nullable<string>;
|
|
37
37
|
address?: Address;
|
|
38
38
|
abilities: {
|
|
39
|
-
|
|
39
|
+
can_manage_batch_order_agreement: boolean;
|
|
40
40
|
confirm_bank_transfer: boolean;
|
|
41
41
|
confirm_quote: boolean;
|
|
42
42
|
delete: boolean;
|
|
@@ -221,6 +221,10 @@ export interface Offering extends OfferingLight {
|
|
|
221
221
|
is_withdrawable: boolean;
|
|
222
222
|
rules?: OfferingRule;
|
|
223
223
|
}
|
|
224
|
+
|
|
225
|
+
export interface OfferingDeepLink {
|
|
226
|
+
deep_link: Nullable<string>;
|
|
227
|
+
}
|
|
224
228
|
export function isOffering(
|
|
225
229
|
entity: CourseListItem | OfferingLight | RichieCourse,
|
|
226
230
|
): entity is OfferingLight {
|
|
@@ -657,7 +661,7 @@ export interface PaymentPlan {
|
|
|
657
661
|
discount?: string;
|
|
658
662
|
discounted_price?: number;
|
|
659
663
|
payment_schedule: PaymentSchedule;
|
|
660
|
-
|
|
664
|
+
skip_contract_inputs: boolean;
|
|
661
665
|
}
|
|
662
666
|
|
|
663
667
|
// - API
|
|
@@ -891,6 +895,9 @@ export interface API {
|
|
|
891
895
|
paymentPlan: {
|
|
892
896
|
get(filters?: CourseProductQueryFilters): Promise<Nullable<PaymentPlan>>;
|
|
893
897
|
};
|
|
898
|
+
deepLink: {
|
|
899
|
+
get(filters?: CourseProductQueryFilters): Promise<OfferingDeepLink>;
|
|
900
|
+
};
|
|
894
901
|
};
|
|
895
902
|
orders: {
|
|
896
903
|
get(
|
|
@@ -51,7 +51,11 @@ import { Payment, PaymentMethod, PaymentProviders } from 'components/PaymentInte
|
|
|
51
51
|
import { CourseStateFactory } from 'utils/test/factories/richie';
|
|
52
52
|
import { FactoryHelper } from 'utils/test/factories/helper';
|
|
53
53
|
import { JoanieUserApiAbilityActions, JoanieUserProfile } from 'types/User';
|
|
54
|
-
import {
|
|
54
|
+
import {
|
|
55
|
+
SaleTunnelContextType,
|
|
56
|
+
SaleTunnelStep,
|
|
57
|
+
PaymentMode,
|
|
58
|
+
} from 'components/SaleTunnel/GenericSaleTunnel';
|
|
55
59
|
import { SaleTunnelProps } from 'components/SaleTunnel';
|
|
56
60
|
import { noop } from 'utils/index';
|
|
57
61
|
import { factory } from './factories';
|
|
@@ -172,7 +176,7 @@ export const OrganizationFactory = factory((): Organization => {
|
|
|
172
176
|
contact_phone: faker.phone.number(),
|
|
173
177
|
address: AddressFactory().one(),
|
|
174
178
|
abilities: {
|
|
175
|
-
|
|
179
|
+
can_manage_batch_order_agreement: faker.datatype.boolean(),
|
|
176
180
|
confirm_bank_transfer: faker.datatype.boolean(),
|
|
177
181
|
confirm_quote: faker.datatype.boolean(),
|
|
178
182
|
delete: faker.datatype.boolean(),
|
|
@@ -453,7 +457,7 @@ export const PaymentPlanFactory = factory((): PaymentPlan => {
|
|
|
453
457
|
price: faker.number.int({ min: 1, max: 1000, multipleOf: 10 }),
|
|
454
458
|
discount: undefined,
|
|
455
459
|
discounted_price: undefined,
|
|
456
|
-
|
|
460
|
+
skip_contract_inputs: false,
|
|
457
461
|
};
|
|
458
462
|
});
|
|
459
463
|
|
|
@@ -670,5 +674,7 @@ export const SaleTunnelContextFactory = factory(
|
|
|
670
674
|
setSchedule: noop,
|
|
671
675
|
needsPayment: true,
|
|
672
676
|
setNeedsPayment: noop,
|
|
677
|
+
paymentMode: PaymentMode.CLASSIC,
|
|
678
|
+
setPaymentMode: noop,
|
|
673
679
|
}),
|
|
674
680
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "richie-education",
|
|
3
|
-
"version": "3.3.
|
|
3
|
+
"version": "3.3.2-dev11",
|
|
4
4
|
"description": "A CMS to build learning portals for Open Education",
|
|
5
5
|
"main": "sandbox/manage.py",
|
|
6
6
|
"scripts": {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"extract-translations": "formatjs extract './**/*.ts*' --ignore ./node_modules --ignore './**/*.d.ts' --out-file './i18n/frontend.json' --id-interpolation-pattern '[sha512:contenthash:base64:6]' --format crowdin",
|
|
10
10
|
"compile-translations": "./i18n/compile-translations.js",
|
|
11
11
|
"lint": "eslint . 'js/**/*.ts?(x)'",
|
|
12
|
+
"prettier-check": "prettier --check 'js/**/*.+(ts|tsx|json|js|jsx)' '*.+(ts|tsx|json|js|jsx)' '**/*.+(css|scss)'",
|
|
12
13
|
"prettier-write": "prettier --write 'js/**/*.+(ts|tsx|json|js|jsx)' '*.+(ts|tsx|json|js|jsx)' '**/*.+(css|scss)'",
|
|
13
14
|
"build-sass": "sass scss/_main.scss ../richie/static/richie/css/main.css --load-path=node_modules",
|
|
14
15
|
"build-sass-production": "sass scss/_main.scss ../richie/static/richie/css/main.css --style=compressed --load-path=node_modules",
|