richie-education 3.2.1-dev1 → 3.2.1-dev10
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 +7 -8
- package/js/components/PurchaseButton/index.spec.tsx +3 -3
- package/js/components/SaleTunnel/AddressSelector/index.spec.tsx +3 -1
- package/js/components/SaleTunnel/GenericSaleTunnel.tsx +17 -1
- package/js/components/SaleTunnel/SaleTunnelInformation/index.tsx +162 -24
- package/js/components/SaleTunnel/SubscriptionButton/index.tsx +4 -2
- package/js/components/SaleTunnel/_styles.scss +48 -0
- package/js/components/SaleTunnel/index.credential.spec.tsx +1 -1
- package/js/components/SaleTunnel/index.full-process.spec.tsx +33 -11
- package/js/components/SaleTunnel/index.spec.tsx +116 -68
- package/js/components/SaleTunnel/index.stories.tsx +5 -1
- package/js/components/SaleTunnel/index.tsx +2 -0
- package/js/hooks/{usePaymentSchedule.tsx → usePaymentPlan.tsx} +8 -6
- package/js/types/Joanie.ts +11 -2
- package/js/utils/test/factories/joanie.ts +11 -0
- package/package.json +1 -1
package/js/api/joanie.ts
CHANGED
|
@@ -16,7 +16,6 @@ import { JOANIE_API_VERSION } from 'settings';
|
|
|
16
16
|
import { ResourcesQuery } from 'hooks/useResources';
|
|
17
17
|
import { ObjectHelper } from 'utils/ObjectHelper';
|
|
18
18
|
import { Maybe, Nullable } from 'types/utils';
|
|
19
|
-
import { PaymentSchedule } from 'types/Joanie';
|
|
20
19
|
import { checkStatus, getFileFromResponse } from './utils';
|
|
21
20
|
|
|
22
21
|
/*
|
|
@@ -145,8 +144,8 @@ export const getRoutes = () => {
|
|
|
145
144
|
},
|
|
146
145
|
products: {
|
|
147
146
|
get: `${baseUrl}/courses/:course_id/products/:id/`,
|
|
148
|
-
|
|
149
|
-
get: `${baseUrl}/courses/:course_id/products/:id/payment-
|
|
147
|
+
paymentPlan: {
|
|
148
|
+
get: `${baseUrl}/courses/:course_id/products/:id/payment-plan/`,
|
|
150
149
|
},
|
|
151
150
|
},
|
|
152
151
|
orders: {
|
|
@@ -430,10 +429,10 @@ const API = (): Joanie.API => {
|
|
|
430
429
|
|
|
431
430
|
return fetchWithJWT(buildApiUrl(ROUTES.courses.products.get, filters)).then(checkStatus);
|
|
432
431
|
},
|
|
433
|
-
|
|
432
|
+
paymentPlan: {
|
|
434
433
|
get: async (
|
|
435
434
|
filters?: Joanie.CourseProductQueryFilters,
|
|
436
|
-
): Promise<Nullable<
|
|
435
|
+
): Promise<Nullable<Joanie.PaymentPlan>> => {
|
|
437
436
|
if (!filters) {
|
|
438
437
|
throw new Error(
|
|
439
438
|
'A course code and a product id are required to fetch a course product',
|
|
@@ -444,9 +443,9 @@ const API = (): Joanie.API => {
|
|
|
444
443
|
throw new Error('A product id is required to fetch a course product');
|
|
445
444
|
}
|
|
446
445
|
|
|
447
|
-
return fetchWithJWT(
|
|
448
|
-
|
|
449
|
-
)
|
|
446
|
+
return fetchWithJWT(buildApiUrl(ROUTES.courses.products.paymentPlan.get, filters)).then(
|
|
447
|
+
checkStatus,
|
|
448
|
+
);
|
|
450
449
|
},
|
|
451
450
|
},
|
|
452
451
|
},
|
|
@@ -113,7 +113,7 @@ describe('PurchaseButton', () => {
|
|
|
113
113
|
fetchMock
|
|
114
114
|
.get(url, {})
|
|
115
115
|
.get(
|
|
116
|
-
`https://joanie.endpoint/api/v1.0/courses/${courseCode}/products/${product.id}/payment-
|
|
116
|
+
`https://joanie.endpoint/api/v1.0/courses/${courseCode}/products/${product.id}/payment-plan/`,
|
|
117
117
|
[],
|
|
118
118
|
);
|
|
119
119
|
|
|
@@ -157,7 +157,7 @@ describe('PurchaseButton', () => {
|
|
|
157
157
|
fetchMock
|
|
158
158
|
.get(url, {})
|
|
159
159
|
.get(
|
|
160
|
-
`https://joanie.endpoint/api/v1.0/courses/${courseCode}/products/${product.id}/payment-
|
|
160
|
+
`https://joanie.endpoint/api/v1.0/courses/${courseCode}/products/${product.id}/payment-plan/`,
|
|
161
161
|
[],
|
|
162
162
|
);
|
|
163
163
|
render(
|
|
@@ -199,7 +199,7 @@ describe('PurchaseButton', () => {
|
|
|
199
199
|
fetchMock
|
|
200
200
|
.get(url, {})
|
|
201
201
|
.get(
|
|
202
|
-
`https://joanie.endpoint/api/v1.0/courses/${courseCode}/products/${product.id}/payment-
|
|
202
|
+
`https://joanie.endpoint/api/v1.0/courses/${courseCode}/products/${product.id}/payment-plan/`,
|
|
203
203
|
[],
|
|
204
204
|
);
|
|
205
205
|
delete product.remaining_order_count;
|
|
@@ -49,6 +49,7 @@ describe('AddressSelector', () => {
|
|
|
49
49
|
|
|
50
50
|
const Wrapper = () => {
|
|
51
51
|
const [billingAddress, setBillingAddress] = useState<Address>();
|
|
52
|
+
const [voucherCode, setVoucherCode] = useState<string>();
|
|
52
53
|
const context: SaleTunnelContextType = useMemo(
|
|
53
54
|
() => ({
|
|
54
55
|
webAnalyticsEventKey: 'eventKey',
|
|
@@ -65,8 +66,9 @@ describe('AddressSelector', () => {
|
|
|
65
66
|
nextStep: jest.fn(),
|
|
66
67
|
hasWaivedWithdrawalRight: false,
|
|
67
68
|
setHasWaivedWithdrawalRight: jest.fn(),
|
|
69
|
+
setVoucherCode,
|
|
68
70
|
}),
|
|
69
|
-
[billingAddress],
|
|
71
|
+
[billingAddress, voucherCode],
|
|
70
72
|
);
|
|
71
73
|
contextRef.current = context;
|
|
72
74
|
|
|
@@ -51,6 +51,8 @@ export interface SaleTunnelContextType {
|
|
|
51
51
|
unregisterSubmitCallback: (key: string) => void;
|
|
52
52
|
runSubmitCallbacks: () => Promise<void>;
|
|
53
53
|
nextStep: () => void;
|
|
54
|
+
voucherCode?: string;
|
|
55
|
+
setVoucherCode: (code?: string) => void;
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
export const SaleTunnelContext = createContext<SaleTunnelContextType>({} as any);
|
|
@@ -93,6 +95,7 @@ export const GenericSaleTunnel = (props: GenericSaleTunnelProps) => {
|
|
|
93
95
|
const [submitCallbacks, setSubmitCallbacks] = useState<Map<string, () => Promise<void>>>(
|
|
94
96
|
new Map(),
|
|
95
97
|
);
|
|
98
|
+
const [voucherCode, setVoucherCode] = useState<string>();
|
|
96
99
|
|
|
97
100
|
const nextStep = useCallback(() => {
|
|
98
101
|
if (order)
|
|
@@ -102,6 +105,8 @@ export const GenericSaleTunnel = (props: GenericSaleTunnelProps) => {
|
|
|
102
105
|
setStep(SaleTunnelStep.SIGN);
|
|
103
106
|
} else if (order.state === OrderState.TO_SAVE_PAYMENT_METHOD) {
|
|
104
107
|
setStep(SaleTunnelStep.SAVE_PAYMENT);
|
|
108
|
+
} else if (order.state === OrderState.COMPLETED) {
|
|
109
|
+
setStep(SaleTunnelStep.SUCCESS);
|
|
105
110
|
}
|
|
106
111
|
break;
|
|
107
112
|
case SaleTunnelStep.SIGN:
|
|
@@ -147,8 +152,19 @@ export const GenericSaleTunnel = (props: GenericSaleTunnelProps) => {
|
|
|
147
152
|
runSubmitCallbacks: async () => {
|
|
148
153
|
await Promise.all(Array.from(submitCallbacks.values()).map((cb) => cb()));
|
|
149
154
|
},
|
|
155
|
+
voucherCode,
|
|
156
|
+
setVoucherCode,
|
|
150
157
|
}),
|
|
151
|
-
[
|
|
158
|
+
[
|
|
159
|
+
props,
|
|
160
|
+
order,
|
|
161
|
+
billingAddress,
|
|
162
|
+
creditCard,
|
|
163
|
+
step,
|
|
164
|
+
submitCallbacks,
|
|
165
|
+
hasWaivedWithdrawalRight,
|
|
166
|
+
voucherCode,
|
|
167
|
+
],
|
|
152
168
|
);
|
|
153
169
|
|
|
154
170
|
return (
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ChangeEvent, useEffect, useState } from 'react';
|
|
2
|
+
import { defineMessages, FormattedMessage, FormattedNumber, useIntl } from 'react-intl';
|
|
3
|
+
import { Alert, Button, Input, VariantType } from '@openfun/cunningham-react';
|
|
2
4
|
import { AddressSelector } from 'components/SaleTunnel/AddressSelector';
|
|
3
5
|
import { PaymentScheduleGrid } from 'components/PaymentScheduleGrid';
|
|
4
6
|
import { useSaleTunnelContext } from 'components/SaleTunnel/GenericSaleTunnel';
|
|
5
7
|
import OpenEdxFullNameForm from 'components/OpenEdxFullNameForm';
|
|
6
8
|
import { useSession } from 'contexts/SessionContext';
|
|
7
9
|
import useOpenEdxProfile from 'hooks/useOpenEdxProfile';
|
|
8
|
-
import { usePaymentSchedule } from 'hooks/usePaymentSchedule';
|
|
9
|
-
import { Spinner } from 'components/Spinner';
|
|
10
10
|
import WithdrawRightCheckbox from 'components/SaleTunnel/WithdrawRightCheckbox';
|
|
11
|
-
import { ProductType } from 'types/Joanie';
|
|
11
|
+
import { PaymentSchedule, ProductType } from 'types/Joanie';
|
|
12
|
+
import { usePaymentPlan } from 'hooks/usePaymentPlan';
|
|
12
13
|
|
|
13
14
|
const messages = defineMessages({
|
|
14
15
|
title: {
|
|
@@ -52,10 +53,63 @@ const messages = defineMessages({
|
|
|
52
53
|
defaultMessage:
|
|
53
54
|
'This email will be used to send you confirmation mails, it is the one you created your account with.',
|
|
54
55
|
},
|
|
56
|
+
voucherTitle: {
|
|
57
|
+
id: 'components.SaleTunnel.Information.voucher.title',
|
|
58
|
+
description: 'Title for the voucher',
|
|
59
|
+
defaultMessage: 'Voucher code',
|
|
60
|
+
},
|
|
61
|
+
voucherInfo: {
|
|
62
|
+
id: 'components.SaleTunnel.Information.voucher.info',
|
|
63
|
+
description: 'Info for the voucher',
|
|
64
|
+
defaultMessage: 'If you have a voucher code, please enter it in the field below.',
|
|
65
|
+
},
|
|
66
|
+
voucherValidate: {
|
|
67
|
+
id: 'components.SaleTunnel.Information.voucher.validate',
|
|
68
|
+
description: 'Validate text for the voucher',
|
|
69
|
+
defaultMessage: 'Validate',
|
|
70
|
+
},
|
|
71
|
+
voucherDelete: {
|
|
72
|
+
id: 'components.SaleTunnel.Information.voucher.delete',
|
|
73
|
+
description: 'Delete text for the voucher',
|
|
74
|
+
defaultMessage: 'Delete this voucher',
|
|
75
|
+
},
|
|
76
|
+
voucherError: {
|
|
77
|
+
id: 'components.SaleTunnel.Information.voucher.error',
|
|
78
|
+
description: 'Error when voucher is invalid',
|
|
79
|
+
defaultMessage: 'The submitted voucher code is not valid.',
|
|
80
|
+
},
|
|
81
|
+
discount: {
|
|
82
|
+
id: 'components.SaleTunnel.Information.voucher.discount',
|
|
83
|
+
description: 'Discount description',
|
|
84
|
+
defaultMessage: 'Discount applied',
|
|
85
|
+
},
|
|
55
86
|
});
|
|
56
87
|
|
|
57
88
|
export const SaleTunnelInformation = () => {
|
|
58
|
-
const { product } = useSaleTunnelContext();
|
|
89
|
+
const { props, product, voucherCode, setVoucherCode } = useSaleTunnelContext();
|
|
90
|
+
const [voucherError, setVoucherError] = useState(false);
|
|
91
|
+
const query = usePaymentPlan({
|
|
92
|
+
course_code: props.course?.code ?? props.enrollment!.course_run.course.code,
|
|
93
|
+
product_id: props.product.id,
|
|
94
|
+
...(voucherCode ? { voucher_code: voucherCode } : {}),
|
|
95
|
+
});
|
|
96
|
+
const schedule = query.data?.payment_schedule ?? props.paymentPlan?.payment_schedule;
|
|
97
|
+
const price = query.data?.price ?? props.paymentPlan?.price;
|
|
98
|
+
const discountedPrice = query.data?.discounted_price ?? props.paymentPlan?.discounted_price;
|
|
99
|
+
const discount = query.data?.discount ?? props.paymentPlan?.discount;
|
|
100
|
+
|
|
101
|
+
const showPaymentSchedule =
|
|
102
|
+
product.type === ProductType.CREDENTIAL &&
|
|
103
|
+
schedule &&
|
|
104
|
+
(discountedPrice != null ? discountedPrice > 0 : price != null && price > 0);
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (query.error && voucherCode) {
|
|
108
|
+
setVoucherCode('');
|
|
109
|
+
setVoucherError(true);
|
|
110
|
+
}
|
|
111
|
+
}, [query.error, voucherCode, setVoucherCode]);
|
|
112
|
+
|
|
59
113
|
return (
|
|
60
114
|
<div className="sale-tunnel__main__column sale-tunnel__information">
|
|
61
115
|
<div>
|
|
@@ -72,13 +126,19 @@ export const SaleTunnelInformation = () => {
|
|
|
72
126
|
</div>
|
|
73
127
|
</div>
|
|
74
128
|
<div>
|
|
75
|
-
{
|
|
76
|
-
<
|
|
129
|
+
{showPaymentSchedule && <PaymentScheduleBlock schedule={schedule} />}
|
|
130
|
+
<Voucher
|
|
131
|
+
discount={discount}
|
|
132
|
+
voucherError={voucherError}
|
|
133
|
+
setVoucherError={setVoucherError}
|
|
134
|
+
/>
|
|
135
|
+
<Total price={price} discountedPrice={discountedPrice} />
|
|
77
136
|
<WithdrawRightCheckbox />
|
|
78
137
|
</div>
|
|
79
138
|
</div>
|
|
80
139
|
);
|
|
81
140
|
};
|
|
141
|
+
|
|
82
142
|
const Email = () => {
|
|
83
143
|
const { user } = useSession();
|
|
84
144
|
const { data: openEdxProfileData } = useOpenEdxProfile({
|
|
@@ -100,44 +160,122 @@ const Email = () => {
|
|
|
100
160
|
);
|
|
101
161
|
};
|
|
102
162
|
|
|
103
|
-
const Total = () => {
|
|
104
|
-
const { product
|
|
105
|
-
const totalPrice =
|
|
106
|
-
enrollment?.offerings?.[0]?.rules?.discounted_price ??
|
|
107
|
-
offering?.rules?.discounted_price ??
|
|
108
|
-
product.price;
|
|
163
|
+
const Total = ({ price, discountedPrice }: { price?: number; discountedPrice?: number }) => {
|
|
164
|
+
const { product } = useSaleTunnelContext();
|
|
165
|
+
const totalPrice = price || product.price;
|
|
109
166
|
return (
|
|
110
167
|
<div className="sale-tunnel__total">
|
|
111
168
|
<div className="sale-tunnel__total__amount mt-t" data-testid="sale-tunnel__total__amount">
|
|
112
169
|
<div className="block-title">
|
|
113
170
|
<FormattedMessage {...messages.totalLabel} />
|
|
114
171
|
</div>
|
|
172
|
+
|
|
115
173
|
<div className="block-title">
|
|
116
|
-
|
|
174
|
+
{discountedPrice !== undefined ? (
|
|
175
|
+
<>
|
|
176
|
+
<span className="price--striked">
|
|
177
|
+
<FormattedNumber
|
|
178
|
+
value={totalPrice}
|
|
179
|
+
style="currency"
|
|
180
|
+
currency={product.price_currency}
|
|
181
|
+
/>
|
|
182
|
+
</span>
|
|
183
|
+
<FormattedNumber
|
|
184
|
+
value={discountedPrice}
|
|
185
|
+
style="currency"
|
|
186
|
+
currency={product.price_currency}
|
|
187
|
+
/>
|
|
188
|
+
</>
|
|
189
|
+
) : (
|
|
190
|
+
<FormattedNumber
|
|
191
|
+
value={totalPrice}
|
|
192
|
+
style="currency"
|
|
193
|
+
currency={product.price_currency}
|
|
194
|
+
/>
|
|
195
|
+
)}
|
|
117
196
|
</div>
|
|
118
197
|
</div>
|
|
119
198
|
</div>
|
|
120
199
|
);
|
|
121
200
|
};
|
|
122
201
|
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
202
|
+
const Voucher = ({
|
|
203
|
+
discount,
|
|
204
|
+
voucherError,
|
|
205
|
+
setVoucherError,
|
|
206
|
+
}: {
|
|
207
|
+
discount?: string;
|
|
208
|
+
voucherError: boolean;
|
|
209
|
+
setVoucherError: (value: boolean) => void;
|
|
210
|
+
}) => {
|
|
211
|
+
const intl = useIntl();
|
|
212
|
+
const { voucherCode, setVoucherCode } = useSaleTunnelContext();
|
|
213
|
+
const [voucher, setVoucher] = useState('');
|
|
214
|
+
const handleVoucher = (e: ChangeEvent<HTMLInputElement>) => setVoucher(e.target.value);
|
|
215
|
+
const submitVoucher = () => {
|
|
216
|
+
setVoucherError(false);
|
|
217
|
+
setVoucherCode(voucher);
|
|
218
|
+
setVoucher('');
|
|
219
|
+
};
|
|
129
220
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
221
|
+
return (
|
|
222
|
+
<div className="sale-tunnel__voucher">
|
|
223
|
+
<div className="description">
|
|
224
|
+
<h4 className="block-title mb-t">
|
|
225
|
+
<FormattedMessage {...messages.voucherTitle} />
|
|
226
|
+
</h4>
|
|
227
|
+
<span className="mb-t">
|
|
228
|
+
<FormattedMessage {...messages.voucherInfo} />
|
|
229
|
+
</span>
|
|
230
|
+
</div>
|
|
231
|
+
<div className="form">
|
|
232
|
+
<Input
|
|
233
|
+
className="form-field mt-s"
|
|
234
|
+
value={voucher}
|
|
235
|
+
onChange={handleVoucher}
|
|
236
|
+
label={intl.formatMessage(messages.voucherTitle)}
|
|
237
|
+
disabled={!!voucherCode}
|
|
238
|
+
/>
|
|
239
|
+
<Button size="small" color="primary" onClick={submitVoucher} disabled={!!voucherCode}>
|
|
240
|
+
<FormattedMessage {...messages.voucherValidate} />
|
|
241
|
+
</Button>
|
|
242
|
+
</div>
|
|
243
|
+
{voucherCode && (
|
|
244
|
+
<div className="voucher-tag">
|
|
245
|
+
<span>{voucherCode}</span>
|
|
246
|
+
<button
|
|
247
|
+
onClick={() => setVoucherCode('')}
|
|
248
|
+
title={intl.formatMessage(messages.voucherDelete)}
|
|
249
|
+
>
|
|
250
|
+
<span className="material-icons">close</span>
|
|
251
|
+
</button>
|
|
252
|
+
</div>
|
|
253
|
+
)}
|
|
254
|
+
{discount && (
|
|
255
|
+
<div className="voucher-discount">
|
|
256
|
+
<span>
|
|
257
|
+
<FormattedMessage {...messages.discount} />
|
|
258
|
+
</span>
|
|
259
|
+
<span>{discount}</span>
|
|
260
|
+
</div>
|
|
261
|
+
)}
|
|
262
|
+
{voucherError && (
|
|
263
|
+
<Alert type={VariantType.ERROR} className="mt-s">
|
|
264
|
+
<FormattedMessage {...messages.voucherError} />
|
|
265
|
+
</Alert>
|
|
266
|
+
)}
|
|
267
|
+
</div>
|
|
268
|
+
);
|
|
269
|
+
};
|
|
133
270
|
|
|
271
|
+
const PaymentScheduleBlock = ({ schedule }: { schedule: PaymentSchedule }) => {
|
|
134
272
|
return (
|
|
135
273
|
<div className="payment-schedule">
|
|
136
274
|
<h4 className="block-title mb-t">
|
|
137
275
|
<FormattedMessage {...messages.paymentSchedule} />
|
|
138
276
|
</h4>
|
|
139
277
|
<div className="mt-t">
|
|
140
|
-
<PaymentScheduleGrid schedule={
|
|
278
|
+
<PaymentScheduleGrid schedule={schedule} />
|
|
141
279
|
</div>
|
|
142
280
|
</div>
|
|
143
281
|
);
|
|
@@ -18,7 +18,7 @@ const messages = defineMessages({
|
|
|
18
18
|
walkthroughToSignAndSavePayment: {
|
|
19
19
|
id: 'components.SaleTunnel.SubscriptionButton.walkthroughToSignAndSavePayment',
|
|
20
20
|
defaultMessage:
|
|
21
|
-
'To enroll in the training, you will first be invited to sign the training agreement and then to define a payment method.',
|
|
21
|
+
'To enroll in the training, you will first be invited to sign the training agreement and then to define a payment method if required.',
|
|
22
22
|
description:
|
|
23
23
|
'Message explaining the subscription process with a training agreement to sign and a payment method to set.',
|
|
24
24
|
},
|
|
@@ -72,7 +72,7 @@ interface Props {
|
|
|
72
72
|
buildOrderPayload: (
|
|
73
73
|
payload: Pick<
|
|
74
74
|
OrderCreationPayload,
|
|
75
|
-
'product_id' | 'billing_address' | 'has_waived_withdrawal_right'
|
|
75
|
+
'product_id' | 'billing_address' | 'has_waived_withdrawal_right' | 'voucher_code'
|
|
76
76
|
>,
|
|
77
77
|
) => OrderCreationPayload;
|
|
78
78
|
}
|
|
@@ -87,6 +87,7 @@ const SubscriptionButton = ({ buildOrderPayload }: Props) => {
|
|
|
87
87
|
nextStep,
|
|
88
88
|
runSubmitCallbacks,
|
|
89
89
|
props: saleTunnelProps,
|
|
90
|
+
voucherCode,
|
|
90
91
|
} = useSaleTunnelContext();
|
|
91
92
|
const { methods: orderMethods } = useOrders(undefined, { enabled: false });
|
|
92
93
|
const [state, setState] = useState<ComponentStates>(ComponentStates.IDLE);
|
|
@@ -125,6 +126,7 @@ const SubscriptionButton = ({ buildOrderPayload }: Props) => {
|
|
|
125
126
|
product_id: product.id,
|
|
126
127
|
billing_address: billingAddress!,
|
|
127
128
|
has_waived_withdrawal_right: hasWaivedWithdrawalRight,
|
|
129
|
+
voucher_code: voucherCode,
|
|
128
130
|
});
|
|
129
131
|
|
|
130
132
|
orderMethods.create(payload, {
|
|
@@ -82,6 +82,54 @@
|
|
|
82
82
|
font-size: 0.75rem;
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
|
+
.price--striked {
|
|
86
|
+
text-decoration: line-through;
|
|
87
|
+
opacity: 0.5;
|
|
88
|
+
margin-right: 0.5rem;
|
|
89
|
+
}
|
|
90
|
+
.sale-tunnel__voucher {
|
|
91
|
+
margin-top: 1rem;
|
|
92
|
+
& > .form {
|
|
93
|
+
display: flex;
|
|
94
|
+
flex-direction: row;
|
|
95
|
+
align-items: center;
|
|
96
|
+
margin-top: 0.75rem;
|
|
97
|
+
& > .mt-s {
|
|
98
|
+
margin-top: 0;
|
|
99
|
+
}
|
|
100
|
+
& > button {
|
|
101
|
+
margin-left: 0.5rem;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
& > .voucher-tag {
|
|
105
|
+
margin-top: 0.5rem;
|
|
106
|
+
border-radius: 0.25rem;
|
|
107
|
+
padding: 0.5rem;
|
|
108
|
+
width: fit-content;
|
|
109
|
+
color: black;
|
|
110
|
+
display: flex;
|
|
111
|
+
flex-direction: row;
|
|
112
|
+
justify-content: space-between;
|
|
113
|
+
align-items: center;
|
|
114
|
+
border: none;
|
|
115
|
+
background-color: lightgray;
|
|
116
|
+
&:hover {
|
|
117
|
+
background-color: gray;
|
|
118
|
+
}
|
|
119
|
+
& > button {
|
|
120
|
+
background: none;
|
|
121
|
+
border: none;
|
|
122
|
+
display: flex;
|
|
123
|
+
align-items: center;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
& > .voucher-discount {
|
|
127
|
+
margin-top: 1rem;
|
|
128
|
+
display: flex;
|
|
129
|
+
justify-content: space-between;
|
|
130
|
+
font-size: var(--c--theme--font--sizes--l);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
85
133
|
}
|
|
86
134
|
|
|
87
135
|
.description {
|
|
@@ -93,7 +93,7 @@ describe('SaleTunnel / Credential', () => {
|
|
|
93
93
|
fetchMock
|
|
94
94
|
.get(url, [])
|
|
95
95
|
.get(
|
|
96
|
-
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-
|
|
96
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
|
|
97
97
|
[],
|
|
98
98
|
)
|
|
99
99
|
.post('https://joanie.endpoint/api/v1.0/orders/', order)
|
|
@@ -19,8 +19,8 @@ import {
|
|
|
19
19
|
CredentialOrderFactory,
|
|
20
20
|
CreditCardFactory,
|
|
21
21
|
PaymentFactory,
|
|
22
|
-
PaymentInstallmentFactory,
|
|
23
22
|
ProductFactory,
|
|
23
|
+
PaymentPlanFactory,
|
|
24
24
|
} from 'utils/test/factories/joanie';
|
|
25
25
|
import { CourseRun, NOT_CANCELED_ORDER_STATES, OrderState } from 'types/Joanie';
|
|
26
26
|
import { Priority } from 'types';
|
|
@@ -70,7 +70,9 @@ describe('SaleTunnel', () => {
|
|
|
70
70
|
new Intl.NumberFormat('en', {
|
|
71
71
|
currency,
|
|
72
72
|
style: 'currency',
|
|
73
|
-
})
|
|
73
|
+
})
|
|
74
|
+
.format(price)
|
|
75
|
+
.replace(/(\u202F|\u00a0)/g, ' ');
|
|
74
76
|
|
|
75
77
|
beforeEach(() => {
|
|
76
78
|
richieUser = UserFactory().one();
|
|
@@ -104,15 +106,15 @@ describe('SaleTunnel', () => {
|
|
|
104
106
|
product,
|
|
105
107
|
is_withdrawable: false,
|
|
106
108
|
}).one();
|
|
107
|
-
const
|
|
109
|
+
const paymentPlan = PaymentPlanFactory().one();
|
|
108
110
|
|
|
109
111
|
fetchMock.get(
|
|
110
112
|
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/`,
|
|
111
113
|
offering,
|
|
112
114
|
);
|
|
113
115
|
fetchMock.get(
|
|
114
|
-
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-
|
|
115
|
-
|
|
116
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
|
|
117
|
+
paymentPlan,
|
|
116
118
|
);
|
|
117
119
|
fetchMock.get(`https://joanie.endpoint/api/v1.0/enrollments/`, []);
|
|
118
120
|
const orderQueryParameters = {
|
|
@@ -135,7 +137,7 @@ describe('SaleTunnel', () => {
|
|
|
135
137
|
screen.getByText(
|
|
136
138
|
// the price formatter generates non-breaking spaces and getByText doesn't seem to handle that well, replace it
|
|
137
139
|
// with a regular space. We replace NNBSP (\u202F) and NBSP (\u00a0) with a regular space
|
|
138
|
-
priceFormatter(product.price_currency, product.price)
|
|
140
|
+
priceFormatter(product.price_currency, product.price),
|
|
139
141
|
);
|
|
140
142
|
expect(screen.queryByText('Purchased')).not.toBeInTheDocument();
|
|
141
143
|
|
|
@@ -250,14 +252,12 @@ describe('SaleTunnel', () => {
|
|
|
250
252
|
* Make sure the payment schedule is displayed.
|
|
251
253
|
*/
|
|
252
254
|
screen.getByRole('heading', { name: 'Payment schedule' });
|
|
253
|
-
|
|
255
|
+
paymentPlan.payment_schedule.forEach((installment, index) => {
|
|
254
256
|
const row = screen.getByTestId(installment.id);
|
|
255
257
|
const cells = getAllByRole(row, 'cell');
|
|
256
258
|
expect(cells).toHaveLength(4);
|
|
257
259
|
expect(cells[0]).toHaveTextContent((index + 1).toString());
|
|
258
|
-
expect(cells[1]).toHaveTextContent(
|
|
259
|
-
priceFormatter(installment.currency, installment.amount).replace(/(\u202F|\u00a0)/g, ' '),
|
|
260
|
-
);
|
|
260
|
+
expect(cells[1]).toHaveTextContent(priceFormatter(installment.currency, installment.amount));
|
|
261
261
|
expect(cells[2]).toHaveTextContent(
|
|
262
262
|
`Withdrawn on ${dateFormatter.format(new Date(installment.due_date))}`,
|
|
263
263
|
);
|
|
@@ -266,8 +266,30 @@ describe('SaleTunnel', () => {
|
|
|
266
266
|
|
|
267
267
|
const $totalAmount = screen.getByTestId('sale-tunnel__total__amount');
|
|
268
268
|
expect($totalAmount).toHaveTextContent(
|
|
269
|
+
'Total' + priceFormatter(product.price_currency, paymentPlan.price),
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Submit voucher and check price
|
|
274
|
+
*/
|
|
275
|
+
const paymentPlanVoucher = PaymentPlanFactory({
|
|
276
|
+
discounted_price: 70,
|
|
277
|
+
discount: '-30%',
|
|
278
|
+
}).one();
|
|
279
|
+
fetchMock.get(
|
|
280
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/?voucher_code=DISCOUNT30`,
|
|
281
|
+
paymentPlanVoucher,
|
|
282
|
+
{ overwriteRoutes: true },
|
|
283
|
+
);
|
|
284
|
+
await user.type(screen.getByLabelText('Voucher code'), 'DISCOUNT30');
|
|
285
|
+
await user.click(screen.getByRole('button', { name: 'Validate' }));
|
|
286
|
+
screen.getByRole('heading', { name: 'Payment schedule' });
|
|
287
|
+
await screen.findByTestId('sale-tunnel__total__amount');
|
|
288
|
+
const $totalAmountVoucher = screen.getByTestId('sale-tunnel__total__amount');
|
|
289
|
+
expect($totalAmountVoucher).toHaveTextContent(
|
|
269
290
|
'Total' +
|
|
270
|
-
priceFormatter(product.price_currency,
|
|
291
|
+
priceFormatter(product.price_currency, paymentPlanVoucher.price!) +
|
|
292
|
+
priceFormatter(product.price_currency, paymentPlanVoucher.discounted_price!),
|
|
271
293
|
);
|
|
272
294
|
|
|
273
295
|
/**
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
CredentialProductFactory,
|
|
23
23
|
CreditCardFactory,
|
|
24
24
|
EnrollmentFactory,
|
|
25
|
-
|
|
25
|
+
PaymentPlanFactory,
|
|
26
26
|
} from 'utils/test/factories/joanie';
|
|
27
27
|
import { Priority } from 'types';
|
|
28
28
|
import { render } from 'utils/test/render';
|
|
@@ -163,15 +163,15 @@ describe.each([
|
|
|
163
163
|
is_main: true,
|
|
164
164
|
}).one();
|
|
165
165
|
const order = OrderFactory({ state: OrderState.TO_SAVE_PAYMENT_METHOD }).one();
|
|
166
|
-
|
|
166
|
+
const paymentPlan = PaymentPlanFactory().one();
|
|
167
167
|
fetchMock
|
|
168
168
|
.get(
|
|
169
169
|
`https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
|
|
170
170
|
[],
|
|
171
171
|
)
|
|
172
172
|
.get(
|
|
173
|
-
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-
|
|
174
|
-
|
|
173
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
|
|
174
|
+
paymentPlan,
|
|
175
175
|
)
|
|
176
176
|
.post('https://joanie.endpoint/api/v1.0/orders/', order)
|
|
177
177
|
.get(`https://joanie.endpoint/api/v1.0/orders/${order.id}/`, order)
|
|
@@ -179,15 +179,13 @@ describe.each([
|
|
|
179
179
|
overwriteRoutes: true,
|
|
180
180
|
});
|
|
181
181
|
|
|
182
|
-
render(<Wrapper product={product} isWithdrawable={true} />, {
|
|
182
|
+
render(<Wrapper product={product} isWithdrawable={true} paymentPlan={paymentPlan} />, {
|
|
183
183
|
queryOptions: { client: createTestQueryClient({ user: richieUser }) },
|
|
184
184
|
});
|
|
185
185
|
nbApiCalls += 1; // useProductOrder call.
|
|
186
186
|
nbApiCalls += 1; // get user account call.
|
|
187
187
|
nbApiCalls += 1; // get user preferences call.
|
|
188
|
-
|
|
189
|
-
nbApiCalls += 1; // product payment-schedule call
|
|
190
|
-
}
|
|
188
|
+
nbApiCalls += 1; // product payment-schedule call
|
|
191
189
|
await waitFor(() => expect(fetchMock.calls()).toHaveLength(nbApiCalls));
|
|
192
190
|
|
|
193
191
|
const user = userEvent.setup({ delay: null });
|
|
@@ -253,30 +251,28 @@ describe.each([
|
|
|
253
251
|
is_main: true,
|
|
254
252
|
}).one();
|
|
255
253
|
const deferred = new Deferred();
|
|
256
|
-
|
|
254
|
+
const paymentPlan = PaymentPlanFactory().one();
|
|
257
255
|
fetchMock
|
|
258
256
|
.get(
|
|
259
257
|
`https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
|
|
260
258
|
[],
|
|
261
259
|
)
|
|
262
260
|
.get(
|
|
263
|
-
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-
|
|
264
|
-
|
|
261
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
|
|
262
|
+
paymentPlan,
|
|
265
263
|
)
|
|
266
264
|
.post('https://joanie.endpoint/api/v1.0/orders/', deferred.promise)
|
|
267
265
|
.get('https://joanie.endpoint/api/v1.0/addresses/', [billingAddress], {
|
|
268
266
|
overwriteRoutes: true,
|
|
269
267
|
});
|
|
270
268
|
|
|
271
|
-
render(<Wrapper product={product} isWithdrawable={true} />, {
|
|
269
|
+
render(<Wrapper product={product} isWithdrawable={true} paymentPlan={paymentPlan} />, {
|
|
272
270
|
queryOptions: { client: createTestQueryClient({ user: richieUser }) },
|
|
273
271
|
});
|
|
274
272
|
nbApiCalls += 1; // useProductOrder get order with filters
|
|
275
273
|
nbApiCalls += 1; // get user account call.
|
|
276
274
|
nbApiCalls += 1; // get user preferences call.
|
|
277
|
-
|
|
278
|
-
nbApiCalls += 1; // get product payment schedule.
|
|
279
|
-
}
|
|
275
|
+
nbApiCalls += 1; // get paymentPlan call.
|
|
280
276
|
await waitFor(() => expect(fetchMock.calls()).toHaveLength(nbApiCalls));
|
|
281
277
|
|
|
282
278
|
const user = userEvent.setup({ delay: null });
|
|
@@ -328,15 +324,15 @@ describe.each([
|
|
|
328
324
|
}).one();
|
|
329
325
|
const creditCard = CreditCardFactory().one();
|
|
330
326
|
const order = OrderFactory({ state: OrderState.TO_SAVE_PAYMENT_METHOD }).one();
|
|
331
|
-
|
|
327
|
+
const paymentPlan = PaymentPlanFactory().one();
|
|
332
328
|
fetchMock
|
|
333
329
|
.get(
|
|
334
330
|
`https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
|
|
335
331
|
[order],
|
|
336
332
|
)
|
|
337
333
|
.get(
|
|
338
|
-
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-
|
|
339
|
-
|
|
334
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
|
|
335
|
+
paymentPlan,
|
|
340
336
|
)
|
|
341
337
|
.get('https://joanie.endpoint/api/v1.0/credit-cards/', [creditCard], {
|
|
342
338
|
overwriteRoutes: true,
|
|
@@ -345,7 +341,7 @@ describe.each([
|
|
|
345
341
|
overwriteRoutes: true,
|
|
346
342
|
});
|
|
347
343
|
|
|
348
|
-
render(<Wrapper product={product} isWithdrawable={true} />, {
|
|
344
|
+
render(<Wrapper product={product} isWithdrawable={true} paymentPlan={paymentPlan} />, {
|
|
349
345
|
queryOptions: { client: createTestQueryClient({ user: richieUser }) },
|
|
350
346
|
});
|
|
351
347
|
|
|
@@ -364,15 +360,15 @@ describe.each([
|
|
|
364
360
|
}).one();
|
|
365
361
|
const creditCard = CreditCardFactory().one();
|
|
366
362
|
const order = OrderFactory({ state }).one();
|
|
367
|
-
|
|
363
|
+
const paymentPlan = PaymentPlanFactory().one();
|
|
368
364
|
fetchMock
|
|
369
365
|
.get(
|
|
370
366
|
`https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
|
|
371
367
|
[order],
|
|
372
368
|
)
|
|
373
369
|
.get(
|
|
374
|
-
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-
|
|
375
|
-
|
|
370
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
|
|
371
|
+
paymentPlan,
|
|
376
372
|
)
|
|
377
373
|
.get('https://joanie.endpoint/api/v1.0/credit-cards/', [creditCard], {
|
|
378
374
|
overwriteRoutes: true,
|
|
@@ -381,7 +377,7 @@ describe.each([
|
|
|
381
377
|
overwriteRoutes: true,
|
|
382
378
|
});
|
|
383
379
|
|
|
384
|
-
render(<Wrapper product={product} isWithdrawable={true} />, {
|
|
380
|
+
render(<Wrapper product={product} isWithdrawable={true} paymentPlan={paymentPlan} />, {
|
|
385
381
|
queryOptions: { client: createTestQueryClient({ user: richieUser }) },
|
|
386
382
|
});
|
|
387
383
|
|
|
@@ -395,18 +391,19 @@ describe.each([
|
|
|
395
391
|
it('should show the product payment schedule', async () => {
|
|
396
392
|
const intl = createIntl({ locale: 'en' });
|
|
397
393
|
const product = ProductFactory().one();
|
|
398
|
-
const
|
|
394
|
+
const paymentPlan = PaymentPlanFactory().one();
|
|
395
|
+
const schedule = paymentPlan.payment_schedule;
|
|
399
396
|
fetchMock
|
|
400
397
|
.get(
|
|
401
398
|
`https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
|
|
402
399
|
[],
|
|
403
400
|
)
|
|
404
401
|
.get(
|
|
405
|
-
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-
|
|
406
|
-
|
|
402
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
|
|
403
|
+
paymentPlan,
|
|
407
404
|
);
|
|
408
405
|
|
|
409
|
-
render(<Wrapper product={product} isWithdrawable={true} />, {
|
|
406
|
+
render(<Wrapper paymentPlan={paymentPlan} product={product} isWithdrawable={true} />, {
|
|
410
407
|
queryOptions: { client: createTestQueryClient({ user: richieUser }) },
|
|
411
408
|
});
|
|
412
409
|
|
|
@@ -448,7 +445,8 @@ describe.each([
|
|
|
448
445
|
|
|
449
446
|
const $totalAmount = screen.getByTestId('sale-tunnel__total__amount');
|
|
450
447
|
expect($totalAmount).toHaveTextContent(
|
|
451
|
-
'Total' +
|
|
448
|
+
'Total' +
|
|
449
|
+
formatPrice(paymentPlan.price, product.price_currency).replace(/(\u202F|\u00a0)/g, ' '),
|
|
452
450
|
);
|
|
453
451
|
});
|
|
454
452
|
|
|
@@ -473,39 +471,49 @@ describe.each([
|
|
|
473
471
|
}).one(),
|
|
474
472
|
],
|
|
475
473
|
}).one();
|
|
476
|
-
|
|
474
|
+
const paymentPlan = PaymentPlanFactory({ discounted_price: 80 }).one();
|
|
477
475
|
if (product.type === ProductType.CERTIFICATE) {
|
|
478
476
|
enrollmentDiscounted.offerings[0].product = product;
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
477
|
+
fetchMock
|
|
478
|
+
.get(
|
|
479
|
+
`https://joanie.endpoint/api/v1.0/orders/?enrollment_id=${enrollmentDiscounted.id}&product_id=${product.id}&state=pending&state=pending_payment&state=no_payment&state=failed_payment&state=completed&state=draft&state=assigned&state=to_sign&state=signing&state=to_save_payment_method`,
|
|
480
|
+
{
|
|
481
|
+
results: [],
|
|
482
|
+
next: null,
|
|
483
|
+
previous: null,
|
|
484
|
+
count: 0,
|
|
485
|
+
},
|
|
486
|
+
)
|
|
487
|
+
.get(
|
|
488
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
|
|
489
|
+
paymentPlan,
|
|
490
|
+
);
|
|
490
491
|
render(
|
|
491
|
-
<Wrapper
|
|
492
|
+
<Wrapper
|
|
493
|
+
product={product}
|
|
494
|
+
enrollment={enrollmentDiscounted}
|
|
495
|
+
isWithdrawable={true}
|
|
496
|
+
paymentPlan={paymentPlan}
|
|
497
|
+
/>,
|
|
492
498
|
{ queryOptions: { client: createTestQueryClient({ user: richieUser }) } },
|
|
493
499
|
);
|
|
494
500
|
|
|
495
501
|
const $totalAmount = screen.getByTestId('sale-tunnel__total__amount');
|
|
496
502
|
expect($totalAmount).toHaveTextContent(
|
|
497
503
|
'Total' +
|
|
498
|
-
formatPrice(
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
504
|
+
formatPrice(paymentPlan.price!, product.price_currency).replace(/(\u202F|\u00a0)/g, ' ') +
|
|
505
|
+
formatPrice(paymentPlan.discounted_price!, product.price_currency).replace(
|
|
506
|
+
/(\u202F|\u00a0)/g,
|
|
507
|
+
' ',
|
|
508
|
+
),
|
|
502
509
|
);
|
|
503
510
|
}
|
|
504
511
|
});
|
|
505
512
|
|
|
506
513
|
it('should show the product payment schedule with discounted price', async () => {
|
|
507
514
|
const intl = createIntl({ locale: 'en' });
|
|
508
|
-
const
|
|
515
|
+
const paymentPlan = PaymentPlanFactory({ discounted_price: 80 }).one();
|
|
516
|
+
const schedule = paymentPlan.payment_schedule;
|
|
509
517
|
|
|
510
518
|
const offering = OfferingFactory({
|
|
511
519
|
product: ProductFactory({
|
|
@@ -525,13 +533,21 @@ describe.each([
|
|
|
525
533
|
[],
|
|
526
534
|
)
|
|
527
535
|
.get(
|
|
528
|
-
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-
|
|
529
|
-
|
|
536
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
|
|
537
|
+
paymentPlan,
|
|
530
538
|
);
|
|
531
539
|
|
|
532
|
-
render(
|
|
533
|
-
|
|
534
|
-
|
|
540
|
+
render(
|
|
541
|
+
<Wrapper
|
|
542
|
+
paymentPlan={paymentPlan}
|
|
543
|
+
product={product}
|
|
544
|
+
offering={offering}
|
|
545
|
+
isWithdrawable={true}
|
|
546
|
+
/>,
|
|
547
|
+
{
|
|
548
|
+
queryOptions: { client: createTestQueryClient({ user: richieUser }) },
|
|
549
|
+
},
|
|
550
|
+
);
|
|
535
551
|
|
|
536
552
|
if (product.type === ProductType.CREDENTIAL) {
|
|
537
553
|
await screen.findByRole('heading', { level: 4, name: 'Payment schedule' });
|
|
@@ -569,7 +585,8 @@ describe.each([
|
|
|
569
585
|
const $totalAmount = screen.getByTestId('sale-tunnel__total__amount');
|
|
570
586
|
expect($totalAmount).toHaveTextContent(
|
|
571
587
|
'Total' +
|
|
572
|
-
formatPrice(
|
|
588
|
+
formatPrice(paymentPlan.price!, product.price_currency).replace(/(\u202F|\u00a0)/g, ' ') +
|
|
589
|
+
formatPrice(paymentPlan.discounted_price!, product.price_currency).replace(
|
|
573
590
|
/(\u202F|\u00a0)/g,
|
|
574
591
|
' ',
|
|
575
592
|
),
|
|
@@ -578,18 +595,18 @@ describe.each([
|
|
|
578
595
|
|
|
579
596
|
it('should show a walkthrough to explain the subscription process', async () => {
|
|
580
597
|
const product = ProductFactory().one();
|
|
581
|
-
const
|
|
598
|
+
const paymentPlan = PaymentPlanFactory().one();
|
|
582
599
|
fetchMock
|
|
583
600
|
.get(
|
|
584
601
|
`https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
|
|
585
602
|
[],
|
|
586
603
|
)
|
|
587
604
|
.get(
|
|
588
|
-
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-
|
|
589
|
-
|
|
605
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
|
|
606
|
+
paymentPlan,
|
|
590
607
|
);
|
|
591
608
|
|
|
592
|
-
render(<Wrapper product={product} isWithdrawable={true} />, {
|
|
609
|
+
render(<Wrapper paymentPlan={paymentPlan} product={product} isWithdrawable={true} />, {
|
|
593
610
|
queryOptions: { client: createTestQueryClient({ user: richieUser }) },
|
|
594
611
|
});
|
|
595
612
|
|
|
@@ -598,18 +615,18 @@ describe.each([
|
|
|
598
615
|
|
|
599
616
|
it('should show a checkbox to waive withdrawal right if the product is not withdrawable', async () => {
|
|
600
617
|
const product = ProductFactory().one();
|
|
601
|
-
const
|
|
618
|
+
const paymentPlan = PaymentPlanFactory().one();
|
|
602
619
|
fetchMock
|
|
603
620
|
.get(
|
|
604
621
|
`https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
|
|
605
622
|
[],
|
|
606
623
|
)
|
|
607
624
|
.get(
|
|
608
|
-
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-
|
|
609
|
-
|
|
625
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
|
|
626
|
+
paymentPlan,
|
|
610
627
|
);
|
|
611
628
|
|
|
612
|
-
render(<Wrapper product={product} isWithdrawable={false} />, {
|
|
629
|
+
render(<Wrapper paymentPlan={paymentPlan} product={product} isWithdrawable={false} />, {
|
|
613
630
|
queryOptions: { client: createTestQueryClient({ user: richieUser }) },
|
|
614
631
|
});
|
|
615
632
|
|
|
@@ -618,18 +635,18 @@ describe.each([
|
|
|
618
635
|
|
|
619
636
|
it('should not show a checkbox to waive withdrawal right if the product is withdrawable', async () => {
|
|
620
637
|
const product = ProductFactory().one();
|
|
621
|
-
const
|
|
638
|
+
const paymentPlan = PaymentPlanFactory().one();
|
|
622
639
|
fetchMock
|
|
623
640
|
.get(
|
|
624
641
|
`https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
|
|
625
642
|
[],
|
|
626
643
|
)
|
|
627
644
|
.get(
|
|
628
|
-
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-
|
|
629
|
-
|
|
645
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
|
|
646
|
+
paymentPlan,
|
|
630
647
|
);
|
|
631
648
|
|
|
632
|
-
render(<Wrapper product={product} isWithdrawable={true} />, {
|
|
649
|
+
render(<Wrapper paymentPlan={paymentPlan} product={product} isWithdrawable={true} />, {
|
|
633
650
|
queryOptions: { client: createTestQueryClient({ user: richieUser }) },
|
|
634
651
|
});
|
|
635
652
|
|
|
@@ -638,18 +655,18 @@ describe.each([
|
|
|
638
655
|
|
|
639
656
|
it('should show a specific checkbox to waive withdrawal right according to the product type', async () => {
|
|
640
657
|
const product = ProductFactory().one();
|
|
641
|
-
const
|
|
658
|
+
const paymentPlan = PaymentPlanFactory().one();
|
|
642
659
|
fetchMock
|
|
643
660
|
.get(
|
|
644
661
|
`https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
|
|
645
662
|
[],
|
|
646
663
|
)
|
|
647
664
|
.get(
|
|
648
|
-
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-
|
|
649
|
-
|
|
665
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
|
|
666
|
+
paymentPlan,
|
|
650
667
|
);
|
|
651
668
|
|
|
652
|
-
render(<Wrapper product={product} isWithdrawable={false} />, {
|
|
669
|
+
render(<Wrapper paymentPlan={paymentPlan} product={product} isWithdrawable={false} />, {
|
|
653
670
|
queryOptions: { client: createTestQueryClient({ user: richieUser }) },
|
|
654
671
|
});
|
|
655
672
|
|
|
@@ -672,4 +689,35 @@ describe.each([
|
|
|
672
689
|
screen.getByText(message);
|
|
673
690
|
});
|
|
674
691
|
});
|
|
692
|
+
|
|
693
|
+
it('should show an error when submit an invalid voucher', async () => {
|
|
694
|
+
const user = userEvent.setup({ delay: null });
|
|
695
|
+
const paymentPlan = PaymentPlanFactory().one();
|
|
696
|
+
const product = ProductFactory().one();
|
|
697
|
+
fetchMock
|
|
698
|
+
.get(
|
|
699
|
+
`https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
|
|
700
|
+
[],
|
|
701
|
+
)
|
|
702
|
+
.get(
|
|
703
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
|
|
704
|
+
paymentPlan,
|
|
705
|
+
)
|
|
706
|
+
.get(
|
|
707
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/?voucher_code=DISCOUNT30`,
|
|
708
|
+
{
|
|
709
|
+
status: 404,
|
|
710
|
+
body: {
|
|
711
|
+
detail: 'No Voucher matches the given query.',
|
|
712
|
+
},
|
|
713
|
+
},
|
|
714
|
+
);
|
|
715
|
+
render(<Wrapper paymentPlan={paymentPlan} product={product} isWithdrawable={true} />, {
|
|
716
|
+
queryOptions: { client: createTestQueryClient({ user: richieUser }) },
|
|
717
|
+
});
|
|
718
|
+
expect(screen.getByLabelText('Voucher code'));
|
|
719
|
+
await user.type(screen.getByLabelText('Voucher code'), 'DISCOUNT30');
|
|
720
|
+
await user.click(screen.getByRole('button', { name: 'Validate' }));
|
|
721
|
+
expect(await screen.findByText('The submitted voucher code is not valid.')).toBeInTheDocument();
|
|
722
|
+
});
|
|
675
723
|
});
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
CertificateProductFactory,
|
|
5
5
|
EnrollmentFactory,
|
|
6
6
|
OfferingFactory,
|
|
7
|
+
PaymentPlanFactory,
|
|
7
8
|
ProductFactory,
|
|
8
9
|
} from 'utils/test/factories/joanie';
|
|
9
10
|
import { PacedCourseFactory } from 'utils/test/factories/richie';
|
|
@@ -34,7 +35,9 @@ export default {
|
|
|
34
35
|
type Story = StoryObj<typeof SaleTunnel>;
|
|
35
36
|
|
|
36
37
|
export const Credential: Story = {
|
|
37
|
-
args: {
|
|
38
|
+
args: {
|
|
39
|
+
paymentPlan: PaymentPlanFactory().one(),
|
|
40
|
+
},
|
|
38
41
|
};
|
|
39
42
|
|
|
40
43
|
export const CertificateDiscount: Story = {
|
|
@@ -44,5 +47,6 @@ export const CertificateDiscount: Story = {
|
|
|
44
47
|
enrollment: EnrollmentFactory({
|
|
45
48
|
offerings: OfferingFactory({ rules: { discounted_price: 80 } }).many(1),
|
|
46
49
|
}).one(),
|
|
50
|
+
paymentPlan: PaymentPlanFactory({ discounted_price: 80 }).one(),
|
|
47
51
|
},
|
|
48
52
|
};
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
Organization,
|
|
10
10
|
Product,
|
|
11
11
|
ProductType,
|
|
12
|
+
PaymentPlan,
|
|
12
13
|
} from 'types/Joanie';
|
|
13
14
|
import { CredentialSaleTunnel } from 'components/SaleTunnel/CredentialSaleTunnel';
|
|
14
15
|
import { CertificateSaleTunnel } from 'components/SaleTunnel/CertificateSaleTunnel';
|
|
@@ -21,6 +22,7 @@ export interface SaleTunnelProps extends Pick<ModalProps, 'isOpen' | 'onClose'>
|
|
|
21
22
|
isWithdrawable: boolean;
|
|
22
23
|
course?: PacedCourse | CourseLight;
|
|
23
24
|
enrollment?: Enrollment;
|
|
25
|
+
paymentPlan?: PaymentPlan;
|
|
24
26
|
onFinish?: (order: Order) => void;
|
|
25
27
|
}
|
|
26
28
|
|
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
import { useQuery } from '@tanstack/react-query';
|
|
2
2
|
import { useJoanieApi } from 'contexts/JoanieApiContext';
|
|
3
|
-
import {
|
|
3
|
+
import { PaymentPlan } from 'types/Joanie';
|
|
4
4
|
import { Nullable } from 'types/utils';
|
|
5
5
|
|
|
6
|
-
type
|
|
6
|
+
type PaymentPlanFilters = {
|
|
7
7
|
course_code: string;
|
|
8
8
|
product_id: string;
|
|
9
|
+
voucher_code?: string;
|
|
9
10
|
};
|
|
10
11
|
|
|
11
|
-
export const
|
|
12
|
-
const queryKey = ['courses-products', ...Object.values(filters), 'payment-
|
|
12
|
+
export const usePaymentPlan = (filters: PaymentPlanFilters) => {
|
|
13
|
+
const queryKey = ['courses-products', ...Object.values(filters), 'payment-plan'];
|
|
13
14
|
|
|
14
15
|
const api = useJoanieApi();
|
|
15
|
-
return useQuery<Nullable<
|
|
16
|
+
return useQuery<Nullable<PaymentPlan>, Error>({
|
|
16
17
|
queryKey,
|
|
17
18
|
queryFn: () =>
|
|
18
|
-
api.courses.products.
|
|
19
|
+
api.courses.products.paymentPlan.get({
|
|
19
20
|
id: filters.product_id,
|
|
20
21
|
course_id: filters.course_code,
|
|
22
|
+
voucher_code: filters.voucher_code,
|
|
21
23
|
}),
|
|
22
24
|
});
|
|
23
25
|
};
|
package/js/types/Joanie.ts
CHANGED
|
@@ -482,6 +482,13 @@ export interface PaymentInstallment {
|
|
|
482
482
|
|
|
483
483
|
export type PaymentSchedule = readonly PaymentInstallment[];
|
|
484
484
|
|
|
485
|
+
export interface PaymentPlan {
|
|
486
|
+
price: number;
|
|
487
|
+
discount?: string;
|
|
488
|
+
discounted_price?: number;
|
|
489
|
+
payment_schedule: PaymentSchedule;
|
|
490
|
+
}
|
|
491
|
+
|
|
485
492
|
// - API
|
|
486
493
|
export interface AddressCreationPayload extends Omit<Address, 'id' | 'is_main'> {
|
|
487
494
|
is_main?: boolean;
|
|
@@ -491,6 +498,7 @@ interface AbstractOrderProductCreationPayload {
|
|
|
491
498
|
product_id: Product['id'];
|
|
492
499
|
billing_address: Omit<Address, 'id' | 'is_main'>;
|
|
493
500
|
has_waived_withdrawal_right: boolean;
|
|
501
|
+
voucher_code?: string;
|
|
494
502
|
}
|
|
495
503
|
|
|
496
504
|
interface OrderCertificateCreationPayload extends AbstractOrderProductCreationPayload {
|
|
@@ -547,6 +555,7 @@ export interface CourseQueryFilters extends ResourcesQuery {
|
|
|
547
555
|
export interface CourseProductQueryFilters extends ResourcesQuery {
|
|
548
556
|
id?: Product['id'];
|
|
549
557
|
course_id?: CourseListItem['id'];
|
|
558
|
+
voucher_code?: string;
|
|
550
559
|
}
|
|
551
560
|
export interface OfferingQueryFilters extends PaginatedResourceQuery {
|
|
552
561
|
id?: Offering['id'];
|
|
@@ -684,8 +693,8 @@ export interface API {
|
|
|
684
693
|
: Promise<PaginatedResponse<CourseListItem>>;
|
|
685
694
|
products: {
|
|
686
695
|
get(filters?: CourseProductQueryFilters): Promise<Nullable<Offering>>;
|
|
687
|
-
|
|
688
|
-
get(filters?: CourseProductQueryFilters): Promise<Nullable<
|
|
696
|
+
paymentPlan: {
|
|
697
|
+
get(filters?: CourseProductQueryFilters): Promise<Nullable<PaymentPlan>>;
|
|
689
698
|
};
|
|
690
699
|
};
|
|
691
700
|
orders: {
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
ProductType,
|
|
38
38
|
TargetCourse,
|
|
39
39
|
UserLight,
|
|
40
|
+
PaymentPlan,
|
|
40
41
|
} from 'types/Joanie';
|
|
41
42
|
import { Payment, PaymentProviders } from 'components/PaymentInterfaces/types';
|
|
42
43
|
import { CourseStateFactory } from 'utils/test/factories/richie';
|
|
@@ -360,6 +361,15 @@ export const PaymentInstallmentFactory = factory((): PaymentInstallment => {
|
|
|
360
361
|
};
|
|
361
362
|
});
|
|
362
363
|
|
|
364
|
+
export const PaymentPlanFactory = factory((): PaymentPlan => {
|
|
365
|
+
return {
|
|
366
|
+
payment_schedule: PaymentInstallmentFactory().many(2),
|
|
367
|
+
price: faker.number.int({ min: 1, max: 1000, multipleOf: 10 }),
|
|
368
|
+
discount: undefined,
|
|
369
|
+
discounted_price: undefined,
|
|
370
|
+
};
|
|
371
|
+
});
|
|
372
|
+
|
|
363
373
|
export const OrderEnrollmentFactory = factory((): OrderEnrollment => {
|
|
364
374
|
return {
|
|
365
375
|
id: faker.string.uuid(),
|
|
@@ -492,5 +502,6 @@ export const SaleTunnelContextFactory = factory(
|
|
|
492
502
|
unregisterSubmitCallback: noop,
|
|
493
503
|
runSubmitCallbacks: () => new Promise((resolve) => resolve()),
|
|
494
504
|
nextStep: noop,
|
|
505
|
+
setVoucherCode: noop,
|
|
495
506
|
}),
|
|
496
507
|
);
|