richie-education 2.28.2-dev25 → 2.28.2-dev39
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 +30 -1
- package/js/components/{SaleTunnel/CreditCardSelector → CreditCardSelector}/index.spec.tsx +8 -38
- package/js/components/{SaleTunnel/CreditCardSelector → CreditCardSelector}/index.tsx +15 -22
- package/js/components/PaymentScheduleGrid/_styles.scss +13 -0
- package/js/components/PaymentScheduleGrid/index.tsx +50 -70
- package/js/components/PurchaseButton/index.spec.tsx +27 -12
- package/js/components/SaleTunnel/GenericPaymentButton/index.tsx +2 -7
- package/js/components/SaleTunnel/GenericSaleTunnel.tsx +22 -3
- package/js/components/SaleTunnel/SaleTunnelInformation/index.tsx +43 -17
- package/js/components/SaleTunnel/Sponsors/SaleTunnelSponsors.scss +4 -5
- package/js/components/SaleTunnel/Sponsors/SaleTunnelSponsors.tsx +34 -11
- package/js/components/SaleTunnel/_styles.scss +6 -4
- package/js/components/SaleTunnel/index.credential.spec.tsx +5 -7
- package/js/components/SaleTunnel/index.full-process.spec.tsx +7 -1
- package/js/components/SaleTunnel/index.spec.tsx +127 -61
- package/js/hooks/usePaymentSchedule.tsx +23 -0
- package/js/hooks/useResources/useResourcesRoot.ts +3 -3
- package/js/index.tsx +2 -0
- package/js/types/Joanie.ts +31 -0
- package/js/utils/OrderHelper/index.ts +13 -0
- package/js/utils/test/factories/joanie.ts +32 -19
- package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.spec.tsx +110 -2
- package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.tsx +90 -2
- package/js/widgets/Dashboard/components/DashboardItem/Order/OrderPaymentDetailsModal/_styles.scss +7 -0
- package/js/widgets/Dashboard/components/DashboardItem/Order/OrderPaymentDetailsModal/index.tsx +106 -0
- package/js/widgets/Dashboard/components/DashboardItem/Order/OrderPaymentRetryModal/index.tsx +212 -0
- package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateLearnerMessage/index.tsx +16 -0
- package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateMessage/index.tsx +3 -0
- package/package.json +2 -1
- package/scss/components/_index.scss +2 -1
- /package/js/components/{SaleTunnel/CreditCardSelector → CreditCardSelector}/_styles.scss +0 -0
package/js/api/joanie.ts
CHANGED
|
@@ -15,7 +15,8 @@ import context from 'utils/context';
|
|
|
15
15
|
import { JOANIE_API_VERSION } from 'settings';
|
|
16
16
|
import { ResourcesQuery } from 'hooks/useResources';
|
|
17
17
|
import { ObjectHelper } from 'utils/ObjectHelper';
|
|
18
|
-
import { Maybe } from 'types/utils';
|
|
18
|
+
import { Maybe, Nullable } from 'types/utils';
|
|
19
|
+
import { PaymentSchedule } from 'types/Joanie';
|
|
19
20
|
import { checkStatus, getFileFromResponse } from './utils';
|
|
20
21
|
|
|
21
22
|
/*
|
|
@@ -97,6 +98,7 @@ export const getRoutes = () => {
|
|
|
97
98
|
download: `${baseUrl}/orders/:id/invoice/`,
|
|
98
99
|
},
|
|
99
100
|
submit_for_signature: `${baseUrl}/orders/:id/submit_for_signature/`,
|
|
101
|
+
submit_installment_payment: `${baseUrl}/orders/:id/submit_installment_payment/`,
|
|
100
102
|
},
|
|
101
103
|
certificates: {
|
|
102
104
|
download: `${baseUrl}/certificates/:id/download/`,
|
|
@@ -141,6 +143,9 @@ export const getRoutes = () => {
|
|
|
141
143
|
},
|
|
142
144
|
products: {
|
|
143
145
|
get: `${baseUrl}/courses/:course_id/products/:id/`,
|
|
146
|
+
paymentSchedule: {
|
|
147
|
+
get: `${baseUrl}/courses/:course_id/products/:id/payment-schedule/`,
|
|
148
|
+
},
|
|
144
149
|
},
|
|
145
150
|
orders: {
|
|
146
151
|
get: `${baseUrl}/courses/:course_id/orders/:id/`,
|
|
@@ -291,6 +296,11 @@ const API = (): Joanie.API => {
|
|
|
291
296
|
fetchWithJWT(ROUTES.user.orders.submit_for_signature.replace(':id', id), {
|
|
292
297
|
method: 'POST',
|
|
293
298
|
}).then(checkStatus),
|
|
299
|
+
submit_installment_payment: async (id, payload) =>
|
|
300
|
+
fetchWithJWT(ROUTES.user.orders.submit_installment_payment.replace(':id', id), {
|
|
301
|
+
method: 'POST',
|
|
302
|
+
body: JSON.stringify(payload),
|
|
303
|
+
}).then(checkStatus),
|
|
294
304
|
},
|
|
295
305
|
enrollments: {
|
|
296
306
|
create: async (payload) =>
|
|
@@ -418,6 +428,25 @@ const API = (): Joanie.API => {
|
|
|
418
428
|
|
|
419
429
|
return fetchWithJWT(buildApiUrl(ROUTES.courses.products.get, filters)).then(checkStatus);
|
|
420
430
|
},
|
|
431
|
+
paymentSchedule: {
|
|
432
|
+
get: async (
|
|
433
|
+
filters?: Joanie.CourseProductQueryFilters,
|
|
434
|
+
): Promise<Nullable<PaymentSchedule>> => {
|
|
435
|
+
if (!filters) {
|
|
436
|
+
throw new Error(
|
|
437
|
+
'A course code and a product id are required to fetch a course product',
|
|
438
|
+
);
|
|
439
|
+
} else if (!filters.course_id) {
|
|
440
|
+
throw new Error('A course code is required to fetch a course product');
|
|
441
|
+
} else if (!filters.id) {
|
|
442
|
+
throw new Error('A product id is required to fetch a course product');
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return fetchWithJWT(
|
|
446
|
+
buildApiUrl(ROUTES.courses.products.paymentSchedule.get, filters),
|
|
447
|
+
).then(checkStatus);
|
|
448
|
+
},
|
|
449
|
+
},
|
|
421
450
|
},
|
|
422
451
|
orders: {
|
|
423
452
|
get: async (filters?: Joanie.CourseOrderResourceQuery) => {
|
|
@@ -1,21 +1,16 @@
|
|
|
1
1
|
import { screen, within } from '@testing-library/react';
|
|
2
|
-
import {
|
|
2
|
+
import { useState, useEffect } from 'react';
|
|
3
3
|
import fetchMock from 'fetch-mock';
|
|
4
4
|
import { faker } from '@faker-js/faker';
|
|
5
5
|
import userEvent from '@testing-library/user-event';
|
|
6
6
|
import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
|
|
7
|
-
import { CreditCardSelector } from 'components/SaleTunnel/CreditCardSelector/index';
|
|
8
7
|
import { render } from 'utils/test/render';
|
|
9
8
|
import { CreditCard } from 'types/Joanie';
|
|
10
|
-
import {
|
|
11
|
-
CredentialOrderFactory,
|
|
12
|
-
CreditCardFactory,
|
|
13
|
-
ProductFactory,
|
|
14
|
-
} from 'utils/test/factories/joanie';
|
|
15
|
-
import { SaleTunnelProps } from 'components/SaleTunnel/index';
|
|
9
|
+
import { CreditCardFactory } from 'utils/test/factories/joanie';
|
|
16
10
|
import { setupJoanieSession } from 'utils/test/wrappers/JoanieAppWrapper';
|
|
17
11
|
import { expectNoSpinner, expectSpinner } from 'utils/test/expectSpinner';
|
|
18
|
-
import {
|
|
12
|
+
import { SaleTunnelContextType } from 'components/SaleTunnel/GenericSaleTunnel';
|
|
13
|
+
import { CreditCardSelector } from 'components/CreditCardSelector/index';
|
|
19
14
|
|
|
20
15
|
jest.mock('utils/context', () => ({
|
|
21
16
|
__esModule: true,
|
|
@@ -43,30 +38,10 @@ describe('CreditCardSelector', () => {
|
|
|
43
38
|
|
|
44
39
|
const Wrapper = () => {
|
|
45
40
|
const [creditCard, setCreditCard] = useState<CreditCard>();
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
product: ProductFactory().one(),
|
|
51
|
-
props: {} as SaleTunnelProps,
|
|
52
|
-
setBillingAddress: jest.fn(),
|
|
53
|
-
creditCard,
|
|
54
|
-
setCreditCard,
|
|
55
|
-
onPaymentSuccess: jest.fn(),
|
|
56
|
-
step: SaleTunnelStep.PAYMENT,
|
|
57
|
-
registerSubmitCallback: jest.fn(),
|
|
58
|
-
unregisterSubmitCallback: jest.fn(),
|
|
59
|
-
runSubmitCallbacks: jest.fn(),
|
|
60
|
-
}),
|
|
61
|
-
[creditCard],
|
|
62
|
-
);
|
|
63
|
-
contextRef.current = context;
|
|
64
|
-
|
|
65
|
-
return (
|
|
66
|
-
<SaleTunnelContext.Provider value={context}>
|
|
67
|
-
<CreditCardSelector />
|
|
68
|
-
</SaleTunnelContext.Provider>
|
|
69
|
-
);
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
contextRef.current.creditCard = creditCard;
|
|
43
|
+
}, [creditCard]);
|
|
44
|
+
return <CreditCardSelector creditCard={creditCard} setCreditCard={setCreditCard} />;
|
|
70
45
|
};
|
|
71
46
|
|
|
72
47
|
return { contextRef, Wrapper };
|
|
@@ -77,11 +52,6 @@ describe('CreditCardSelector', () => {
|
|
|
77
52
|
const { Wrapper } = buildWrapper();
|
|
78
53
|
render(<Wrapper />);
|
|
79
54
|
|
|
80
|
-
screen.getByRole('heading', {
|
|
81
|
-
name: 'Payment method',
|
|
82
|
-
});
|
|
83
|
-
screen.getByText('Choose your payment method or add a new one during the payment.');
|
|
84
|
-
|
|
85
55
|
// During loading state, the spinner should be displayed and the current selected card should not be displayed.
|
|
86
56
|
expect(screen.queryByText('Add new credit card during payment')).not.toBeInTheDocument();
|
|
87
57
|
await expectSpinner();
|
|
@@ -13,7 +13,6 @@ import { CreditCardBrandLogo } from 'pages/DashboardCreditCardsManagement/Credit
|
|
|
13
13
|
import { CreditCard } from 'types/Joanie';
|
|
14
14
|
import { useCreditCardsManagement } from 'hooks/useCreditCardsManagement';
|
|
15
15
|
import { Spinner } from 'components/Spinner';
|
|
16
|
-
import { useSaleTunnelContext } from 'components/SaleTunnel/GenericSaleTunnel';
|
|
17
16
|
import { CreditCardExpirationStatus, CreditCardHelper } from 'utils/CreditCardHelper';
|
|
18
17
|
import { useMatchMediaLg } from 'hooks/useMatchMedia';
|
|
19
18
|
|
|
@@ -33,16 +32,6 @@ const messages = defineMessages({
|
|
|
33
32
|
description: 'Text to show the credit card expired date',
|
|
34
33
|
defaultMessage: 'Expired since {month}/{year}',
|
|
35
34
|
},
|
|
36
|
-
title: {
|
|
37
|
-
id: 'components.SaleTunnel.CreditCardSelector.title',
|
|
38
|
-
description: 'Title for the credit card section',
|
|
39
|
-
defaultMessage: 'Payment method',
|
|
40
|
-
},
|
|
41
|
-
description: {
|
|
42
|
-
id: 'components.SaleTunnel.CreditCardSelector.description',
|
|
43
|
-
description: 'Description for the credit card section',
|
|
44
|
-
defaultMessage: 'Choose your payment method or add a new one during the payment.',
|
|
45
|
-
},
|
|
46
35
|
creditCardEmptyInlineDescription: {
|
|
47
36
|
id: 'components.SaleTunnel.CreditCardSelector.creditCardEmptyInlineDescription',
|
|
48
37
|
description: 'Description for the empty credit card inline',
|
|
@@ -71,7 +60,19 @@ const messages = defineMessages({
|
|
|
71
60
|
},
|
|
72
61
|
});
|
|
73
62
|
|
|
74
|
-
export
|
|
63
|
+
export interface CreditCardSelectorProps {
|
|
64
|
+
creditCard?: CreditCard;
|
|
65
|
+
setCreditCard: (creditCard?: CreditCard) => void;
|
|
66
|
+
quickRemove?: boolean;
|
|
67
|
+
allowEdit?: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const CreditCardSelector = ({
|
|
71
|
+
creditCard,
|
|
72
|
+
setCreditCard,
|
|
73
|
+
allowEdit = true,
|
|
74
|
+
quickRemove = true,
|
|
75
|
+
}: CreditCardSelectorProps) => {
|
|
75
76
|
const intl = useIntl();
|
|
76
77
|
const modal = useModal();
|
|
77
78
|
const isMobile = useMatchMediaLg();
|
|
@@ -81,8 +82,6 @@ export const CreditCardSelector = () => {
|
|
|
81
82
|
items: creditCards,
|
|
82
83
|
} = useCreditCardsManagement();
|
|
83
84
|
|
|
84
|
-
const { creditCard, setCreditCard } = useSaleTunnelContext();
|
|
85
|
-
|
|
86
85
|
const getDefaultCreditCard = () => {
|
|
87
86
|
if (creditCards.length === 0) {
|
|
88
87
|
return;
|
|
@@ -102,12 +101,6 @@ export const CreditCardSelector = () => {
|
|
|
102
101
|
|
|
103
102
|
return (
|
|
104
103
|
<div className="credit-card-selector">
|
|
105
|
-
<h4 className="block-title mb-t">
|
|
106
|
-
<FormattedMessage {...messages.title} />
|
|
107
|
-
</h4>
|
|
108
|
-
<div className="description mb-s">
|
|
109
|
-
<FormattedMessage {...messages.description} />
|
|
110
|
-
</div>
|
|
111
104
|
{fetching ? (
|
|
112
105
|
<Spinner />
|
|
113
106
|
) : (
|
|
@@ -115,7 +108,7 @@ export const CreditCardSelector = () => {
|
|
|
115
108
|
<div className="credit-card-selector__content">
|
|
116
109
|
{creditCard ? <CreditCardInline creditCard={creditCard} /> : <CreditCardEmptyInline />}
|
|
117
110
|
|
|
118
|
-
{creditCards?.length > 0 && (
|
|
111
|
+
{allowEdit && creditCards?.length > 0 && (
|
|
119
112
|
<Button
|
|
120
113
|
icon={<span className="material-icons">edit</span>}
|
|
121
114
|
color="tertiary-text"
|
|
@@ -125,7 +118,7 @@ export const CreditCardSelector = () => {
|
|
|
125
118
|
/>
|
|
126
119
|
)}
|
|
127
120
|
</div>
|
|
128
|
-
{creditCard && (
|
|
121
|
+
{creditCard && quickRemove && (
|
|
129
122
|
<Button
|
|
130
123
|
onClick={() => setCreditCard(undefined)}
|
|
131
124
|
size="small"
|
|
@@ -1,91 +1,71 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { DataGrid } from '@openfun/cunningham-react';
|
|
2
|
+
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|
2
3
|
import { StringHelper } from 'utils/StringHelper';
|
|
4
|
+
import { PaymentSchedule, PaymentScheduleState } from 'types/Joanie';
|
|
5
|
+
import useDateFormat from 'hooks/useDateFormat';
|
|
6
|
+
|
|
7
|
+
type Props = {
|
|
8
|
+
schedule: PaymentSchedule;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const messages = defineMessages({
|
|
12
|
+
withdrawnAt: {
|
|
13
|
+
id: 'components.PaymentScheduleGrid.withdrawnAt',
|
|
14
|
+
defaultMessage: 'Withdrawn on {date}',
|
|
15
|
+
description: 'Label displayed to explain when the installment will be withdrawn.',
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const PaymentScheduleGrid = ({ schedule }: Props) => {
|
|
20
|
+
const intl = useIntl();
|
|
21
|
+
const formatDate = useDateFormat();
|
|
3
22
|
|
|
4
|
-
export const PaymentScheduleGrid = () => {
|
|
5
23
|
return (
|
|
6
24
|
<div className="payment-schedule__grid">
|
|
7
|
-
<
|
|
25
|
+
<DataGrid
|
|
26
|
+
displayHeader={false}
|
|
8
27
|
columns={[
|
|
28
|
+
{ field: 'index', size: 10 },
|
|
29
|
+
{ field: 'amount', size: 90 },
|
|
9
30
|
{
|
|
10
|
-
|
|
11
|
-
renderCell: (
|
|
12
|
-
|
|
31
|
+
field: 'date',
|
|
32
|
+
renderCell: ({ row }) => (
|
|
33
|
+
<span className="payment-schedule__cell--wrapped">
|
|
34
|
+
<FormattedMessage {...messages.withdrawnAt} values={{ date: row.date }} />
|
|
35
|
+
</span>
|
|
36
|
+
),
|
|
13
37
|
},
|
|
14
38
|
{
|
|
15
|
-
id: '
|
|
16
|
-
renderCell: (
|
|
17
|
-
|
|
18
|
-
<
|
|
39
|
+
id: 'state',
|
|
40
|
+
renderCell: ({ row }) =>
|
|
41
|
+
row.state ? (
|
|
42
|
+
<div className="payment-schedule__cell--alignRight">
|
|
43
|
+
<StatusPill state={row.state} />
|
|
44
|
+
</div>
|
|
19
45
|
) : (
|
|
20
|
-
|
|
46
|
+
''
|
|
21
47
|
),
|
|
22
48
|
},
|
|
23
|
-
{
|
|
24
|
-
id: 'status',
|
|
25
|
-
renderCell: (context) =>
|
|
26
|
-
context.row.status ? <StatusPill status={context.row.status} /> : '',
|
|
27
|
-
},
|
|
28
|
-
{ field: 'message' },
|
|
29
|
-
]}
|
|
30
|
-
rows={[
|
|
31
|
-
{
|
|
32
|
-
id: '1',
|
|
33
|
-
date: '2023-03-15',
|
|
34
|
-
amount: '€ 100.00',
|
|
35
|
-
status: PaymentScheduleStatus.PAID,
|
|
36
|
-
message: 'First payment (30%)',
|
|
37
|
-
},
|
|
38
|
-
{
|
|
39
|
-
id: '2',
|
|
40
|
-
date: '2023-04-15',
|
|
41
|
-
amount: '€ 100.00',
|
|
42
|
-
status: PaymentScheduleStatus.REQUIRE_PAYMENT,
|
|
43
|
-
message: 'Periodic',
|
|
44
|
-
},
|
|
45
|
-
{
|
|
46
|
-
id: '3',
|
|
47
|
-
date: '2023-05-15',
|
|
48
|
-
amount: '€ 100.00',
|
|
49
|
-
status: PaymentScheduleStatus.FAILED,
|
|
50
|
-
message: 'Periodic',
|
|
51
|
-
},
|
|
52
|
-
{
|
|
53
|
-
id: '4',
|
|
54
|
-
date: '2023-06-15',
|
|
55
|
-
amount: '€ 100.00',
|
|
56
|
-
status: PaymentScheduleStatus.INCOMING,
|
|
57
|
-
message: 'Periodic',
|
|
58
|
-
},
|
|
59
|
-
{
|
|
60
|
-
id: '5',
|
|
61
|
-
date: '2023-06-15',
|
|
62
|
-
amount: '€ 100.00',
|
|
63
|
-
status: PaymentScheduleStatus.PENDING,
|
|
64
|
-
message: 'Periodic',
|
|
65
|
-
},
|
|
66
|
-
{
|
|
67
|
-
id: 'total',
|
|
68
|
-
date: 'Total',
|
|
69
|
-
amount: '€ 1150.00',
|
|
70
|
-
},
|
|
71
49
|
]}
|
|
50
|
+
rows={schedule.map((installment, index) => ({
|
|
51
|
+
id: installment.id,
|
|
52
|
+
index: index + 1,
|
|
53
|
+
date: formatDate(installment.due_date),
|
|
54
|
+
amount: intl.formatNumber(installment.amount, {
|
|
55
|
+
style: 'currency',
|
|
56
|
+
currency: installment.currency,
|
|
57
|
+
}),
|
|
58
|
+
state: installment.state,
|
|
59
|
+
}))}
|
|
72
60
|
/>
|
|
73
61
|
</div>
|
|
74
62
|
);
|
|
75
63
|
};
|
|
76
64
|
|
|
77
|
-
export
|
|
78
|
-
INCOMING = 'incoming',
|
|
79
|
-
PENDING = 'pending',
|
|
80
|
-
PAID = 'paid',
|
|
81
|
-
FAILED = 'failed',
|
|
82
|
-
REQUIRE_PAYMENT = 'require_payment',
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export const StatusPill = ({ status }: { status: PaymentScheduleStatus }) => {
|
|
65
|
+
export const StatusPill = ({ state }: { state: PaymentScheduleState }) => {
|
|
86
66
|
return (
|
|
87
|
-
<span className={`status-pill status-pill--${
|
|
88
|
-
{StringHelper.capitalizeFirst(
|
|
67
|
+
<span className={`status-pill status-pill--${state}`}>
|
|
68
|
+
{StringHelper.capitalizeFirst(state.replace('_', ' '))}
|
|
89
69
|
</span>
|
|
90
70
|
);
|
|
91
71
|
};
|
|
@@ -102,10 +102,15 @@ describe('PurchaseButton', () => {
|
|
|
102
102
|
it('shows cta to open sale tunnel when user is authenticated', async () => {
|
|
103
103
|
const courseCode = '00000';
|
|
104
104
|
const product = ProductFactory().one();
|
|
105
|
-
fetchMock
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
105
|
+
fetchMock
|
|
106
|
+
.get(
|
|
107
|
+
`https://joanie.endpoint/api/v1.0/orders/?course_code=${courseCode}&product_id=${product.id}&state=pending&state=validated&state=submitted`,
|
|
108
|
+
{},
|
|
109
|
+
)
|
|
110
|
+
.get(
|
|
111
|
+
`https://joanie.endpoint/api/v1.0/courses/${courseCode}/products/${product.id}/payment-schedule/`,
|
|
112
|
+
[],
|
|
113
|
+
);
|
|
109
114
|
|
|
110
115
|
render(
|
|
111
116
|
<Wrapper client={createTestQueryClient({ user: richieUser })}>
|
|
@@ -137,10 +142,15 @@ describe('PurchaseButton', () => {
|
|
|
137
142
|
const product = ProductFactory({ remaining_order_count: null }).one();
|
|
138
143
|
fetchMock.get(`https://demo.endpoint/api/user/v1/accounts/${user.username}`, {});
|
|
139
144
|
fetchMock.get(`https://demo.endpoint/api/user/v1/preferences/${user.username}`, {});
|
|
140
|
-
fetchMock
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
145
|
+
fetchMock
|
|
146
|
+
.get(
|
|
147
|
+
`https://joanie.endpoint/api/v1.0/orders/?course_code=${courseCode}&product_id=${product.id}&state=pending&state=validated&state=submitted`,
|
|
148
|
+
{},
|
|
149
|
+
)
|
|
150
|
+
.get(
|
|
151
|
+
`https://joanie.endpoint/api/v1.0/courses/${courseCode}/products/${product.id}/payment-schedule/`,
|
|
152
|
+
[],
|
|
153
|
+
);
|
|
144
154
|
render(
|
|
145
155
|
<Wrapper client={createTestQueryClient({ user })}>
|
|
146
156
|
<PurchaseButton
|
|
@@ -170,10 +180,15 @@ describe('PurchaseButton', () => {
|
|
|
170
180
|
it('shows cta to open sale tunnel when remaining orders is undefined', async () => {
|
|
171
181
|
const courseCode = '00000';
|
|
172
182
|
const product = ProductFactory().one();
|
|
173
|
-
fetchMock
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
183
|
+
fetchMock
|
|
184
|
+
.get(
|
|
185
|
+
`https://joanie.endpoint/api/v1.0/orders/?course_code=${courseCode}&product_id=${product.id}&state=pending&state=validated&state=submitted`,
|
|
186
|
+
{},
|
|
187
|
+
)
|
|
188
|
+
.get(
|
|
189
|
+
`https://joanie.endpoint/api/v1.0/courses/${courseCode}/products/${product.id}/payment-schedule/`,
|
|
190
|
+
[],
|
|
191
|
+
);
|
|
177
192
|
delete product.remaining_order_count;
|
|
178
193
|
|
|
179
194
|
render(
|
|
@@ -50,15 +50,10 @@ const messages = defineMessages({
|
|
|
50
50
|
id: 'components.PaymentButton.errorTerms',
|
|
51
51
|
},
|
|
52
52
|
pay: {
|
|
53
|
-
defaultMessage: '
|
|
53
|
+
defaultMessage: 'Subscribe',
|
|
54
54
|
description: 'CTA label to proceed to the payment of the product',
|
|
55
55
|
id: 'components.PaymentButton.pay',
|
|
56
56
|
},
|
|
57
|
-
payInOneClick: {
|
|
58
|
-
defaultMessage: 'Pay in one click {price}',
|
|
59
|
-
description: 'CTA label to proceed to the one click payment of the product',
|
|
60
|
-
id: 'components.PaymentButton.payInOneClick',
|
|
61
|
-
},
|
|
62
57
|
paymentInProgress: {
|
|
63
58
|
defaultMessage: 'Payment in progress',
|
|
64
59
|
description: 'Label for screen reader when a payment is in progress.',
|
|
@@ -309,7 +304,7 @@ export const GenericPaymentButton = ({ buildOrderPayload }: Props) => {
|
|
|
309
304
|
</Spinner>
|
|
310
305
|
) : (
|
|
311
306
|
<FormattedMessage
|
|
312
|
-
{...
|
|
307
|
+
{...messages.pay}
|
|
313
308
|
values={{
|
|
314
309
|
price: intl.formatNumber(product.price, {
|
|
315
310
|
style: 'currency',
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { Modal, ModalSize } from '@openfun/cunningham-react';
|
|
1
|
+
import { Alert, Modal, ModalSize, VariantType } from '@openfun/cunningham-react';
|
|
2
2
|
import { createContext, ReactNode, useContext, useEffect, useMemo, useState } from 'react';
|
|
3
|
+
import { FormattedMessage, defineMessages } from 'react-intl';
|
|
3
4
|
import { SaleTunnelSponsors } from 'components/SaleTunnel/Sponsors/SaleTunnelSponsors';
|
|
4
5
|
import { SaleTunnelProps } from 'components/SaleTunnel/index';
|
|
5
6
|
import { Address, CreditCard, Order, Product } from 'types/Joanie';
|
|
@@ -12,6 +13,15 @@ import { SaleTunnelInformation } from 'components/SaleTunnel/SaleTunnelInformati
|
|
|
12
13
|
import { useEnrollments } from 'hooks/useEnrollments';
|
|
13
14
|
import SaleTunnelNotValidated from './SaleTunnelNotValidated';
|
|
14
15
|
|
|
16
|
+
const messages = defineMessages({
|
|
17
|
+
walkthrough: {
|
|
18
|
+
id: 'components.SaleTunnel.GenericSaleTunnel.walkthrough',
|
|
19
|
+
defaultMessage:
|
|
20
|
+
'To enroll in the training, you will first be invited to sign the training agreement and then to define a payment method. You will not be charged during this step; the first payment will take place on the date mentioned in the payment schedule above.',
|
|
21
|
+
description: 'Message explaining the credential sale tunnel process',
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
15
25
|
export interface SaleTunnelContextType {
|
|
16
26
|
props: SaleTunnelProps;
|
|
17
27
|
order?: Order;
|
|
@@ -177,15 +187,24 @@ export const GenericSaleTunnelPaymentStep = (props: GenericSaleTunnelProps) => {
|
|
|
177
187
|
<Modal {...props} size={ModalSize.EXTRA_LARGE} title={props.product.title} closeOnEsc={false}>
|
|
178
188
|
<div className="sale-tunnel" data-testid="generic-sale-tunnel-payment-step">
|
|
179
189
|
<div className="sale-tunnel__main">
|
|
180
|
-
<div className="sale-tunnel__main__left">
|
|
190
|
+
<div className="sale-tunnel__main__column sale-tunnel__main__left ">
|
|
191
|
+
<div>{props.asideNode}</div>
|
|
192
|
+
<div>
|
|
193
|
+
<SaleTunnelSponsors />
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
181
196
|
<div className="sale-tunnel__main__separator" />
|
|
182
197
|
<div className="sale-tunnel__main__right">
|
|
183
198
|
<SaleTunnelInformation />
|
|
184
199
|
</div>
|
|
185
200
|
</div>
|
|
186
201
|
<div className="sale-tunnel__footer">
|
|
202
|
+
<div style={{ maxWidth: '680px' }} className="mb-s" data-testid="walkthrough-banner">
|
|
203
|
+
<Alert type={VariantType.INFO}>
|
|
204
|
+
<FormattedMessage {...messages.walkthrough} />
|
|
205
|
+
</Alert>
|
|
206
|
+
</div>
|
|
187
207
|
{props.paymentNode}
|
|
188
|
-
<SaleTunnelSponsors />
|
|
189
208
|
</div>
|
|
190
209
|
</div>
|
|
191
210
|
</Modal>
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { Alert, VariantType } from '@openfun/cunningham-react';
|
|
2
1
|
import { defineMessages, FormattedMessage, FormattedNumber } from 'react-intl';
|
|
3
2
|
import { AddressSelector } from 'components/SaleTunnel/AddressSelector';
|
|
4
|
-
import { CreditCardSelector } from 'components/
|
|
3
|
+
import { CreditCardSelector } from 'components/CreditCardSelector';
|
|
5
4
|
import { PaymentScheduleGrid } from 'components/PaymentScheduleGrid';
|
|
6
5
|
import { useSaleTunnelContext } from 'components/SaleTunnel/GenericSaleTunnel';
|
|
7
6
|
import OpenEdxFullNameForm from 'components/OpenEdxFullNameForm';
|
|
8
7
|
import { useSession } from 'contexts/SessionContext';
|
|
9
8
|
import useOpenEdxProfile from 'hooks/useOpenEdxProfile';
|
|
9
|
+
import { usePaymentSchedule } from 'hooks/usePaymentSchedule';
|
|
10
|
+
import { Spinner } from 'components/Spinner';
|
|
10
11
|
|
|
11
12
|
const messages = defineMessages({
|
|
12
13
|
title: {
|
|
@@ -24,6 +25,16 @@ const messages = defineMessages({
|
|
|
24
25
|
description: 'Label for the full name input',
|
|
25
26
|
defaultMessage: 'Full name',
|
|
26
27
|
},
|
|
28
|
+
paymentMethodTitle: {
|
|
29
|
+
id: 'components.SaleTunnel.CreditCardSelector.title',
|
|
30
|
+
description: 'Title for the credit card section',
|
|
31
|
+
defaultMessage: 'Payment method',
|
|
32
|
+
},
|
|
33
|
+
paymentMethodDescription: {
|
|
34
|
+
id: 'components.SaleTunnel.CreditCardSelector.description',
|
|
35
|
+
description: 'Description for the credit card section',
|
|
36
|
+
defaultMessage: 'Choose your payment method or add a new one during the payment.',
|
|
37
|
+
},
|
|
27
38
|
totalInfo: {
|
|
28
39
|
id: 'components.SaleTunnel.Information.total.info',
|
|
29
40
|
description: 'Information about the total amount',
|
|
@@ -49,7 +60,7 @@ const messages = defineMessages({
|
|
|
49
60
|
|
|
50
61
|
export const SaleTunnelInformation = () => {
|
|
51
62
|
return (
|
|
52
|
-
<div className="sale-tunnel__information">
|
|
63
|
+
<div className="sale-tunnel__main__column sale-tunnel__information">
|
|
53
64
|
<div>
|
|
54
65
|
<h3 className="block-title mb-t">
|
|
55
66
|
<FormattedMessage {...messages.title} />
|
|
@@ -64,15 +75,31 @@ export const SaleTunnelInformation = () => {
|
|
|
64
75
|
</div>
|
|
65
76
|
</div>
|
|
66
77
|
<div>
|
|
67
|
-
<
|
|
78
|
+
<CreditCardSelectorWrapper />
|
|
68
79
|
</div>
|
|
69
80
|
<div>
|
|
81
|
+
<PaymentScheduleBlock />
|
|
70
82
|
<Total />
|
|
71
83
|
</div>
|
|
72
84
|
</div>
|
|
73
85
|
);
|
|
74
86
|
};
|
|
75
87
|
|
|
88
|
+
const CreditCardSelectorWrapper = () => {
|
|
89
|
+
const { creditCard, setCreditCard } = useSaleTunnelContext();
|
|
90
|
+
return (
|
|
91
|
+
<>
|
|
92
|
+
<h4 className="block-title mb-t">
|
|
93
|
+
<FormattedMessage {...messages.paymentMethodTitle} />
|
|
94
|
+
</h4>
|
|
95
|
+
<div className="description mb-s">
|
|
96
|
+
<FormattedMessage {...messages.paymentMethodDescription} />
|
|
97
|
+
</div>
|
|
98
|
+
<CreditCardSelector creditCard={creditCard} setCreditCard={setCreditCard} />
|
|
99
|
+
</>
|
|
100
|
+
);
|
|
101
|
+
};
|
|
102
|
+
|
|
76
103
|
const Email = () => {
|
|
77
104
|
const { user } = useSession();
|
|
78
105
|
const { data: openEdxProfileData } = useOpenEdxProfile({
|
|
@@ -98,9 +125,6 @@ const Total = () => {
|
|
|
98
125
|
const { product } = useSaleTunnelContext();
|
|
99
126
|
return (
|
|
100
127
|
<div className="sale-tunnel__total">
|
|
101
|
-
<Alert type={VariantType.INFO}>
|
|
102
|
-
<FormattedMessage {...messages.totalInfo} />
|
|
103
|
-
</Alert>
|
|
104
128
|
<div className="sale-tunnel__total__amount mt-t" data-testid="sale-tunnel__total__amount">
|
|
105
129
|
<div className="block-title">
|
|
106
130
|
<FormattedMessage {...messages.totalLabel} />
|
|
@@ -117,20 +141,22 @@ const Total = () => {
|
|
|
117
141
|
);
|
|
118
142
|
};
|
|
119
143
|
|
|
120
|
-
/**
|
|
121
|
-
* Ready for V2.
|
|
122
|
-
*/
|
|
123
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
124
144
|
const PaymentScheduleBlock = () => {
|
|
125
|
-
|
|
145
|
+
const { props } = useSaleTunnelContext();
|
|
146
|
+
const query = usePaymentSchedule({
|
|
147
|
+
course_code: props.course?.code || props.enrollment!.course_run.course.code,
|
|
148
|
+
product_id: props.product.id,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
if (!query.data || query.isLoading) {
|
|
152
|
+
return <Spinner size="large" />;
|
|
153
|
+
}
|
|
154
|
+
|
|
126
155
|
return (
|
|
127
156
|
<div className="payment-schedule">
|
|
128
|
-
<h4 className="block-title mb-t">
|
|
129
|
-
<Alert type={VariantType.INFO}>
|
|
130
|
-
The first payment occurs in 14 days, you will be notified to pay the first 30%.
|
|
131
|
-
</Alert>
|
|
157
|
+
<h4 className="block-title mb-t">Payment schedule</h4>
|
|
132
158
|
<div className="mt-t">
|
|
133
|
-
<PaymentScheduleGrid />
|
|
159
|
+
<PaymentScheduleGrid schedule={query.data} />
|
|
134
160
|
</div>
|
|
135
161
|
</div>
|
|
136
162
|
);
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
.sale-tunnel__sponsors {
|
|
2
2
|
margin-top: 8px;
|
|
3
3
|
display: flex;
|
|
4
|
-
gap:
|
|
5
|
-
justify-content:
|
|
4
|
+
gap: 16px;
|
|
5
|
+
justify-content: flex-start;
|
|
6
|
+
align-items: center;
|
|
6
7
|
overflow: hidden;
|
|
7
8
|
flex-wrap: wrap;
|
|
8
9
|
|
|
9
10
|
img {
|
|
10
|
-
|
|
11
|
-
padding: 8px;
|
|
12
|
-
width: 130px;
|
|
11
|
+
width: 90px;
|
|
13
12
|
object-fit: contain;
|
|
14
13
|
}
|
|
15
14
|
}
|