richie-education 3.1.3-dev3 → 3.1.3-dev8
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/PurchaseButton/index.tsx +3 -3
- package/js/components/SaleTunnel/CredentialSaleTunnel/index.tsx +1 -3
- package/js/components/SaleTunnel/GenericSaleTunnel.tsx +10 -1
- package/js/components/SaleTunnel/SaleTunnelInformation/index.tsx +2 -2
- package/js/components/SaleTunnel/SubscriptionButton/index.tsx +1 -2
- package/js/components/SaleTunnel/index.credential.spec.tsx +5 -19
- package/js/components/SaleTunnel/index.spec.tsx +75 -0
- package/js/components/SaleTunnel/index.stories.tsx +0 -1
- package/js/components/SaleTunnel/index.tsx +2 -2
- package/js/types/Joanie.ts +8 -10
- package/js/utils/ProductHelper/index.ts +1 -5
- package/js/utils/test/factories/joanie.ts +9 -22
- package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/CourseProductItemFooter/index.tsx +18 -29
- package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/_styles.scss +27 -7
- package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/components/CourseProductCourseRuns/CourseRunList.tsx +1 -1
- package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/index.spec.tsx +125 -85
- package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/index.stories.tsx +8 -1
- package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/index.tsx +115 -21
- package/package.json +1 -1
|
@@ -42,7 +42,7 @@ const messages = defineMessages({
|
|
|
42
42
|
|
|
43
43
|
interface PurchaseButtonPropsBase {
|
|
44
44
|
product: Joanie.CredentialProduct | Joanie.CertificateProduct;
|
|
45
|
-
|
|
45
|
+
courseProductRelation?: Joanie.CourseProductRelation;
|
|
46
46
|
isWithdrawable: boolean;
|
|
47
47
|
disabled?: boolean;
|
|
48
48
|
className?: string;
|
|
@@ -66,8 +66,8 @@ interface CertificatePurchaseButtonProps extends PurchaseButtonPropsBase {
|
|
|
66
66
|
const PurchaseButton = ({
|
|
67
67
|
product,
|
|
68
68
|
course,
|
|
69
|
+
courseProductRelation,
|
|
69
70
|
enrollment,
|
|
70
|
-
orderGroup,
|
|
71
71
|
isWithdrawable,
|
|
72
72
|
organizations,
|
|
73
73
|
disabled = false,
|
|
@@ -140,8 +140,8 @@ const PurchaseButton = ({
|
|
|
140
140
|
{...saleTunnelModal}
|
|
141
141
|
product={product}
|
|
142
142
|
organizations={organizations}
|
|
143
|
+
courseProductRelation={courseProductRelation}
|
|
143
144
|
enrollment={enrollment}
|
|
144
|
-
orderGroup={orderGroup}
|
|
145
145
|
course={course}
|
|
146
146
|
isWithdrawable={isWithdrawable}
|
|
147
147
|
onFinish={onFinish}
|
|
@@ -19,9 +19,7 @@ export const CredentialSaleTunnel = (props: CredentialSaleTunnelProps) => {
|
|
|
19
19
|
);
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
-
const CredentialPaymentButton = ({
|
|
23
|
-
course,
|
|
24
|
-
}: Pick<CredentialSaleTunnelProps, 'course' | 'orderGroup'>) => {
|
|
22
|
+
const CredentialPaymentButton = ({ course }: Pick<CredentialSaleTunnelProps, 'course'>) => {
|
|
25
23
|
return (
|
|
26
24
|
<SubscriptionButton
|
|
27
25
|
buildOrderPayload={(payload) => ({
|
|
@@ -10,7 +10,14 @@ import {
|
|
|
10
10
|
} from 'react';
|
|
11
11
|
import { SaleTunnelSponsors } from 'components/SaleTunnel/Sponsors/SaleTunnelSponsors';
|
|
12
12
|
import { SaleTunnelProps } from 'components/SaleTunnel/index';
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
Address,
|
|
15
|
+
CourseProductRelation,
|
|
16
|
+
CreditCard,
|
|
17
|
+
Order,
|
|
18
|
+
OrderState,
|
|
19
|
+
Product,
|
|
20
|
+
} from 'types/Joanie';
|
|
14
21
|
import useProductOrder from 'hooks/useProductOrder';
|
|
15
22
|
import { SaleTunnelSuccess } from 'components/SaleTunnel/SaleTunnelSuccess';
|
|
16
23
|
import WebAnalyticsAPIHandler from 'api/web-analytics';
|
|
@@ -26,6 +33,7 @@ export interface SaleTunnelContextType {
|
|
|
26
33
|
order?: Order;
|
|
27
34
|
product: Product;
|
|
28
35
|
webAnalyticsEventKey: string;
|
|
36
|
+
relation?: CourseProductRelation;
|
|
29
37
|
|
|
30
38
|
// internal
|
|
31
39
|
step: SaleTunnelStep;
|
|
@@ -113,6 +121,7 @@ export const GenericSaleTunnel = (props: GenericSaleTunnelProps) => {
|
|
|
113
121
|
webAnalyticsEventKey: props.eventKey,
|
|
114
122
|
order,
|
|
115
123
|
product: props.product,
|
|
124
|
+
relation: props.courseProductRelation,
|
|
116
125
|
props,
|
|
117
126
|
billingAddress,
|
|
118
127
|
setBillingAddress,
|
|
@@ -99,7 +99,7 @@ const Email = () => {
|
|
|
99
99
|
};
|
|
100
100
|
|
|
101
101
|
const Total = () => {
|
|
102
|
-
const { product } = useSaleTunnelContext();
|
|
102
|
+
const { product, relation } = useSaleTunnelContext();
|
|
103
103
|
return (
|
|
104
104
|
<div className="sale-tunnel__total">
|
|
105
105
|
<div className="sale-tunnel__total__amount mt-t" data-testid="sale-tunnel__total__amount">
|
|
@@ -108,7 +108,7 @@ const Total = () => {
|
|
|
108
108
|
</div>
|
|
109
109
|
<div className="block-title">
|
|
110
110
|
<FormattedNumber
|
|
111
|
-
value={product.price}
|
|
111
|
+
value={relation?.discounted_price || product.price}
|
|
112
112
|
style="currency"
|
|
113
113
|
currency={product.price_currency}
|
|
114
114
|
/>
|
|
@@ -72,7 +72,7 @@ interface Props {
|
|
|
72
72
|
buildOrderPayload: (
|
|
73
73
|
payload: Pick<
|
|
74
74
|
OrderCreationPayload,
|
|
75
|
-
'product_id' | 'billing_address' | '
|
|
75
|
+
'product_id' | 'billing_address' | 'has_waived_withdrawal_right'
|
|
76
76
|
>,
|
|
77
77
|
) => OrderCreationPayload;
|
|
78
78
|
}
|
|
@@ -124,7 +124,6 @@ const SubscriptionButton = ({ buildOrderPayload }: Props) => {
|
|
|
124
124
|
const payload = buildOrderPayload({
|
|
125
125
|
product_id: product.id,
|
|
126
126
|
billing_address: billingAddress!,
|
|
127
|
-
order_group_id: saleTunnelProps.orderGroup?.id,
|
|
128
127
|
has_waived_withdrawal_right: hasWaivedWithdrawalRight,
|
|
129
128
|
});
|
|
130
129
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import fetchMock from 'fetch-mock';
|
|
2
|
-
import {
|
|
2
|
+
import { screen } from '@testing-library/react';
|
|
3
3
|
import queryString from 'query-string';
|
|
4
4
|
import {
|
|
5
5
|
RichieContextFactory as mockRichieContextFactory,
|
|
@@ -11,11 +11,9 @@ import {
|
|
|
11
11
|
AddressFactory,
|
|
12
12
|
CredentialOrderFactory,
|
|
13
13
|
CredentialProductFactory,
|
|
14
|
-
OrderGroupFactory,
|
|
15
14
|
} from 'utils/test/factories/joanie';
|
|
16
15
|
import type * as Joanie from 'types/Joanie';
|
|
17
|
-
import {
|
|
18
|
-
import { NOT_CANCELED_ORDER_STATES, OrderCredentialCreationPayload } from 'types/Joanie';
|
|
16
|
+
import { NOT_CANCELED_ORDER_STATES } from 'types/Joanie';
|
|
19
17
|
import { SaleTunnel, SaleTunnelProps } from 'components/SaleTunnel/index';
|
|
20
18
|
import { render } from 'utils/test/render';
|
|
21
19
|
import { getAddressLabel } from 'components/SaleTunnel/AddressSelector';
|
|
@@ -83,11 +81,9 @@ describe('SaleTunnel / Credential', () => {
|
|
|
83
81
|
it('should create an order with an order group', async () => {
|
|
84
82
|
const course = PacedCourseFactory().one();
|
|
85
83
|
const product = CredentialProductFactory().one();
|
|
86
|
-
const orderGroup = OrderGroupFactory().one();
|
|
87
84
|
const billingAddress: Joanie.Address = AddressFactory({ is_main: true }).one();
|
|
88
85
|
|
|
89
|
-
|
|
90
|
-
const order = CredentialOrderFactory({ order_group_id: orderGroup.id }).one();
|
|
86
|
+
const order = CredentialOrderFactory().one();
|
|
91
87
|
const orderQueryParameters = {
|
|
92
88
|
course_code: course.code,
|
|
93
89
|
product_id: product.id,
|
|
@@ -100,15 +96,12 @@ describe('SaleTunnel / Credential', () => {
|
|
|
100
96
|
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
|
|
101
97
|
[],
|
|
102
98
|
)
|
|
103
|
-
.post('https://joanie.endpoint/api/v1.0/orders/',
|
|
104
|
-
createOrderPayload = JSON.parse(body as any);
|
|
105
|
-
return order;
|
|
106
|
-
})
|
|
99
|
+
.post('https://joanie.endpoint/api/v1.0/orders/', order)
|
|
107
100
|
.get('https://joanie.endpoint/api/v1.0/addresses/', [billingAddress], {
|
|
108
101
|
overwriteRoutes: true,
|
|
109
102
|
});
|
|
110
103
|
|
|
111
|
-
render(<Wrapper product={product} course={course}
|
|
104
|
+
render(<Wrapper product={product} course={course} />, {
|
|
112
105
|
queryOptions: { client: createTestQueryClient({ user: richieUser }) },
|
|
113
106
|
});
|
|
114
107
|
|
|
@@ -121,12 +114,5 @@ describe('SaleTunnel / Credential', () => {
|
|
|
121
114
|
|
|
122
115
|
// - Payment button should not be disabled.
|
|
123
116
|
expect($button.disabled).toBe(false);
|
|
124
|
-
|
|
125
|
-
// - User clicks on pay button
|
|
126
|
-
await act(async () => {
|
|
127
|
-
fireEvent.click($button);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
await waitFor(() => expect(createOrderPayload?.order_group_id).toEqual(orderGroup.id));
|
|
131
117
|
});
|
|
132
118
|
});
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
AddressFactory,
|
|
16
16
|
CertificateOrderFactory,
|
|
17
17
|
CertificateProductFactory,
|
|
18
|
+
CourseProductRelationFactory,
|
|
18
19
|
CredentialOrderFactory,
|
|
19
20
|
CredentialProductFactory,
|
|
20
21
|
CreditCardFactory,
|
|
@@ -432,6 +433,80 @@ describe.each([
|
|
|
432
433
|
name: StringHelper.capitalizeFirst(installment.state.replace('_', ' '))!,
|
|
433
434
|
});
|
|
434
435
|
});
|
|
436
|
+
|
|
437
|
+
const $totalAmount = screen.getByTestId('sale-tunnel__total__amount');
|
|
438
|
+
expect($totalAmount).toHaveTextContent(
|
|
439
|
+
'Total' + formatPrice(product.price, product.price_currency).replace(/(\u202F|\u00a0)/g, ' '),
|
|
440
|
+
);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it('should show the product payment schedule with discounted price', async () => {
|
|
444
|
+
const intl = createIntl({ locale: 'en' });
|
|
445
|
+
const schedule = PaymentInstallmentFactory().many(2);
|
|
446
|
+
|
|
447
|
+
const relation = CourseProductRelationFactory({
|
|
448
|
+
product: ProductFactory({
|
|
449
|
+
price: 840,
|
|
450
|
+
price_currency: 'EUR',
|
|
451
|
+
}).one(),
|
|
452
|
+
discounted_price: 800,
|
|
453
|
+
discount_rate: 0.3,
|
|
454
|
+
}).one();
|
|
455
|
+
const { product } = relation;
|
|
456
|
+
|
|
457
|
+
fetchMock
|
|
458
|
+
.get(
|
|
459
|
+
`https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
|
|
460
|
+
[],
|
|
461
|
+
)
|
|
462
|
+
.get(
|
|
463
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
|
|
464
|
+
schedule,
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
render(<Wrapper product={product} courseProductRelation={relation} isWithdrawable={true} />, {
|
|
468
|
+
queryOptions: { client: createTestQueryClient({ user: richieUser }) },
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
await screen.findByRole('heading', {
|
|
472
|
+
level: 4,
|
|
473
|
+
name: 'Payment schedule',
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
const scheduleTable = screen.getByRole('table');
|
|
477
|
+
const scheduleTableRows = within(scheduleTable).getAllByRole('row');
|
|
478
|
+
expect(scheduleTableRows).toHaveLength(schedule.length);
|
|
479
|
+
|
|
480
|
+
scheduleTableRows.forEach((row, index) => {
|
|
481
|
+
const installment = schedule[index];
|
|
482
|
+
// A first column should show the installment index
|
|
483
|
+
within(row).getByRole('cell', {
|
|
484
|
+
name: (index + 1).toString(),
|
|
485
|
+
});
|
|
486
|
+
// A 2nd column should show the installment amount
|
|
487
|
+
within(row).getByRole('cell', {
|
|
488
|
+
name: formatPrice(installment.amount, installment.currency),
|
|
489
|
+
});
|
|
490
|
+
// A 3rd column should show the installment withdraw date
|
|
491
|
+
within(row).getByRole('cell', {
|
|
492
|
+
name: `Withdrawn on ${intl.formatDate(installment.due_date, {
|
|
493
|
+
...DEFAULT_DATE_FORMAT,
|
|
494
|
+
})}`,
|
|
495
|
+
});
|
|
496
|
+
// A 4th column should show the installment state
|
|
497
|
+
within(row).getByRole('cell', {
|
|
498
|
+
name: StringHelper.capitalizeFirst(installment.state.replace('_', ' '))!,
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
const $totalAmount = screen.getByTestId('sale-tunnel__total__amount');
|
|
503
|
+
expect($totalAmount).toHaveTextContent(
|
|
504
|
+
'Total' +
|
|
505
|
+
formatPrice(relation!.discounted_price!, product.price_currency).replace(
|
|
506
|
+
/(\u202F|\u00a0)/g,
|
|
507
|
+
' ',
|
|
508
|
+
),
|
|
509
|
+
);
|
|
435
510
|
});
|
|
436
511
|
|
|
437
512
|
it('should show a walkthrough to explain the subscription process', async () => {
|
|
@@ -2,10 +2,10 @@ import { ModalProps } from '@openfun/cunningham-react';
|
|
|
2
2
|
import {
|
|
3
3
|
CertificateProduct,
|
|
4
4
|
CourseLight,
|
|
5
|
+
CourseProductRelation,
|
|
5
6
|
CredentialProduct,
|
|
6
7
|
Enrollment,
|
|
7
8
|
Order,
|
|
8
|
-
OrderGroup,
|
|
9
9
|
Organization,
|
|
10
10
|
Product,
|
|
11
11
|
ProductType,
|
|
@@ -16,11 +16,11 @@ import { PacedCourse } from 'types';
|
|
|
16
16
|
|
|
17
17
|
export interface SaleTunnelProps extends Pick<ModalProps, 'isOpen' | 'onClose'> {
|
|
18
18
|
product: Product;
|
|
19
|
+
courseProductRelation?: CourseProductRelation;
|
|
19
20
|
organizations?: Organization[];
|
|
20
21
|
isWithdrawable: boolean;
|
|
21
22
|
course?: PacedCourse | CourseLight;
|
|
22
23
|
enrollment?: Enrollment;
|
|
23
|
-
orderGroup?: OrderGroup;
|
|
24
24
|
onFinish?: (order: Order) => void;
|
|
25
25
|
}
|
|
26
26
|
|
package/js/types/Joanie.ts
CHANGED
|
@@ -183,8 +183,15 @@ export interface CourseProductRelationLight {
|
|
|
183
183
|
}
|
|
184
184
|
|
|
185
185
|
export interface CourseProductRelation extends CourseProductRelationLight {
|
|
186
|
-
order_groups: OrderGroup[];
|
|
187
186
|
is_withdrawable: boolean;
|
|
187
|
+
discounted_price: Nullable<number>;
|
|
188
|
+
discount_rate: Nullable<number>;
|
|
189
|
+
discount_amount: Nullable<number>;
|
|
190
|
+
discount_start: Nullable<string>;
|
|
191
|
+
discount_end: Nullable<string>;
|
|
192
|
+
description: Nullable<string>;
|
|
193
|
+
nb_seats_available: Nullable<number>;
|
|
194
|
+
seats: Nullable<number>;
|
|
188
195
|
}
|
|
189
196
|
export function isCourseProductRelation(
|
|
190
197
|
entity: CourseListItem | CourseProductRelationLight | RichieCourse,
|
|
@@ -325,7 +332,6 @@ export interface Order {
|
|
|
325
332
|
enrollment: Nullable<EnrollmentLight>;
|
|
326
333
|
organization_id: Organization['id'];
|
|
327
334
|
organization: Organization;
|
|
328
|
-
order_group_id?: OrderGroup['id'];
|
|
329
335
|
payment_schedule?: PaymentSchedule;
|
|
330
336
|
credit_card_id?: CreditCard['id'];
|
|
331
337
|
}
|
|
@@ -403,13 +409,6 @@ export interface CourseOrderResourceQuery extends PaginatedResourceQuery {
|
|
|
403
409
|
product_id?: Product['id'];
|
|
404
410
|
}
|
|
405
411
|
|
|
406
|
-
export interface OrderGroup {
|
|
407
|
-
id: string;
|
|
408
|
-
is_active: boolean;
|
|
409
|
-
nb_seats: number;
|
|
410
|
-
nb_available_seats: number;
|
|
411
|
-
}
|
|
412
|
-
|
|
413
412
|
export enum CreditCardBrand {
|
|
414
413
|
MASTERCARD = 'mastercard',
|
|
415
414
|
MAESTRO = 'maestro',
|
|
@@ -481,7 +480,6 @@ export interface AddressCreationPayload extends Omit<Address, 'id' | 'is_main'>
|
|
|
481
480
|
|
|
482
481
|
interface AbstractOrderProductCreationPayload {
|
|
483
482
|
product_id: Product['id'];
|
|
484
|
-
order_group_id?: OrderGroup['id'];
|
|
485
483
|
billing_address: Omit<Address, 'id' | 'is_main'>;
|
|
486
484
|
has_waived_withdrawal_right: boolean;
|
|
487
485
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { IntlShape } from 'react-intl';
|
|
2
|
-
import {
|
|
2
|
+
import { Product, TargetCourse } from 'types/Joanie';
|
|
3
3
|
import { Maybe } from 'types/utils';
|
|
4
4
|
import { IntlHelper } from 'utils/IntlHelper';
|
|
5
5
|
import * as Joanie from 'types/Joanie';
|
|
@@ -44,10 +44,6 @@ export class ProductHelper {
|
|
|
44
44
|
return IntlHelper.getLocalizedLanguages(uniqueLanguages, intl);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
static getActiveOrderGroups(courseProductRelation: CourseProductRelation) {
|
|
48
|
-
return courseProductRelation.order_groups?.filter((orderGroup) => orderGroup.is_active);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
47
|
static hasRemainingSeats(product: Maybe<Product>) {
|
|
52
48
|
if (!product) return false;
|
|
53
49
|
return typeof product?.remaining_order_count !== 'number' || product.remaining_order_count > 0;
|
|
@@ -28,7 +28,6 @@ import {
|
|
|
28
28
|
NestedCredentialOrder,
|
|
29
29
|
Order,
|
|
30
30
|
OrderEnrollment,
|
|
31
|
-
OrderGroup,
|
|
32
31
|
PaymentInstallment,
|
|
33
32
|
OrderLite,
|
|
34
33
|
OrderState,
|
|
@@ -197,7 +196,7 @@ export const CredentialProductFactory = factory((): CredentialProduct => {
|
|
|
197
196
|
created_on: faker.date.past().toISOString(),
|
|
198
197
|
title: FactoryHelper.sequence((counter) => `Certificate Product ${counter}`),
|
|
199
198
|
type: ProductType.CREDENTIAL,
|
|
200
|
-
price: faker.number.int(),
|
|
199
|
+
price: faker.number.int({ min: 1, max: 1000, multipleOf: 10 }),
|
|
201
200
|
price_currency: faker.finance.currencyCode(),
|
|
202
201
|
call_to_action: faker.lorem.words(3),
|
|
203
202
|
certificate_definition: CertificationDefinitionFactory().one(),
|
|
@@ -294,25 +293,6 @@ export const CourseLightFactory = factory((): CourseLight => {
|
|
|
294
293
|
};
|
|
295
294
|
});
|
|
296
295
|
|
|
297
|
-
export const OrderGroupFactory = factory((): OrderGroup => {
|
|
298
|
-
const seats = faker.number.int({ min: 5, max: 100 });
|
|
299
|
-
return {
|
|
300
|
-
id: faker.string.uuid(),
|
|
301
|
-
is_active: true,
|
|
302
|
-
nb_seats: seats,
|
|
303
|
-
nb_available_seats: faker.number.int({ min: 2, max: seats }),
|
|
304
|
-
};
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
export const OrderGroupFullFactory = factory((): OrderGroup => {
|
|
308
|
-
return {
|
|
309
|
-
id: faker.string.uuid(),
|
|
310
|
-
is_active: true,
|
|
311
|
-
nb_seats: faker.number.int({ min: 5, max: 100 }),
|
|
312
|
-
nb_available_seats: 0,
|
|
313
|
-
};
|
|
314
|
-
});
|
|
315
|
-
|
|
316
296
|
export const NestedCourseOrderFactory = factory((): NestedCourseOrder => {
|
|
317
297
|
return {
|
|
318
298
|
id: faker.string.uuid(),
|
|
@@ -336,8 +316,15 @@ export const CourseProductRelationFactory = factory((): CourseProductRelation =>
|
|
|
336
316
|
course: CourseFactory().one(),
|
|
337
317
|
product: ProductFactory().one(),
|
|
338
318
|
organizations: OrganizationFactory().many(1),
|
|
339
|
-
order_groups: [],
|
|
340
319
|
is_withdrawable: true,
|
|
320
|
+
discounted_price: null,
|
|
321
|
+
discount_rate: null,
|
|
322
|
+
discount_amount: null,
|
|
323
|
+
discount_start: null,
|
|
324
|
+
discount_end: null,
|
|
325
|
+
description: null,
|
|
326
|
+
seats: null,
|
|
327
|
+
nb_seats_available: null,
|
|
341
328
|
};
|
|
342
329
|
});
|
|
343
330
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { FormattedMessage, defineMessages } from 'react-intl';
|
|
2
2
|
import PurchaseButton from 'components/PurchaseButton';
|
|
3
|
-
import { CourseProductRelation, CredentialProduct
|
|
3
|
+
import { CourseProductRelation, CredentialProduct } from 'types/Joanie';
|
|
4
4
|
import { PacedCourse } from 'types';
|
|
5
5
|
|
|
6
6
|
const messages = defineMessages({
|
|
@@ -26,54 +26,43 @@ interface CourseProductItemFooterProps {
|
|
|
26
26
|
course: PacedCourse;
|
|
27
27
|
courseProductRelation: CourseProductRelation;
|
|
28
28
|
canPurchase: boolean;
|
|
29
|
-
orderGroups: OrderGroup[];
|
|
30
|
-
orderGroupsAvailable: OrderGroup[];
|
|
31
29
|
}
|
|
32
30
|
|
|
33
31
|
const CourseProductItemFooter = ({
|
|
34
32
|
course,
|
|
35
33
|
courseProductRelation,
|
|
36
|
-
orderGroups,
|
|
37
|
-
orderGroupsAvailable,
|
|
38
34
|
canPurchase,
|
|
39
35
|
}: CourseProductItemFooterProps) => {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
organizations={courseProductRelation.organizations}
|
|
46
|
-
isWithdrawable={courseProductRelation.is_withdrawable}
|
|
47
|
-
disabled={!canPurchase}
|
|
48
|
-
buttonProps={{ fullWidth: true }}
|
|
49
|
-
/>
|
|
50
|
-
);
|
|
51
|
-
}
|
|
52
|
-
if (orderGroupsAvailable.length === 0) {
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
37
|
+
const { seats, nb_seats_available } = courseProductRelation;
|
|
38
|
+
const hasSeatsLimit = seats && nb_seats_available !== undefined;
|
|
39
|
+
const hasNoSeatsAvailable = hasSeatsLimit && nb_seats_available === 0;
|
|
40
|
+
if (hasNoSeatsAvailable)
|
|
53
41
|
return (
|
|
54
42
|
<p className="product-widget__footer__message">
|
|
55
43
|
<FormattedMessage {...messages.noSeatsAvailable} />
|
|
56
44
|
</p>
|
|
57
45
|
);
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
<div className="product-widget__footer__order-group" key={orderGroup.id}>
|
|
46
|
+
return (
|
|
47
|
+
<div className="product-widget__footer__order-group">
|
|
61
48
|
<PurchaseButton
|
|
62
49
|
course={course}
|
|
63
50
|
product={courseProductRelation.product as CredentialProduct}
|
|
51
|
+
courseProductRelation={courseProductRelation}
|
|
64
52
|
organizations={courseProductRelation.organizations}
|
|
65
53
|
isWithdrawable={courseProductRelation.is_withdrawable}
|
|
66
54
|
disabled={!canPurchase}
|
|
67
|
-
orderGroup={orderGroup}
|
|
68
55
|
buttonProps={{ fullWidth: true }}
|
|
69
56
|
/>
|
|
70
|
-
|
|
71
|
-
<
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
57
|
+
{hasSeatsLimit && (
|
|
58
|
+
<p className="product-widget__footer__message">
|
|
59
|
+
<FormattedMessage
|
|
60
|
+
{...messages.nbSeatsAvailable}
|
|
61
|
+
values={{ nb: courseProductRelation.nb_seats_available }}
|
|
62
|
+
/>
|
|
63
|
+
</p>
|
|
64
|
+
)}
|
|
76
65
|
</div>
|
|
77
|
-
)
|
|
66
|
+
);
|
|
78
67
|
};
|
|
79
68
|
export default CourseProductItemFooter;
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
background-color: r-theme-val(product-item, base-border);
|
|
44
44
|
color: r-theme-val(product-item, light-color);
|
|
45
45
|
justify-content: space-between;
|
|
46
|
+
text-align: center;
|
|
46
47
|
padding: 1rem 0.5rem;
|
|
47
48
|
|
|
48
49
|
&-main {
|
|
@@ -51,6 +52,8 @@
|
|
|
51
52
|
font-family: $r-font-family-montserrat;
|
|
52
53
|
font-weight: bold;
|
|
53
54
|
justify-content: space-between;
|
|
55
|
+
text-align: initial;
|
|
56
|
+
margin-bottom: rem-calc(8px);
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
&-metadata {
|
|
@@ -74,10 +77,30 @@
|
|
|
74
77
|
border-radius: 100vw;
|
|
75
78
|
color: r-theme-val(product-item, base-border);
|
|
76
79
|
font-size: 0.81rem;
|
|
77
|
-
margin-bottom: 0;
|
|
78
|
-
margin-left: rem-calc(8px);
|
|
80
|
+
margin-bottom: 0.3rem;
|
|
79
81
|
padding: 0.375rem 0.81rem;
|
|
80
82
|
white-space: nowrap;
|
|
83
|
+
display: inline-block;
|
|
84
|
+
|
|
85
|
+
&-discounted {
|
|
86
|
+
text-decoration: line-through;
|
|
87
|
+
margin-right: rem-calc(6px);
|
|
88
|
+
font-size: 0.73rem;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
&-discount {
|
|
92
|
+
color: r-theme-val(product-item, feedback-color);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
&-description,
|
|
97
|
+
&-discount {
|
|
98
|
+
font-size: 1.1rem;
|
|
99
|
+
margin: 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
&-discount {
|
|
103
|
+
font-weight: bold;
|
|
81
104
|
}
|
|
82
105
|
}
|
|
83
106
|
|
|
@@ -149,13 +172,10 @@
|
|
|
149
172
|
|
|
150
173
|
&__order-group {
|
|
151
174
|
text-align: center;
|
|
175
|
+
margin-bottom: 0.5rem;
|
|
152
176
|
|
|
153
177
|
.product-widget__footer__message {
|
|
154
|
-
margin: 0.5rem 0;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
&:last-child {
|
|
158
|
-
margin-bottom: -1rem;
|
|
178
|
+
margin: 0.5rem 0 0;
|
|
159
179
|
}
|
|
160
180
|
}
|
|
161
181
|
}
|
|
@@ -38,7 +38,7 @@ const CourseRunList = ({ courseRuns }: Props) => {
|
|
|
38
38
|
<ol className="course-runs-list">
|
|
39
39
|
{Children.toArray(
|
|
40
40
|
courseRuns.map((courseRun) => (
|
|
41
|
-
<li className="course-runs-item course-runs-item--inactive">
|
|
41
|
+
<li key={courseRun.id} className="course-runs-item course-runs-item--inactive">
|
|
42
42
|
<strong className="course-runs-item__course-dates">
|
|
43
43
|
<span
|
|
44
44
|
className="offscreen"
|
|
@@ -10,8 +10,7 @@ import {
|
|
|
10
10
|
EnrollmentFactory,
|
|
11
11
|
CredentialOrderFactory,
|
|
12
12
|
ProductFactory,
|
|
13
|
-
|
|
14
|
-
OrderGroupFactory,
|
|
13
|
+
CredentialProductFactory,
|
|
15
14
|
} from 'utils/test/factories/joanie';
|
|
16
15
|
import {
|
|
17
16
|
CourseRun,
|
|
@@ -151,6 +150,128 @@ describe('CourseProductItem', () => {
|
|
|
151
150
|
expect(screen.queryByTestId('PurchaseButton__cta')).toBeNull();
|
|
152
151
|
});
|
|
153
152
|
|
|
153
|
+
it('renders discount rate for anonymous user', async () => {
|
|
154
|
+
const relation = CourseProductRelationFactory({
|
|
155
|
+
product: CredentialProductFactory({
|
|
156
|
+
price: 840,
|
|
157
|
+
price_currency: 'EUR',
|
|
158
|
+
}).one(),
|
|
159
|
+
discounted_price: 800,
|
|
160
|
+
discount_rate: 0.3,
|
|
161
|
+
description: 'Year 2023 discount',
|
|
162
|
+
discount_start: new Date('2023-01-01T00:00:00Z').toISOString(),
|
|
163
|
+
discount_end: new Date('2023-12-31T23:59:59Z').toISOString(),
|
|
164
|
+
}).one();
|
|
165
|
+
const { product } = relation;
|
|
166
|
+
fetchMock.get(
|
|
167
|
+
`https://joanie.endpoint/api/v1.0/courses/00000/products/${product.id}/`,
|
|
168
|
+
relation,
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
render(
|
|
172
|
+
<CourseProductItem
|
|
173
|
+
course={PacedCourseFactory({ code: '00000' }).one()}
|
|
174
|
+
productId={product.id}
|
|
175
|
+
/>,
|
|
176
|
+
{ queryOptions: { client: createTestQueryClient({ user: null }) } },
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
await screen.findByRole('heading', { level: 3, name: product.title });
|
|
180
|
+
|
|
181
|
+
// - Render discount information
|
|
182
|
+
// Original price should be displayed as a del element
|
|
183
|
+
const originalPriceLabel = screen.getByText('Original price:');
|
|
184
|
+
expect(originalPriceLabel.classList.contains('offscreen')).toBe(true);
|
|
185
|
+
const originalPrice = screen.getByText(
|
|
186
|
+
priceFormatter(product.price_currency, product.price).replace(/(\u202F|\u00a0)/g, ' '),
|
|
187
|
+
);
|
|
188
|
+
expect(originalPrice.tagName).toBe('DEL');
|
|
189
|
+
expect(originalPrice.getAttribute('aria-describedby')).toEqual(originalPriceLabel.id);
|
|
190
|
+
|
|
191
|
+
// Discounted price should be displayed as an ins element
|
|
192
|
+
const discountedPriceLabel = screen.getByText('Discounted price:');
|
|
193
|
+
expect(discountedPriceLabel.classList.contains('offscreen')).toBe(true);
|
|
194
|
+
const discountedPrice = screen.getByText(
|
|
195
|
+
priceFormatter(product.price_currency, relation.discounted_price!).replace(
|
|
196
|
+
/(\u202F|\u00a0)/g,
|
|
197
|
+
' ',
|
|
198
|
+
),
|
|
199
|
+
);
|
|
200
|
+
expect(discountedPrice.tagName).toBe('INS');
|
|
201
|
+
expect(discountedPrice.getAttribute('aria-describedby')).toEqual(discountedPriceLabel.id);
|
|
202
|
+
|
|
203
|
+
// Discount description should be displayed
|
|
204
|
+
screen.getByText('Year 2023 discount');
|
|
205
|
+
|
|
206
|
+
// Discount rate should be displayed
|
|
207
|
+
screen.getByText('-30%');
|
|
208
|
+
|
|
209
|
+
// Discount date range should be displayed
|
|
210
|
+
screen.getByText('from Jan 01, 2023');
|
|
211
|
+
screen.getByText('to Dec 31, 2023');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('renders discount amount for anonymous user', async () => {
|
|
215
|
+
const relation = CourseProductRelationFactory({
|
|
216
|
+
product: CredentialProductFactory({
|
|
217
|
+
price: 840,
|
|
218
|
+
price_currency: 'EUR',
|
|
219
|
+
}).one(),
|
|
220
|
+
discounted_price: 800,
|
|
221
|
+
discount_amount: 40,
|
|
222
|
+
description: 'Year 2023 discount',
|
|
223
|
+
discount_start: new Date('2023-01-01T00:00:00Z').toISOString(),
|
|
224
|
+
discount_end: new Date('2023-12-31T23:59:59Z').toISOString(),
|
|
225
|
+
}).one();
|
|
226
|
+
const { product } = relation;
|
|
227
|
+
fetchMock.get(
|
|
228
|
+
`https://joanie.endpoint/api/v1.0/courses/00000/products/${product.id}/`,
|
|
229
|
+
relation,
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
render(
|
|
233
|
+
<CourseProductItem
|
|
234
|
+
course={PacedCourseFactory({ code: '00000' }).one()}
|
|
235
|
+
productId={product.id}
|
|
236
|
+
/>,
|
|
237
|
+
{ queryOptions: { client: createTestQueryClient({ user: null }) } },
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
await screen.findByRole('heading', { level: 3, name: product.title });
|
|
241
|
+
|
|
242
|
+
// - Render discount information
|
|
243
|
+
// Original price should be displayed as a del element
|
|
244
|
+
const originalPriceLabel = screen.getByText('Original price:');
|
|
245
|
+
expect(originalPriceLabel.classList.contains('offscreen')).toBe(true);
|
|
246
|
+
const originalPrice = screen.getByText(
|
|
247
|
+
priceFormatter(product.price_currency, product.price).replace(/(\u202F|\u00a0)/g, ' '),
|
|
248
|
+
);
|
|
249
|
+
expect(originalPrice.tagName).toBe('DEL');
|
|
250
|
+
expect(originalPrice.getAttribute('aria-describedby')).toEqual(originalPriceLabel.id);
|
|
251
|
+
|
|
252
|
+
// Discounted price should be displayed as an ins element
|
|
253
|
+
const discountedPriceLabel = screen.getByText('Discounted price:');
|
|
254
|
+
expect(discountedPriceLabel.classList.contains('offscreen')).toBe(true);
|
|
255
|
+
const discountedPrice = screen.getByText(
|
|
256
|
+
priceFormatter(product.price_currency, relation.discounted_price!).replace(
|
|
257
|
+
/(\u202F|\u00a0)/g,
|
|
258
|
+
' ',
|
|
259
|
+
),
|
|
260
|
+
);
|
|
261
|
+
expect(discountedPrice.tagName).toBe('INS');
|
|
262
|
+
expect(discountedPrice.getAttribute('aria-describedby')).toEqual(discountedPriceLabel.id);
|
|
263
|
+
|
|
264
|
+
// Discount description should be displayed
|
|
265
|
+
screen.getByText('Year 2023 discount');
|
|
266
|
+
|
|
267
|
+
// Discount rate should be displayed
|
|
268
|
+
screen.getByText(priceFormatter(product.price_currency, -40).replace(/(\u202F|\u00a0)/g, ' '));
|
|
269
|
+
|
|
270
|
+
// Discount date range should be displayed
|
|
271
|
+
screen.getByText('from Jan 01, 2023');
|
|
272
|
+
screen.getByText('to Dec 31, 2023');
|
|
273
|
+
});
|
|
274
|
+
|
|
154
275
|
it('does not render <CertificateItem /> if product do not have a certificate', async () => {
|
|
155
276
|
const relation = CourseProductRelationFactory({
|
|
156
277
|
product: ProductFactory({
|
|
@@ -674,7 +795,8 @@ describe('CourseProductItem', () => {
|
|
|
674
795
|
|
|
675
796
|
it('renders a warning message that tells that no seats are left', async () => {
|
|
676
797
|
const relation = CourseProductRelationFactory({
|
|
677
|
-
|
|
798
|
+
seats: 2,
|
|
799
|
+
nb_seats_available: 0,
|
|
678
800
|
}).one();
|
|
679
801
|
const { product } = relation;
|
|
680
802
|
const order = CredentialOrderFactory({
|
|
@@ -710,86 +832,4 @@ describe('CourseProductItem', () => {
|
|
|
710
832
|
expect(screen.queryByRole('button', { name: product.call_to_action })).not.toBeInTheDocument();
|
|
711
833
|
screen.getByText('Sorry, no seats available for now');
|
|
712
834
|
});
|
|
713
|
-
|
|
714
|
-
it('renders one payment button when one of two order groups is full', async () => {
|
|
715
|
-
const relation = CourseProductRelationFactory({
|
|
716
|
-
order_groups: [OrderGroupFullFactory().one(), OrderGroupFactory().one()],
|
|
717
|
-
}).one();
|
|
718
|
-
const { product } = relation;
|
|
719
|
-
const order = CredentialOrderFactory({
|
|
720
|
-
product_id: product.id,
|
|
721
|
-
course: PacedCourseFactory({ code: '00000' }).one(),
|
|
722
|
-
target_courses: product.target_courses,
|
|
723
|
-
state: OrderState.DRAFT,
|
|
724
|
-
}).one();
|
|
725
|
-
fetchMock.get(
|
|
726
|
-
`https://joanie.endpoint/api/v1.0/courses/00000/products/${product.id}/`,
|
|
727
|
-
relation,
|
|
728
|
-
);
|
|
729
|
-
const orderQueryParameters = {
|
|
730
|
-
product_id: order.product_id,
|
|
731
|
-
course_code: order.course?.code,
|
|
732
|
-
state: NOT_CANCELED_ORDER_STATES,
|
|
733
|
-
};
|
|
734
|
-
fetchMock.get(
|
|
735
|
-
`https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(orderQueryParameters)}`,
|
|
736
|
-
[order],
|
|
737
|
-
);
|
|
738
|
-
|
|
739
|
-
render(
|
|
740
|
-
<CourseProductItem
|
|
741
|
-
productId={product.id}
|
|
742
|
-
course={PacedCourseFactory({ code: '00000' }).one()}
|
|
743
|
-
/>,
|
|
744
|
-
);
|
|
745
|
-
|
|
746
|
-
// wait for component to be fully loaded
|
|
747
|
-
await screen.findByRole('heading', { level: 3, name: product.title });
|
|
748
|
-
|
|
749
|
-
expect(screen.queryByText('Sorry, no seats available for now')).not.toBeInTheDocument();
|
|
750
|
-
screen.getByRole('button', { name: product.call_to_action });
|
|
751
|
-
screen.getByText(relation.order_groups[1].nb_available_seats + ' remaining seats');
|
|
752
|
-
});
|
|
753
|
-
|
|
754
|
-
it('renders mutliple payment button when there are multiple order groups', async () => {
|
|
755
|
-
const relation = CourseProductRelationFactory({
|
|
756
|
-
order_groups: [OrderGroupFactory().one(), OrderGroupFactory({ nb_available_seats: 1 }).one()],
|
|
757
|
-
}).one();
|
|
758
|
-
const { product } = relation;
|
|
759
|
-
const order = CredentialOrderFactory({
|
|
760
|
-
product_id: product.id,
|
|
761
|
-
course: PacedCourseFactory({ code: '00000' }).one(),
|
|
762
|
-
target_courses: product.target_courses,
|
|
763
|
-
state: OrderState.DRAFT,
|
|
764
|
-
}).one();
|
|
765
|
-
fetchMock.get(
|
|
766
|
-
`https://joanie.endpoint/api/v1.0/courses/00000/products/${product.id}/`,
|
|
767
|
-
relation,
|
|
768
|
-
);
|
|
769
|
-
const orderQueryParameters = {
|
|
770
|
-
product_id: order.product_id,
|
|
771
|
-
course_code: order.course?.code,
|
|
772
|
-
state: NOT_CANCELED_ORDER_STATES,
|
|
773
|
-
};
|
|
774
|
-
fetchMock.get(
|
|
775
|
-
`https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(orderQueryParameters)}`,
|
|
776
|
-
[order],
|
|
777
|
-
);
|
|
778
|
-
|
|
779
|
-
render(
|
|
780
|
-
<CourseProductItem
|
|
781
|
-
productId={product.id}
|
|
782
|
-
course={PacedCourseFactory({ code: '00000' }).one()}
|
|
783
|
-
/>,
|
|
784
|
-
);
|
|
785
|
-
|
|
786
|
-
// wait for component to be fully loaded
|
|
787
|
-
await screen.findByRole('heading', { level: 3, name: product.title });
|
|
788
|
-
|
|
789
|
-
expect(screen.queryByText('Sorry, no seats available for now')).not.toBeInTheDocument();
|
|
790
|
-
expect(screen.getAllByTestId('PurchaseButton__cta')).toHaveLength(2);
|
|
791
|
-
expect(screen.getAllByRole('button', { name: product.call_to_action })).toHaveLength(2);
|
|
792
|
-
screen.getByText(relation.order_groups[0].nb_available_seats + ' remaining seats');
|
|
793
|
-
screen.getByText('Last remaining seat!');
|
|
794
|
-
});
|
|
795
835
|
});
|
|
@@ -22,7 +22,14 @@ const render = (args: CourseProductItemProps, options?: Maybe<{ order: Credentia
|
|
|
22
22
|
fetchMock.get(`http://localhost:8071/api/v1.0/addresses/`, [], { overwriteRoutes: true });
|
|
23
23
|
fetchMock.get(
|
|
24
24
|
`http://localhost:8071/api/v1.0/courses/${args.course.code}/products/${args.productId}/`,
|
|
25
|
-
CourseProductRelationFactory({
|
|
25
|
+
CourseProductRelationFactory({
|
|
26
|
+
product: CredentialProductFactory({
|
|
27
|
+
price: 840,
|
|
28
|
+
price_currency: 'EUR',
|
|
29
|
+
}).one(),
|
|
30
|
+
discounted_price: 800,
|
|
31
|
+
discount_rate: 0.3,
|
|
32
|
+
}).one(),
|
|
26
33
|
{ overwriteRoutes: true },
|
|
27
34
|
);
|
|
28
35
|
fetchMock.get(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Children, useEffect, useMemo } from 'react';
|
|
2
2
|
import { defineMessages, FormattedMessage, FormattedNumber, useIntl } from 'react-intl';
|
|
3
3
|
import c from 'classnames';
|
|
4
|
-
import {
|
|
4
|
+
import { CourseProductRelation, CredentialOrder, Product, ProductType } from 'types/Joanie';
|
|
5
5
|
import { useCourseProduct } from 'hooks/useCourseProducts';
|
|
6
6
|
import { Spinner } from 'components/Spinner';
|
|
7
7
|
import { Icon, IconTypeEnum } from 'components/Icon';
|
|
@@ -38,6 +38,31 @@ const messages = defineMessages({
|
|
|
38
38
|
description: 'Course run languages',
|
|
39
39
|
id: 'components.CourseProductItem.availableIn',
|
|
40
40
|
},
|
|
41
|
+
original_price: {
|
|
42
|
+
defaultMessage: 'Original price:',
|
|
43
|
+
description: 'Label for the original price of a product',
|
|
44
|
+
id: 'components.CourseProductItem.original_price',
|
|
45
|
+
},
|
|
46
|
+
discounted_price: {
|
|
47
|
+
defaultMessage: 'Discounted price:',
|
|
48
|
+
description: 'Label for the discounted price of a product',
|
|
49
|
+
id: 'components.CourseProductItem.discounted_price',
|
|
50
|
+
},
|
|
51
|
+
discount_rate: {
|
|
52
|
+
defaultMessage: '-{rate}%',
|
|
53
|
+
description: 'Discount rate information',
|
|
54
|
+
id: 'components.CourseProductItem.discount_rate',
|
|
55
|
+
},
|
|
56
|
+
from: {
|
|
57
|
+
defaultMessage: 'from {from}',
|
|
58
|
+
description: 'Discount start date information',
|
|
59
|
+
id: 'components.CourseProductItem.from',
|
|
60
|
+
},
|
|
61
|
+
to: {
|
|
62
|
+
defaultMessage: 'to {to}',
|
|
63
|
+
description: 'Discount end date information',
|
|
64
|
+
id: 'components.CourseProductItem.to',
|
|
65
|
+
},
|
|
41
66
|
});
|
|
42
67
|
|
|
43
68
|
export interface CourseProductItemProps {
|
|
@@ -52,8 +77,16 @@ type HeaderProps = {
|
|
|
52
77
|
canPurchase: boolean;
|
|
53
78
|
order: Maybe<CredentialOrder>;
|
|
54
79
|
product: Product;
|
|
80
|
+
courseProductRelation: CourseProductRelation;
|
|
55
81
|
};
|
|
56
|
-
const Header = ({
|
|
82
|
+
const Header = ({
|
|
83
|
+
product,
|
|
84
|
+
order,
|
|
85
|
+
courseProductRelation,
|
|
86
|
+
hasPurchased,
|
|
87
|
+
canPurchase,
|
|
88
|
+
compact,
|
|
89
|
+
}: HeaderProps) => {
|
|
57
90
|
const intl = useIntl();
|
|
58
91
|
const formatDate = useDateFormat();
|
|
59
92
|
|
|
@@ -72,21 +105,90 @@ const Header = ({ product, order, hasPurchased, canPurchase, compact }: HeaderPr
|
|
|
72
105
|
return ProductHelper.getLanguages(product, true, intl);
|
|
73
106
|
}, [canShowMetadata, product, intl]);
|
|
74
107
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
108
|
+
const displayPrice = useMemo(() => {
|
|
109
|
+
if (!canPurchase) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (courseProductRelation.discounted_price) {
|
|
114
|
+
return (
|
|
115
|
+
<>
|
|
116
|
+
<span id="original-price" className="offscreen">
|
|
117
|
+
<FormattedMessage {...messages.original_price} />
|
|
118
|
+
</span>
|
|
119
|
+
<del aria-describedby="original-price" className="product-widget__price-discounted">
|
|
82
120
|
<FormattedNumber
|
|
83
121
|
currency={product.price_currency}
|
|
84
122
|
value={product.price}
|
|
85
123
|
style="currency"
|
|
86
124
|
/>
|
|
87
|
-
|
|
88
|
-
|
|
125
|
+
</del>
|
|
126
|
+
<span id="discount-price" className="offscreen">
|
|
127
|
+
<FormattedMessage {...messages.discounted_price} />
|
|
128
|
+
</span>
|
|
129
|
+
<ins aria-describedby="discount-price" className="product-widget__price-discount">
|
|
130
|
+
<FormattedNumber
|
|
131
|
+
currency={product.price_currency}
|
|
132
|
+
value={courseProductRelation.discounted_price}
|
|
133
|
+
style="currency"
|
|
134
|
+
/>
|
|
135
|
+
</ins>
|
|
136
|
+
</>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<FormattedNumber currency={product.price_currency} value={product.price} style="currency" />
|
|
142
|
+
);
|
|
143
|
+
}, [canPurchase, courseProductRelation.discounted_price, product.price]);
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<header className="product-widget__header">
|
|
147
|
+
<div className="product-widget__header-main">
|
|
148
|
+
<h3 className="product-widget__title">{product.title}</h3>
|
|
89
149
|
</div>
|
|
150
|
+
<strong className="product-widget__price h6">
|
|
151
|
+
{hasPurchased && <FormattedMessage {...messages.purchased} />}
|
|
152
|
+
{displayPrice}
|
|
153
|
+
</strong>
|
|
154
|
+
{courseProductRelation?.description && (
|
|
155
|
+
<p className="product-widget__header-description">{courseProductRelation.description}</p>
|
|
156
|
+
)}
|
|
157
|
+
{courseProductRelation?.discounted_price && (
|
|
158
|
+
<p className="product-widget__header-discount">
|
|
159
|
+
{courseProductRelation.discount_rate ? (
|
|
160
|
+
<span className="product-widget__header-discount-rate">
|
|
161
|
+
<FormattedNumber value={-courseProductRelation.discount_rate} style="percent" />
|
|
162
|
+
</span>
|
|
163
|
+
) : (
|
|
164
|
+
<span className="product-widget__header-discount-amount">
|
|
165
|
+
<FormattedNumber
|
|
166
|
+
currency={product.price_currency}
|
|
167
|
+
value={-courseProductRelation.discount_amount!}
|
|
168
|
+
style="currency"
|
|
169
|
+
/>
|
|
170
|
+
</span>
|
|
171
|
+
)}
|
|
172
|
+
{courseProductRelation.discount_start && (
|
|
173
|
+
<span className="product-widget__header-discount-date">
|
|
174
|
+
|
|
175
|
+
<FormattedMessage
|
|
176
|
+
{...messages.from}
|
|
177
|
+
values={{ from: formatDate(courseProductRelation.discount_start) }}
|
|
178
|
+
/>
|
|
179
|
+
</span>
|
|
180
|
+
)}
|
|
181
|
+
{courseProductRelation.discount_end && (
|
|
182
|
+
<span className="product-widget__header-discount-date">
|
|
183
|
+
|
|
184
|
+
<FormattedMessage
|
|
185
|
+
{...messages.to}
|
|
186
|
+
values={{ to: formatDate(courseProductRelation.discount_end) }}
|
|
187
|
+
/>
|
|
188
|
+
</span>
|
|
189
|
+
)}
|
|
190
|
+
</p>
|
|
191
|
+
)}
|
|
90
192
|
{canShowMetadata && (
|
|
91
193
|
<>
|
|
92
194
|
<p
|
|
@@ -131,7 +233,7 @@ const Content = ({ product, order }: { product: Product; order?: CredentialOrder
|
|
|
131
233
|
<ol className="product-widget__content">
|
|
132
234
|
{Children.toArray(
|
|
133
235
|
targetCourses.map((target_course) => (
|
|
134
|
-
<CourseRunItem targetCourse={target_course} order={order} />
|
|
236
|
+
<CourseRunItem key={target_course.code} targetCourse={target_course} order={order} />
|
|
135
237
|
)),
|
|
136
238
|
)}
|
|
137
239
|
{product.certificate_definition && (
|
|
@@ -179,13 +281,6 @@ const CourseProductItem = ({ productId, course, compact = false }: CourseProduct
|
|
|
179
281
|
return null;
|
|
180
282
|
}
|
|
181
283
|
|
|
182
|
-
const orderGroups = courseProductRelation
|
|
183
|
-
? ProductHelper.getActiveOrderGroups(courseProductRelation)
|
|
184
|
-
: [];
|
|
185
|
-
const orderGroupsAvailable = orderGroups.filter(
|
|
186
|
-
(orderGroup) => orderGroup.nb_available_seats > 0,
|
|
187
|
-
);
|
|
188
|
-
|
|
189
284
|
return (
|
|
190
285
|
<section
|
|
191
286
|
className={c('product-widget', {
|
|
@@ -214,6 +309,7 @@ const CourseProductItem = ({ productId, course, compact = false }: CourseProduct
|
|
|
214
309
|
<Header
|
|
215
310
|
product={product}
|
|
216
311
|
order={order}
|
|
312
|
+
courseProductRelation={courseProductRelation}
|
|
217
313
|
canPurchase={canPurchase}
|
|
218
314
|
hasPurchased={hasPurchased}
|
|
219
315
|
compact={compact}
|
|
@@ -223,8 +319,6 @@ const CourseProductItem = ({ productId, course, compact = false }: CourseProduct
|
|
|
223
319
|
<CourseProductItemFooter
|
|
224
320
|
course={course}
|
|
225
321
|
courseProductRelation={courseProductRelation}
|
|
226
|
-
orderGroups={orderGroups}
|
|
227
|
-
orderGroupsAvailable={orderGroupsAvailable}
|
|
228
322
|
canPurchase={canPurchase}
|
|
229
323
|
/>
|
|
230
324
|
</footer>
|