richie-education 3.1.3-dev12 → 3.1.3-dev15
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/components/SaleTunnel/SaleTunnelInformation/index.tsx +4 -2
- package/js/components/SaleTunnel/index.spec.tsx +72 -59
- package/js/types/Joanie.ts +9 -4
- package/js/utils/test/factories/joanie.ts +11 -8
- package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/CourseProductItemFooter/index.tsx +3 -7
- package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/index.spec.tsx +20 -14
- package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/index.stories.tsx +4 -2
- package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/index.tsx +16 -13
- package/package.json +1 -1
|
@@ -8,6 +8,7 @@ import useOpenEdxProfile from 'hooks/useOpenEdxProfile';
|
|
|
8
8
|
import { usePaymentSchedule } from 'hooks/usePaymentSchedule';
|
|
9
9
|
import { Spinner } from 'components/Spinner';
|
|
10
10
|
import WithdrawRightCheckbox from 'components/SaleTunnel/WithdrawRightCheckbox';
|
|
11
|
+
import { ProductType } from 'types/Joanie';
|
|
11
12
|
|
|
12
13
|
const messages = defineMessages({
|
|
13
14
|
title: {
|
|
@@ -54,6 +55,7 @@ const messages = defineMessages({
|
|
|
54
55
|
});
|
|
55
56
|
|
|
56
57
|
export const SaleTunnelInformation = () => {
|
|
58
|
+
const { product } = useSaleTunnelContext();
|
|
57
59
|
return (
|
|
58
60
|
<div className="sale-tunnel__main__column sale-tunnel__information">
|
|
59
61
|
<div>
|
|
@@ -70,7 +72,7 @@ export const SaleTunnelInformation = () => {
|
|
|
70
72
|
</div>
|
|
71
73
|
</div>
|
|
72
74
|
<div>
|
|
73
|
-
<PaymentScheduleBlock />
|
|
75
|
+
{product.type === ProductType.CREDENTIAL && <PaymentScheduleBlock />}
|
|
74
76
|
<Total />
|
|
75
77
|
<WithdrawRightCheckbox />
|
|
76
78
|
</div>
|
|
@@ -108,7 +110,7 @@ const Total = () => {
|
|
|
108
110
|
</div>
|
|
109
111
|
<div className="block-title">
|
|
110
112
|
<FormattedNumber
|
|
111
|
-
value={offer?.discounted_price || product.price}
|
|
113
|
+
value={offer?.rules.discounted_price || product.price}
|
|
112
114
|
style="currency"
|
|
113
115
|
currency={product.price_currency}
|
|
114
116
|
/>
|
|
@@ -182,7 +182,9 @@ describe.each([
|
|
|
182
182
|
nbApiCalls += 1; // useProductOrder call.
|
|
183
183
|
nbApiCalls += 1; // get user account call.
|
|
184
184
|
nbApiCalls += 1; // get user preferences call.
|
|
185
|
-
|
|
185
|
+
if (product.type === ProductType.CREDENTIAL) {
|
|
186
|
+
nbApiCalls += 1; // product payment-schedule call
|
|
187
|
+
}
|
|
186
188
|
await waitFor(() => expect(fetchMock.calls()).toHaveLength(nbApiCalls));
|
|
187
189
|
|
|
188
190
|
const user = userEvent.setup({ delay: null });
|
|
@@ -269,7 +271,9 @@ describe.each([
|
|
|
269
271
|
nbApiCalls += 1; // useProductOrder get order with filters
|
|
270
272
|
nbApiCalls += 1; // get user account call.
|
|
271
273
|
nbApiCalls += 1; // get user preferences call.
|
|
272
|
-
|
|
274
|
+
if (product.type === ProductType.CREDENTIAL) {
|
|
275
|
+
nbApiCalls += 1; // get product payment schedule.
|
|
276
|
+
}
|
|
273
277
|
await waitFor(() => expect(fetchMock.calls()).toHaveLength(nbApiCalls));
|
|
274
278
|
|
|
275
279
|
const user = userEvent.setup({ delay: null });
|
|
@@ -403,36 +407,41 @@ describe.each([
|
|
|
403
407
|
queryOptions: { client: createTestQueryClient({ user: richieUser }) },
|
|
404
408
|
});
|
|
405
409
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
+
if (product.type === ProductType.CREDENTIAL) {
|
|
411
|
+
await screen.findByRole('heading', {
|
|
412
|
+
level: 4,
|
|
413
|
+
name: 'Payment schedule',
|
|
414
|
+
});
|
|
410
415
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
416
|
+
const scheduleTable = screen.getByRole('table');
|
|
417
|
+
const scheduleTableRows = within(scheduleTable).getAllByRole('row');
|
|
418
|
+
expect(scheduleTableRows).toHaveLength(schedule.length);
|
|
414
419
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
420
|
+
scheduleTableRows.forEach((row, index) => {
|
|
421
|
+
const installment = schedule[index];
|
|
422
|
+
// A first column should show the installment index
|
|
423
|
+
within(row).getByRole('cell', {
|
|
424
|
+
name: (index + 1).toString(),
|
|
425
|
+
});
|
|
426
|
+
// A 2nd column should show the installment amount
|
|
427
|
+
within(row).getByRole('cell', {
|
|
428
|
+
name: formatPrice(installment.amount, installment.currency),
|
|
429
|
+
});
|
|
430
|
+
// A 3rd column should show the installment withdraw date
|
|
431
|
+
within(row).getByRole('cell', {
|
|
432
|
+
name: `Withdrawn on ${intl.formatDate(installment.due_date, {
|
|
433
|
+
...DEFAULT_DATE_FORMAT,
|
|
434
|
+
})}`,
|
|
435
|
+
});
|
|
436
|
+
// A 4th column should show the installment state
|
|
437
|
+
within(row).getByRole('cell', {
|
|
438
|
+
name: StringHelper.capitalizeFirst(installment.state.replace('_', ' '))!,
|
|
439
|
+
});
|
|
434
440
|
});
|
|
435
|
-
}
|
|
441
|
+
} else {
|
|
442
|
+
expect(screen.queryByRole('heading', { level: 4, name: 'Payment schedule' })).toBeNull();
|
|
443
|
+
expect(screen.queryByRole('table')).toBeNull();
|
|
444
|
+
}
|
|
436
445
|
|
|
437
446
|
const $totalAmount = screen.getByTestId('sale-tunnel__total__amount');
|
|
438
447
|
expect($totalAmount).toHaveTextContent(
|
|
@@ -449,8 +458,10 @@ describe.each([
|
|
|
449
458
|
price: 840,
|
|
450
459
|
price_currency: 'EUR',
|
|
451
460
|
}).one(),
|
|
452
|
-
|
|
453
|
-
|
|
461
|
+
rules: {
|
|
462
|
+
discounted_price: 800,
|
|
463
|
+
discount_rate: 0.3,
|
|
464
|
+
},
|
|
454
465
|
}).one();
|
|
455
466
|
const { product } = offer;
|
|
456
467
|
|
|
@@ -468,41 +479,43 @@ describe.each([
|
|
|
468
479
|
queryOptions: { client: createTestQueryClient({ user: richieUser }) },
|
|
469
480
|
});
|
|
470
481
|
|
|
471
|
-
|
|
472
|
-
level: 4,
|
|
473
|
-
name: 'Payment schedule',
|
|
474
|
-
});
|
|
482
|
+
if (product.type === ProductType.CREDENTIAL) {
|
|
483
|
+
await screen.findByRole('heading', { level: 4, name: 'Payment schedule' });
|
|
475
484
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
485
|
+
const scheduleTable = screen.getByRole('table');
|
|
486
|
+
const scheduleTableRows = within(scheduleTable).getAllByRole('row');
|
|
487
|
+
expect(scheduleTableRows).toHaveLength(schedule.length);
|
|
479
488
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
489
|
+
scheduleTableRows.forEach((row, index) => {
|
|
490
|
+
const installment = schedule[index];
|
|
491
|
+
// A first column should show the installment index
|
|
492
|
+
within(row).getByRole('cell', {
|
|
493
|
+
name: (index + 1).toString(),
|
|
494
|
+
});
|
|
495
|
+
// A 2nd column should show the installment amount
|
|
496
|
+
within(row).getByRole('cell', {
|
|
497
|
+
name: formatPrice(installment.amount, installment.currency),
|
|
498
|
+
});
|
|
499
|
+
// A 3rd column should show the installment withdraw date
|
|
500
|
+
within(row).getByRole('cell', {
|
|
501
|
+
name: `Withdrawn on ${intl.formatDate(installment.due_date, {
|
|
502
|
+
...DEFAULT_DATE_FORMAT,
|
|
503
|
+
})}`,
|
|
504
|
+
});
|
|
505
|
+
// A 4th column should show the installment state
|
|
506
|
+
within(row).getByRole('cell', {
|
|
507
|
+
name: StringHelper.capitalizeFirst(installment.state.replace('_', ' '))!,
|
|
508
|
+
});
|
|
499
509
|
});
|
|
500
|
-
}
|
|
510
|
+
} else {
|
|
511
|
+
expect(screen.queryByRole('heading', { level: 4, name: 'Payment schedule' })).toBeNull();
|
|
512
|
+
expect(screen.queryByRole('table')).toBeNull();
|
|
513
|
+
}
|
|
501
514
|
|
|
502
515
|
const $totalAmount = screen.getByTestId('sale-tunnel__total__amount');
|
|
503
516
|
expect($totalAmount).toHaveTextContent(
|
|
504
517
|
'Total' +
|
|
505
|
-
formatPrice(offer!.discounted_price!, product.price_currency).replace(
|
|
518
|
+
formatPrice(offer!.rules.discounted_price!, product.price_currency).replace(
|
|
506
519
|
/(\u202F|\u00a0)/g,
|
|
507
520
|
' ',
|
|
508
521
|
),
|
package/js/types/Joanie.ts
CHANGED
|
@@ -182,16 +182,21 @@ export interface OfferLight {
|
|
|
182
182
|
created_on: string;
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
-
export interface
|
|
186
|
-
is_withdrawable: boolean;
|
|
185
|
+
export interface OfferRule {
|
|
187
186
|
discounted_price: Nullable<number>;
|
|
188
187
|
discount_rate: Nullable<number>;
|
|
189
188
|
discount_amount: Nullable<number>;
|
|
190
189
|
discount_start: Nullable<string>;
|
|
191
190
|
discount_end: Nullable<string>;
|
|
192
191
|
description: Nullable<string>;
|
|
193
|
-
|
|
194
|
-
|
|
192
|
+
nb_available_seats: Nullable<number>;
|
|
193
|
+
has_seat_limit: boolean;
|
|
194
|
+
has_seats_left: boolean;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface Offer extends OfferLight {
|
|
198
|
+
is_withdrawable: boolean;
|
|
199
|
+
rules: OfferRule;
|
|
195
200
|
}
|
|
196
201
|
export function isOffer(entity: CourseListItem | OfferLight | RichieCourse): entity is OfferLight {
|
|
197
202
|
return 'course' in entity && 'product' in entity;
|
|
@@ -317,14 +317,17 @@ export const OfferFactory = factory((): Offer => {
|
|
|
317
317
|
product: ProductFactory().one(),
|
|
318
318
|
organizations: OrganizationFactory().many(1),
|
|
319
319
|
is_withdrawable: true,
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
320
|
+
rules: {
|
|
321
|
+
discounted_price: null,
|
|
322
|
+
discount_rate: null,
|
|
323
|
+
discount_amount: null,
|
|
324
|
+
discount_start: null,
|
|
325
|
+
discount_end: null,
|
|
326
|
+
description: null,
|
|
327
|
+
nb_available_seats: null,
|
|
328
|
+
has_seat_limit: false,
|
|
329
|
+
has_seats_left: true,
|
|
330
|
+
},
|
|
328
331
|
};
|
|
329
332
|
});
|
|
330
333
|
|
|
@@ -29,11 +29,7 @@ interface CourseProductItemFooterProps {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
const CourseProductItemFooter = ({ course, offer, canPurchase }: CourseProductItemFooterProps) => {
|
|
32
|
-
|
|
33
|
-
const { seats, nb_seats_available } = offer;
|
|
34
|
-
const hasSeatsLimit = seats && nb_seats_available !== undefined;
|
|
35
|
-
const hasNoSeatsAvailable = hasSeatsLimit && nb_seats_available === 0;
|
|
36
|
-
if (hasNoSeatsAvailable)
|
|
32
|
+
if (!offer.rules.has_seats_left)
|
|
37
33
|
return (
|
|
38
34
|
<p className="product-widget__footer__message">
|
|
39
35
|
<FormattedMessage {...messages.noSeatsAvailable} />
|
|
@@ -50,11 +46,11 @@ const CourseProductItemFooter = ({ course, offer, canPurchase }: CourseProductIt
|
|
|
50
46
|
disabled={!canPurchase}
|
|
51
47
|
buttonProps={{ fullWidth: true }}
|
|
52
48
|
/>
|
|
53
|
-
{
|
|
49
|
+
{offer.rules.has_seat_limit && (
|
|
54
50
|
<p className="product-widget__footer__message">
|
|
55
51
|
<FormattedMessage
|
|
56
52
|
{...messages.nbSeatsAvailable}
|
|
57
|
-
values={{ nb: offer.
|
|
53
|
+
values={{ nb: offer.rules.nb_available_seats }}
|
|
58
54
|
/>
|
|
59
55
|
</p>
|
|
60
56
|
)}
|
|
@@ -153,11 +153,13 @@ describe('CourseProductItem', () => {
|
|
|
153
153
|
price: 840,
|
|
154
154
|
price_currency: 'EUR',
|
|
155
155
|
}).one(),
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
156
|
+
rules: {
|
|
157
|
+
discounted_price: 800,
|
|
158
|
+
discount_rate: 0.3,
|
|
159
|
+
description: 'Year 2023 discount',
|
|
160
|
+
discount_start: new Date('2023-01-01T00:00:00Z').toISOString(),
|
|
161
|
+
discount_end: new Date('2023-12-31T23:59:59Z').toISOString(),
|
|
162
|
+
},
|
|
161
163
|
}).one();
|
|
162
164
|
const { product } = offer;
|
|
163
165
|
fetchMock.get(`https://joanie.endpoint/api/v1.0/courses/00000/products/${product.id}/`, offer);
|
|
@@ -186,7 +188,7 @@ describe('CourseProductItem', () => {
|
|
|
186
188
|
const discountedPriceLabel = screen.getByText('Discounted price:');
|
|
187
189
|
expect(discountedPriceLabel.classList.contains('offscreen')).toBe(true);
|
|
188
190
|
const discountedPrice = screen.getByText(
|
|
189
|
-
priceFormatter(product.price_currency, offer.discounted_price!).replace(
|
|
191
|
+
priceFormatter(product.price_currency, offer.rules.discounted_price!).replace(
|
|
190
192
|
/(\u202F|\u00a0)/g,
|
|
191
193
|
' ',
|
|
192
194
|
),
|
|
@@ -211,11 +213,13 @@ describe('CourseProductItem', () => {
|
|
|
211
213
|
price: 840,
|
|
212
214
|
price_currency: 'EUR',
|
|
213
215
|
}).one(),
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
216
|
+
rules: {
|
|
217
|
+
discounted_price: 800,
|
|
218
|
+
discount_amount: 40,
|
|
219
|
+
description: 'Year 2023 discount',
|
|
220
|
+
discount_start: new Date('2023-01-01T00:00:00Z').toISOString(),
|
|
221
|
+
discount_end: new Date('2023-12-31T23:59:59Z').toISOString(),
|
|
222
|
+
},
|
|
219
223
|
}).one();
|
|
220
224
|
const { product } = offer;
|
|
221
225
|
fetchMock.get(`https://joanie.endpoint/api/v1.0/courses/00000/products/${product.id}/`, offer);
|
|
@@ -244,7 +248,7 @@ describe('CourseProductItem', () => {
|
|
|
244
248
|
const discountedPriceLabel = screen.getByText('Discounted price:');
|
|
245
249
|
expect(discountedPriceLabel.classList.contains('offscreen')).toBe(true);
|
|
246
250
|
const discountedPrice = screen.getByText(
|
|
247
|
-
priceFormatter(product.price_currency, offer.discounted_price!).replace(
|
|
251
|
+
priceFormatter(product.price_currency, offer.rules.discounted_price!).replace(
|
|
248
252
|
/(\u202F|\u00a0)/g,
|
|
249
253
|
' ',
|
|
250
254
|
),
|
|
@@ -774,8 +778,10 @@ describe('CourseProductItem', () => {
|
|
|
774
778
|
|
|
775
779
|
it('renders a warning message that tells that no seats are left', async () => {
|
|
776
780
|
const offer = OfferFactory({
|
|
777
|
-
|
|
778
|
-
|
|
781
|
+
rules: {
|
|
782
|
+
nb_available_seats: 0,
|
|
783
|
+
has_seats_left: false,
|
|
784
|
+
},
|
|
779
785
|
}).one();
|
|
780
786
|
const { product } = offer;
|
|
781
787
|
const order = CredentialOrderFactory({
|
|
@@ -27,8 +27,10 @@ const render = (args: CourseProductItemProps, options?: Maybe<{ order: Credentia
|
|
|
27
27
|
price: 840,
|
|
28
28
|
price_currency: 'EUR',
|
|
29
29
|
}).one(),
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
rules: {
|
|
31
|
+
discounted_price: 800,
|
|
32
|
+
discount_rate: 0.3,
|
|
33
|
+
},
|
|
32
34
|
}).one(),
|
|
33
35
|
{ overwriteRoutes: true },
|
|
34
36
|
);
|
|
@@ -103,7 +103,7 @@ const Header = ({ product, order, offer, hasPurchased, canPurchase, compact }: H
|
|
|
103
103
|
return null;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
if (offer.discounted_price) {
|
|
106
|
+
if (offer.rules.discounted_price != null) {
|
|
107
107
|
return (
|
|
108
108
|
<>
|
|
109
109
|
<span id="original-price" className="offscreen">
|
|
@@ -122,7 +122,7 @@ const Header = ({ product, order, offer, hasPurchased, canPurchase, compact }: H
|
|
|
122
122
|
<ins aria-describedby="discount-price" className="product-widget__price-discount">
|
|
123
123
|
<FormattedNumber
|
|
124
124
|
currency={product.price_currency}
|
|
125
|
-
value={offer.discounted_price}
|
|
125
|
+
value={offer.rules.discounted_price}
|
|
126
126
|
style="currency"
|
|
127
127
|
/>
|
|
128
128
|
</ins>
|
|
@@ -133,7 +133,7 @@ const Header = ({ product, order, offer, hasPurchased, canPurchase, compact }: H
|
|
|
133
133
|
return (
|
|
134
134
|
<FormattedNumber currency={product.price_currency} value={product.price} style="currency" />
|
|
135
135
|
);
|
|
136
|
-
}, [canPurchase, offer.discounted_price, product.price]);
|
|
136
|
+
}, [canPurchase, offer.rules.discounted_price, product.price]);
|
|
137
137
|
|
|
138
138
|
return (
|
|
139
139
|
<header className="product-widget__header">
|
|
@@ -144,37 +144,40 @@ const Header = ({ product, order, offer, hasPurchased, canPurchase, compact }: H
|
|
|
144
144
|
{hasPurchased && <FormattedMessage {...messages.purchased} />}
|
|
145
145
|
{displayPrice}
|
|
146
146
|
</strong>
|
|
147
|
-
{offer?.description && (
|
|
148
|
-
<p className="product-widget__header-description">{offer.description}</p>
|
|
147
|
+
{offer?.rules.description && (
|
|
148
|
+
<p className="product-widget__header-description">{offer.rules.description}</p>
|
|
149
149
|
)}
|
|
150
|
-
{offer?.discounted_price && (
|
|
150
|
+
{offer?.rules.discounted_price && (
|
|
151
151
|
<p className="product-widget__header-discount">
|
|
152
|
-
{offer.discount_rate ? (
|
|
152
|
+
{offer.rules.discount_rate ? (
|
|
153
153
|
<span className="product-widget__header-discount-rate">
|
|
154
|
-
<FormattedNumber value={-offer.discount_rate} style="percent" />
|
|
154
|
+
<FormattedNumber value={-offer.rules.discount_rate} style="percent" />
|
|
155
155
|
</span>
|
|
156
156
|
) : (
|
|
157
157
|
<span className="product-widget__header-discount-amount">
|
|
158
158
|
<FormattedNumber
|
|
159
159
|
currency={product.price_currency}
|
|
160
|
-
value={-offer.discount_amount!}
|
|
160
|
+
value={-offer.rules.discount_amount!}
|
|
161
161
|
style="currency"
|
|
162
162
|
/>
|
|
163
163
|
</span>
|
|
164
164
|
)}
|
|
165
|
-
{offer.discount_start && (
|
|
165
|
+
{offer.rules.discount_start && (
|
|
166
166
|
<span className="product-widget__header-discount-date">
|
|
167
167
|
|
|
168
168
|
<FormattedMessage
|
|
169
169
|
{...messages.from}
|
|
170
|
-
values={{ from: formatDate(offer.discount_start) }}
|
|
170
|
+
values={{ from: formatDate(offer.rules.discount_start) }}
|
|
171
171
|
/>
|
|
172
172
|
</span>
|
|
173
173
|
)}
|
|
174
|
-
{offer.discount_end && (
|
|
174
|
+
{offer.rules.discount_end && (
|
|
175
175
|
<span className="product-widget__header-discount-date">
|
|
176
176
|
|
|
177
|
-
<FormattedMessage
|
|
177
|
+
<FormattedMessage
|
|
178
|
+
{...messages.to}
|
|
179
|
+
values={{ to: formatDate(offer.rules.discount_end) }}
|
|
180
|
+
/>
|
|
178
181
|
</span>
|
|
179
182
|
)}
|
|
180
183
|
</p>
|