@akinon/pz-saved-card 1.57.0
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/.gitattributes +15 -0
- package/.prettierrc +13 -0
- package/assets/img/amex.jpg +0 -0
- package/assets/img/mastercard.png +0 -0
- package/assets/img/other.png +0 -0
- package/assets/img/troy.png +0 -0
- package/assets/img/visa.png +0 -0
- package/package.json +22 -0
- package/readme.md +42 -0
- package/src/components/delete-confirmation-modal.tsx +80 -0
- package/src/components/installments.tsx +113 -0
- package/src/index.tsx +4 -0
- package/src/redux/api.ts +136 -0
- package/src/redux/reducer.ts +45 -0
- package/src/types/index.ts +52 -0
- package/src/utils/index.ts +15 -0
- package/src/views/saved-card-option.tsx +324 -0
package/.gitattributes
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
*.js text eol=lf
|
|
2
|
+
*.jsx text eol=lf
|
|
3
|
+
*.ts text eol=lf
|
|
4
|
+
*.tsx text eol=lf
|
|
5
|
+
*.json text eol=lf
|
|
6
|
+
*.md text eol=lf
|
|
7
|
+
|
|
8
|
+
.eslintignore text eol=lf
|
|
9
|
+
.eslintrc text eol=lf
|
|
10
|
+
.gitignore text eol=lf
|
|
11
|
+
.prettierrc text eol=lf
|
|
12
|
+
.yarnrc text eol=lf
|
|
13
|
+
|
|
14
|
+
* text=auto
|
|
15
|
+
|
package/.prettierrc
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"bracketSameLine": false,
|
|
3
|
+
"tabWidth": 2,
|
|
4
|
+
"singleQuote": true,
|
|
5
|
+
"jsxSingleQuote": false,
|
|
6
|
+
"bracketSpacing": true,
|
|
7
|
+
"semi": true,
|
|
8
|
+
"useTabs": false,
|
|
9
|
+
"arrowParens": "always",
|
|
10
|
+
"endOfLine": "lf",
|
|
11
|
+
"proseWrap": "never",
|
|
12
|
+
"trailingComma": "none"
|
|
13
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@akinon/pz-saved-card",
|
|
3
|
+
"version": "1.57.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"main": "src/index.tsx",
|
|
6
|
+
"peerDependencies": {
|
|
7
|
+
"react": "^18.0.0",
|
|
8
|
+
"react-dom": "^18.0.0"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"react-redux": "8.1.3"
|
|
12
|
+
},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"@types/node": "^18.7.8",
|
|
15
|
+
"@types/react": "^18.0.17",
|
|
16
|
+
"@types/react-dom": "^18.0.6",
|
|
17
|
+
"prettier": "^3.0.3",
|
|
18
|
+
"react": "^18.2.0",
|
|
19
|
+
"react-dom": "^18.2.0",
|
|
20
|
+
"typescript": "^5.2.2"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Saved Card Plugin
|
|
2
|
+
|
|
3
|
+
## Installation
|
|
4
|
+
|
|
5
|
+
There are two ways to install the Saved Card plugin:
|
|
6
|
+
|
|
7
|
+
### 1. Install the npm package using Yarn
|
|
8
|
+
|
|
9
|
+
For the latest version, you can install the package using Yarn:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
yarn add @akinon/pz-saved-card
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
### 2. Preferred installation method
|
|
16
|
+
|
|
17
|
+
You can also use the following command to install the extension with the latest plugins:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npx @akinon/projectzero@latest --plugins
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
##### File Path: src/views/checkout/steps/payment/options/saved-card.tsx
|
|
26
|
+
|
|
27
|
+
```jsx
|
|
28
|
+
import { SavedCardOption } from '@akinon/pz-saved-card';
|
|
29
|
+
|
|
30
|
+
const SavedCard = () => {
|
|
31
|
+
return (
|
|
32
|
+
<SavedCardOption
|
|
33
|
+
texts={{
|
|
34
|
+
title: 'Pay with Saved Card',
|
|
35
|
+
button: 'Pay Now'
|
|
36
|
+
}}
|
|
37
|
+
/>
|
|
38
|
+
);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export default SavedCard;
|
|
42
|
+
```
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Button, LoaderSpinner, Modal } from '@akinon/next/components';
|
|
4
|
+
import { useAppDispatch, useAppSelector } from '@akinon/next/redux/hooks';
|
|
5
|
+
import {
|
|
6
|
+
setCards,
|
|
7
|
+
setDeletionModalId,
|
|
8
|
+
setDeletionModalVisible
|
|
9
|
+
} from '../redux/reducer';
|
|
10
|
+
import { useDeleteSavedCardMutation } from '../redux/api';
|
|
11
|
+
import { useState } from 'react';
|
|
12
|
+
import { DeletePopupTexts } from '../types';
|
|
13
|
+
|
|
14
|
+
const defaultTranslations = {
|
|
15
|
+
title: 'Are you sure you want to delete this card?',
|
|
16
|
+
delete_button: 'Delete',
|
|
17
|
+
cancel_button: 'Cancel'
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export interface DeleteConfirmationModalProps {
|
|
21
|
+
translations?: DeletePopupTexts;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const DeleteConfirmationModal = ({
|
|
25
|
+
translations
|
|
26
|
+
}: DeleteConfirmationModalProps) => {
|
|
27
|
+
const [error, setError] = useState<string | null>(null);
|
|
28
|
+
const { cards, deletion } = useAppSelector((state) => state.savedCard);
|
|
29
|
+
const dispatch = useAppDispatch();
|
|
30
|
+
|
|
31
|
+
const [deleteCard, { isLoading }] = useDeleteSavedCardMutation();
|
|
32
|
+
|
|
33
|
+
const handleClose = () => {
|
|
34
|
+
dispatch(setDeletionModalId(null));
|
|
35
|
+
dispatch(setDeletionModalVisible(false));
|
|
36
|
+
setError(null);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const handleDelete = async () => {
|
|
40
|
+
try {
|
|
41
|
+
await deleteCard(deletion.id).unwrap();
|
|
42
|
+
dispatch(setCards(cards.filter((card) => card.id !== deletion.id)));
|
|
43
|
+
handleClose();
|
|
44
|
+
} catch (error) {
|
|
45
|
+
setError(error.data.detail);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<Modal
|
|
51
|
+
portalId="saved-card-remove-card-modal"
|
|
52
|
+
className="w-full sm:w-[28rem] max-h-[90vh] overflow-y-auto"
|
|
53
|
+
open={deletion.isModalVisible}
|
|
54
|
+
setOpen={() => dispatch(setDeletionModalVisible(false))}
|
|
55
|
+
>
|
|
56
|
+
<div className="px-6">
|
|
57
|
+
<h3 className="text-center mt-4 text-lg">
|
|
58
|
+
{translations?.title ?? defaultTranslations.title}
|
|
59
|
+
</h3>
|
|
60
|
+
<div className="flex flex-col gap-3 p-5 w-3/4 m-auto">
|
|
61
|
+
<Button className="py-3 h-auto" onClick={handleDelete}>
|
|
62
|
+
{isLoading ? (
|
|
63
|
+
<LoaderSpinner className="w-4 h-4" />
|
|
64
|
+
) : (
|
|
65
|
+
translations?.delete_button ?? defaultTranslations.delete_button
|
|
66
|
+
)}
|
|
67
|
+
</Button>
|
|
68
|
+
<Button
|
|
69
|
+
appearance="outlined"
|
|
70
|
+
className="underline px-5 py-3 h-auto"
|
|
71
|
+
onClick={handleClose}
|
|
72
|
+
>
|
|
73
|
+
{translations?.cancel_button ?? defaultTranslations.cancel_button}
|
|
74
|
+
</Button>
|
|
75
|
+
{error && <p className="text-error text-xs text-center">{error}</p>}
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
</Modal>
|
|
79
|
+
);
|
|
80
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { Price, Radio } from '@akinon/next/components';
|
|
2
|
+
import { useLocalization } from '@akinon/next/hooks';
|
|
3
|
+
import { useSetSavedCardInstallmentOptionMutation } from '../redux/api';
|
|
4
|
+
import { useEffect, useState } from 'react';
|
|
5
|
+
import { SavedCard } from '../redux/reducer';
|
|
6
|
+
import { InstallmentTexts } from '../types';
|
|
7
|
+
|
|
8
|
+
const defaultTranslations = {
|
|
9
|
+
payments: 'Payments',
|
|
10
|
+
per_month: 'Per Month',
|
|
11
|
+
total: 'Total'
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type SavedCardInstallmentsProps = {
|
|
15
|
+
selectedCard: SavedCard;
|
|
16
|
+
installmentOptions: any[];
|
|
17
|
+
translations?: InstallmentTexts;
|
|
18
|
+
error: any;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const SavedCardInstallments = ({
|
|
22
|
+
selectedCard,
|
|
23
|
+
installmentOptions = [],
|
|
24
|
+
translations,
|
|
25
|
+
error
|
|
26
|
+
}: SavedCardInstallmentsProps) => {
|
|
27
|
+
const { t } = useLocalization();
|
|
28
|
+
const [installmentOption, setInstallmentOption] = useState(null);
|
|
29
|
+
const [setInstallment] = useSetSavedCardInstallmentOptionMutation();
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (
|
|
33
|
+
selectedCard &&
|
|
34
|
+
installmentOptions.length > 0 &&
|
|
35
|
+
installmentOptions[0]?.pk !== installmentOption
|
|
36
|
+
) {
|
|
37
|
+
const firstOptionPk = installmentOptions[0].pk;
|
|
38
|
+
setInstallment({
|
|
39
|
+
installment: firstOptionPk
|
|
40
|
+
});
|
|
41
|
+
setInstallmentOption(firstOptionPk);
|
|
42
|
+
}
|
|
43
|
+
}, [installmentOptions, installmentOption, setInstallment]);
|
|
44
|
+
|
|
45
|
+
if (installmentOptions.length === 0) {
|
|
46
|
+
return (
|
|
47
|
+
<div className="text-xs text-black-800 p-4 sm:p-6">
|
|
48
|
+
{t('checkout.payment.installment_options.description')}
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div>
|
|
55
|
+
<div className="px-4 mb-4 sm:px-6 sm:mb-6">
|
|
56
|
+
<table className="w-full border border-solid border-gray-400">
|
|
57
|
+
<thead>
|
|
58
|
+
<tr>
|
|
59
|
+
<th className="text-xs font-normal border-e border-solid border-gray-400 px-2 py-2 text-start">
|
|
60
|
+
{translations?.payments || defaultTranslations.payments}
|
|
61
|
+
</th>
|
|
62
|
+
<th className="text-xs font-normal border-e border-solid border-gray-400 px-2 py-2 text-right">
|
|
63
|
+
{translations?.per_month || defaultTranslations.per_month}
|
|
64
|
+
</th>
|
|
65
|
+
<th className="text-xs font-normal border-e border-solid border-gray-400 px-2 py-2 text-right">
|
|
66
|
+
{translations?.total || defaultTranslations.total}
|
|
67
|
+
</th>
|
|
68
|
+
</tr>
|
|
69
|
+
</thead>
|
|
70
|
+
<tbody>
|
|
71
|
+
{installmentOptions.map((option) => (
|
|
72
|
+
<tr
|
|
73
|
+
key={`installment-${option.pk}`}
|
|
74
|
+
className="border-t border-solid border-gray-400"
|
|
75
|
+
>
|
|
76
|
+
<td className="text-xs font-normal border-e border-solid border-gray-400 px-2 py-2 text-left">
|
|
77
|
+
<Radio
|
|
78
|
+
value={option.pk}
|
|
79
|
+
name="installment"
|
|
80
|
+
checked={option.pk === installmentOption}
|
|
81
|
+
onChange={() => {
|
|
82
|
+
setInstallment({
|
|
83
|
+
installment: option.pk
|
|
84
|
+
});
|
|
85
|
+
setInstallmentOption(option.pk);
|
|
86
|
+
}}
|
|
87
|
+
>
|
|
88
|
+
<span className="w-full flex items-center justify-start pl-2">
|
|
89
|
+
<span className="text-xs text-black-800 transition-all">
|
|
90
|
+
{option.label}
|
|
91
|
+
</span>
|
|
92
|
+
</span>
|
|
93
|
+
</Radio>
|
|
94
|
+
</td>
|
|
95
|
+
<td className="text-xs font-normal border-e border-solid border-gray-400 px-2 py-2 text-right">
|
|
96
|
+
<Price value={option.monthly_price_with_accrued_interest} />
|
|
97
|
+
</td>
|
|
98
|
+
<td className="text-xs font-normal border-e border-solid border-gray-400 px-2 py-2 text-right">
|
|
99
|
+
<Price value={option.price_with_accrued_interest} />
|
|
100
|
+
</td>
|
|
101
|
+
</tr>
|
|
102
|
+
))}
|
|
103
|
+
</tbody>
|
|
104
|
+
</table>
|
|
105
|
+
</div>
|
|
106
|
+
{error && (
|
|
107
|
+
<div className="px-6 mt-4 text-sm text-error">{error.message}</div>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export default SavedCardInstallments;
|
package/src/index.tsx
ADDED
package/src/redux/api.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { CheckoutContext, PreOrder } from '@akinon/next/types';
|
|
2
|
+
import { api } from '@akinon/next/data/client/api';
|
|
3
|
+
import { buildClientRequestUrl } from '@akinon/next/utils';
|
|
4
|
+
import { setPaymentStepBusy } from '@akinon/next/redux/reducers/checkout';
|
|
5
|
+
import { SavedCard } from './reducer';
|
|
6
|
+
|
|
7
|
+
interface CheckoutResponse {
|
|
8
|
+
pre_order?: PreOrder;
|
|
9
|
+
errors: {
|
|
10
|
+
non_field_errors: string;
|
|
11
|
+
};
|
|
12
|
+
context_list?: CheckoutContext[];
|
|
13
|
+
template_name?: string;
|
|
14
|
+
redirect_url?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface GetResponse<T> {
|
|
18
|
+
count: number;
|
|
19
|
+
next: null;
|
|
20
|
+
previous: null;
|
|
21
|
+
results: T[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type SetSavedCardRequest = {
|
|
25
|
+
card: SavedCard;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type DeleteSavedCardRequest = number;
|
|
29
|
+
|
|
30
|
+
type SetInstallmentRequest = {
|
|
31
|
+
installment: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type CompleteSavedCardRequest = {
|
|
35
|
+
agreement: boolean;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const savedCardApi = api.injectEndpoints({
|
|
39
|
+
endpoints: (build) => ({
|
|
40
|
+
getSavedCards: build.query<GetResponse<SavedCard>, void>({
|
|
41
|
+
query: () => ({
|
|
42
|
+
url: buildClientRequestUrl('/users/saved-cards/')
|
|
43
|
+
}),
|
|
44
|
+
async onQueryStarted(arg, { dispatch, queryFulfilled }) {
|
|
45
|
+
dispatch(setPaymentStepBusy(true));
|
|
46
|
+
await queryFulfilled;
|
|
47
|
+
dispatch(setPaymentStepBusy(false));
|
|
48
|
+
}
|
|
49
|
+
}),
|
|
50
|
+
deleteSavedCard: build.mutation<
|
|
51
|
+
GetResponse<SavedCard>,
|
|
52
|
+
DeleteSavedCardRequest
|
|
53
|
+
>({
|
|
54
|
+
query: (cardId) => ({
|
|
55
|
+
url: buildClientRequestUrl(`/users/saved-cards/${cardId}`),
|
|
56
|
+
method: 'DELETE'
|
|
57
|
+
}),
|
|
58
|
+
async onQueryStarted(arg, { dispatch, queryFulfilled }) {
|
|
59
|
+
dispatch(setPaymentStepBusy(true));
|
|
60
|
+
await queryFulfilled;
|
|
61
|
+
dispatch(setPaymentStepBusy(false));
|
|
62
|
+
}
|
|
63
|
+
}),
|
|
64
|
+
setSavedCard: build.mutation<CheckoutResponse, SetSavedCardRequest>({
|
|
65
|
+
query: ({ card }) => ({
|
|
66
|
+
url: buildClientRequestUrl(
|
|
67
|
+
`/orders/checkout?page=SavedCardSelectionPage`,
|
|
68
|
+
{
|
|
69
|
+
useFormData: true
|
|
70
|
+
}
|
|
71
|
+
),
|
|
72
|
+
method: 'POST',
|
|
73
|
+
body: {
|
|
74
|
+
card: card.token
|
|
75
|
+
}
|
|
76
|
+
}),
|
|
77
|
+
async onQueryStarted({ card }, { dispatch, queryFulfilled }) {
|
|
78
|
+
dispatch(setPaymentStepBusy(true));
|
|
79
|
+
await queryFulfilled;
|
|
80
|
+
dispatch(setPaymentStepBusy(false));
|
|
81
|
+
}
|
|
82
|
+
}),
|
|
83
|
+
setSavedCardInstallmentOption: build.mutation<
|
|
84
|
+
CheckoutResponse,
|
|
85
|
+
SetInstallmentRequest
|
|
86
|
+
>({
|
|
87
|
+
query: ({ installment }) => ({
|
|
88
|
+
url: buildClientRequestUrl(
|
|
89
|
+
`/orders/checkout?page=SavedCardInstallmentSelectionPage`,
|
|
90
|
+
{
|
|
91
|
+
useFormData: true
|
|
92
|
+
}
|
|
93
|
+
),
|
|
94
|
+
method: 'POST',
|
|
95
|
+
body: {
|
|
96
|
+
installment
|
|
97
|
+
}
|
|
98
|
+
}),
|
|
99
|
+
async onQueryStarted(arg, { dispatch, queryFulfilled }) {
|
|
100
|
+
dispatch(setPaymentStepBusy(true));
|
|
101
|
+
await queryFulfilled;
|
|
102
|
+
dispatch(setPaymentStepBusy(false));
|
|
103
|
+
}
|
|
104
|
+
}),
|
|
105
|
+
completeSavedCard: build.mutation<
|
|
106
|
+
CheckoutResponse,
|
|
107
|
+
CompleteSavedCardRequest
|
|
108
|
+
>({
|
|
109
|
+
query: ({ agreement }) => ({
|
|
110
|
+
url: buildClientRequestUrl(
|
|
111
|
+
`/orders/checkout?page=CompleteSavedCardRequest`,
|
|
112
|
+
{
|
|
113
|
+
useFormData: true
|
|
114
|
+
}
|
|
115
|
+
),
|
|
116
|
+
method: 'POST',
|
|
117
|
+
body: {
|
|
118
|
+
agreement
|
|
119
|
+
}
|
|
120
|
+
}),
|
|
121
|
+
async onQueryStarted(arg, { dispatch, queryFulfilled }) {
|
|
122
|
+
dispatch(setPaymentStepBusy(true));
|
|
123
|
+
await queryFulfilled;
|
|
124
|
+
dispatch(setPaymentStepBusy(false));
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
export const {
|
|
131
|
+
useGetSavedCardsQuery,
|
|
132
|
+
useSetSavedCardMutation,
|
|
133
|
+
useSetSavedCardInstallmentOptionMutation,
|
|
134
|
+
useCompleteSavedCardMutation,
|
|
135
|
+
useDeleteSavedCardMutation
|
|
136
|
+
} = savedCardApi;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createSlice } from '@reduxjs/toolkit';
|
|
2
|
+
|
|
3
|
+
export type SavedCard = {
|
|
4
|
+
id: number;
|
|
5
|
+
name: string;
|
|
6
|
+
masked_card_number: string;
|
|
7
|
+
token: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export interface SavedCardState {
|
|
11
|
+
cards?: Array<SavedCard>;
|
|
12
|
+
deletion: {
|
|
13
|
+
id: number;
|
|
14
|
+
isModalVisible: boolean;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const initialState: SavedCardState = {
|
|
19
|
+
cards: undefined,
|
|
20
|
+
deletion: {
|
|
21
|
+
id: null,
|
|
22
|
+
isModalVisible: false
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const savedCardSlice = createSlice({
|
|
27
|
+
name: 'savedCard',
|
|
28
|
+
initialState,
|
|
29
|
+
reducers: {
|
|
30
|
+
setCards(state, { payload }) {
|
|
31
|
+
state.cards = payload;
|
|
32
|
+
},
|
|
33
|
+
setDeletionModalId(state, { payload }) {
|
|
34
|
+
state.deletion.id = payload;
|
|
35
|
+
},
|
|
36
|
+
setDeletionModalVisible(state, { payload }) {
|
|
37
|
+
state.deletion.isModalVisible = payload;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export const { setCards, setDeletionModalId, setDeletionModalVisible } =
|
|
43
|
+
savedCardSlice.actions;
|
|
44
|
+
|
|
45
|
+
export default savedCardSlice.reducer;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { ReactElement } from 'react';
|
|
2
|
+
|
|
3
|
+
export type InstallmentTexts = {
|
|
4
|
+
title?: string;
|
|
5
|
+
payments?: string;
|
|
6
|
+
per_month?: string;
|
|
7
|
+
total?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type DeletePopupTexts = {
|
|
11
|
+
title?: string;
|
|
12
|
+
delete_button?: string;
|
|
13
|
+
cancel_button?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type ErrorTexts = {
|
|
17
|
+
required?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type SavedCardOptionTexts = {
|
|
21
|
+
title?: string;
|
|
22
|
+
button?: string;
|
|
23
|
+
installment?: InstallmentTexts;
|
|
24
|
+
deletePopup?: DeletePopupTexts;
|
|
25
|
+
errors?: ErrorTexts;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type CardSelectionSectionProps = {
|
|
29
|
+
title: string;
|
|
30
|
+
cards: any[];
|
|
31
|
+
selectedCard: any;
|
|
32
|
+
onSelect: (card: any) => void;
|
|
33
|
+
register: any;
|
|
34
|
+
errors: any;
|
|
35
|
+
dispatch: any;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type InstallmentSectionProps = {
|
|
39
|
+
title: string;
|
|
40
|
+
selectedCard: any;
|
|
41
|
+
installmentOptions: any;
|
|
42
|
+
translations: InstallmentTexts;
|
|
43
|
+
errors: any;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type AgreementAndSubmitProps = {
|
|
47
|
+
agreementCheckbox: ReactElement | undefined;
|
|
48
|
+
control: any;
|
|
49
|
+
errors: any;
|
|
50
|
+
formError: any;
|
|
51
|
+
buttonText: string;
|
|
52
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const getCreditCardType = (maskedCardNumber: string): string => {
|
|
2
|
+
const cardNumber = maskedCardNumber.replace(/\D/g, '');
|
|
3
|
+
|
|
4
|
+
if (/^4/.test(cardNumber)) {
|
|
5
|
+
return 'visa';
|
|
6
|
+
} else if (/^5[1-5]/.test(cardNumber)) {
|
|
7
|
+
return 'mastercard';
|
|
8
|
+
} else if (/^3[47]/.test(cardNumber)) {
|
|
9
|
+
return 'amex';
|
|
10
|
+
} else if (/^9/.test(cardNumber)) {
|
|
11
|
+
return 'troy';
|
|
12
|
+
} else {
|
|
13
|
+
return 'other';
|
|
14
|
+
}
|
|
15
|
+
};
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as yup from 'yup';
|
|
4
|
+
import { yupResolver } from '@hookform/resolvers/yup';
|
|
5
|
+
import { useForm } from 'react-hook-form';
|
|
6
|
+
import React, { cloneElement, ReactElement, useEffect, useState } from 'react';
|
|
7
|
+
import { useAppDispatch, useAppSelector } from '@akinon/next/redux/hooks';
|
|
8
|
+
import { RootState } from 'projectzeronext/src/redux/store';
|
|
9
|
+
import {
|
|
10
|
+
useCompleteSavedCardMutation,
|
|
11
|
+
useGetSavedCardsQuery,
|
|
12
|
+
useSetSavedCardMutation
|
|
13
|
+
} from '../redux/api';
|
|
14
|
+
import { Button, Icon, Image } from '@akinon/next/components';
|
|
15
|
+
import { getCreditCardType } from '../utils';
|
|
16
|
+
|
|
17
|
+
import amex from '../../assets/img/amex.jpg';
|
|
18
|
+
import mastercard from '../../assets/img/mastercard.png';
|
|
19
|
+
import other from '../../assets/img/other.png';
|
|
20
|
+
import troy from '../../assets/img/troy.png';
|
|
21
|
+
import visa from '../../assets/img/visa.png';
|
|
22
|
+
|
|
23
|
+
import SavedCardInstallments from '../components/installments';
|
|
24
|
+
import {
|
|
25
|
+
setCards,
|
|
26
|
+
setDeletionModalId,
|
|
27
|
+
setDeletionModalVisible
|
|
28
|
+
} from '../redux/reducer';
|
|
29
|
+
import { DeleteConfirmationModal } from '../components/delete-confirmation-modal';
|
|
30
|
+
import {
|
|
31
|
+
AgreementAndSubmitProps,
|
|
32
|
+
CardSelectionSectionProps,
|
|
33
|
+
ErrorTexts,
|
|
34
|
+
InstallmentSectionProps,
|
|
35
|
+
SavedCardOptionTexts
|
|
36
|
+
} from '../types';
|
|
37
|
+
|
|
38
|
+
const cardImages = { amex, mastercard, troy, visa, other };
|
|
39
|
+
|
|
40
|
+
type SavedCardOptionProps = {
|
|
41
|
+
texts?: SavedCardOptionTexts;
|
|
42
|
+
agreementCheckbox?: ReactElement;
|
|
43
|
+
customRender?: {
|
|
44
|
+
cardSelectionSection?: (props: CardSelectionSectionProps) => ReactElement;
|
|
45
|
+
installmentSection?: (props: InstallmentSectionProps) => ReactElement;
|
|
46
|
+
agreementAndSubmit?: (props: AgreementAndSubmitProps) => ReactElement;
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const defaultTranslations: SavedCardOptionTexts = {
|
|
51
|
+
title: 'Pay with Saved Card',
|
|
52
|
+
button: 'Continue with selected card',
|
|
53
|
+
installment: {
|
|
54
|
+
title: 'Installment Options'
|
|
55
|
+
},
|
|
56
|
+
errors: {
|
|
57
|
+
required: 'This field is required'
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const mergeTranslations = (
|
|
62
|
+
customTranslations: SavedCardOptionTexts,
|
|
63
|
+
defaultTranslations: SavedCardOptionTexts
|
|
64
|
+
) => {
|
|
65
|
+
return {
|
|
66
|
+
...defaultTranslations,
|
|
67
|
+
...customTranslations,
|
|
68
|
+
installment: {
|
|
69
|
+
...defaultTranslations.installment,
|
|
70
|
+
...customTranslations.installment
|
|
71
|
+
},
|
|
72
|
+
errors: {
|
|
73
|
+
...defaultTranslations.errors,
|
|
74
|
+
...customTranslations.errors
|
|
75
|
+
},
|
|
76
|
+
deletePopup: {
|
|
77
|
+
...defaultTranslations.deletePopup,
|
|
78
|
+
...customTranslations.deletePopup
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const createFormSchema = (errors: ErrorTexts) =>
|
|
84
|
+
yup.object().shape({
|
|
85
|
+
card: yup
|
|
86
|
+
.string()
|
|
87
|
+
.required(errors?.required ?? defaultTranslations.errors.required)
|
|
88
|
+
.typeError(errors?.required ?? defaultTranslations.errors.required),
|
|
89
|
+
agreement: yup
|
|
90
|
+
.boolean()
|
|
91
|
+
.oneOf([true], errors?.required ?? defaultTranslations.errors.required)
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const SavedCardOption = ({
|
|
95
|
+
texts = defaultTranslations,
|
|
96
|
+
agreementCheckbox,
|
|
97
|
+
customRender
|
|
98
|
+
}: SavedCardOptionProps) => {
|
|
99
|
+
const mergedTexts = mergeTranslations(texts, defaultTranslations);
|
|
100
|
+
|
|
101
|
+
const [selectedCard, setSelectedCard] = useState(null);
|
|
102
|
+
const [formError, setFormError] = useState(null);
|
|
103
|
+
const dispatch = useAppDispatch();
|
|
104
|
+
const { data: savedCards } = useGetSavedCardsQuery();
|
|
105
|
+
const [completeSavedCard] = useCompleteSavedCardMutation();
|
|
106
|
+
const [setSavedCard] = useSetSavedCardMutation();
|
|
107
|
+
const installmentOptions = useAppSelector(
|
|
108
|
+
(state: RootState) => state.checkout.installmentOptions
|
|
109
|
+
);
|
|
110
|
+
const cards = useAppSelector((state: RootState) => state.savedCard.cards);
|
|
111
|
+
|
|
112
|
+
const {
|
|
113
|
+
register,
|
|
114
|
+
handleSubmit,
|
|
115
|
+
control,
|
|
116
|
+
formState: { errors }
|
|
117
|
+
} = useForm({
|
|
118
|
+
resolver: yupResolver(createFormSchema(mergedTexts.errors))
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const handleCardSelection = async (card) => {
|
|
122
|
+
await setSavedCard({ card }).unwrap();
|
|
123
|
+
setSelectedCard(card);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const onSubmit = async () => {
|
|
127
|
+
try {
|
|
128
|
+
await completeSavedCard({ agreement: true });
|
|
129
|
+
} catch (error) {
|
|
130
|
+
setFormError(error);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
if (savedCards?.results && !cards?.length) {
|
|
136
|
+
dispatch(setCards(savedCards.results));
|
|
137
|
+
}
|
|
138
|
+
}, [savedCards]);
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<>
|
|
142
|
+
<form className="flex flex-wrap w-full" onSubmit={handleSubmit(onSubmit)}>
|
|
143
|
+
<div className="w-full flex flex-col xl:w-6/10">
|
|
144
|
+
{customRender?.cardSelectionSection ? (
|
|
145
|
+
customRender.cardSelectionSection({
|
|
146
|
+
title: mergedTexts.title,
|
|
147
|
+
cards,
|
|
148
|
+
selectedCard,
|
|
149
|
+
onSelect: handleCardSelection,
|
|
150
|
+
register,
|
|
151
|
+
errors,
|
|
152
|
+
dispatch
|
|
153
|
+
})
|
|
154
|
+
) : (
|
|
155
|
+
<CardSelectionSection
|
|
156
|
+
title={mergedTexts.title}
|
|
157
|
+
cards={cards}
|
|
158
|
+
selectedCard={selectedCard}
|
|
159
|
+
onSelect={handleCardSelection}
|
|
160
|
+
register={register}
|
|
161
|
+
errors={errors}
|
|
162
|
+
dispatch={dispatch}
|
|
163
|
+
/>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
<div className="w-full xl:w-4/10 xl:border-l xl:border-t-0">
|
|
168
|
+
{customRender?.installmentSection ? (
|
|
169
|
+
customRender.installmentSection({
|
|
170
|
+
title: mergedTexts.installment?.title,
|
|
171
|
+
selectedCard,
|
|
172
|
+
installmentOptions,
|
|
173
|
+
translations: mergedTexts.installment,
|
|
174
|
+
errors: errors.installment
|
|
175
|
+
})
|
|
176
|
+
) : (
|
|
177
|
+
<InstallmentSection
|
|
178
|
+
title={mergedTexts.installment?.title}
|
|
179
|
+
selectedCard={selectedCard}
|
|
180
|
+
installmentOptions={installmentOptions}
|
|
181
|
+
translations={mergedTexts.installment}
|
|
182
|
+
errors={errors.installment}
|
|
183
|
+
/>
|
|
184
|
+
)}
|
|
185
|
+
|
|
186
|
+
{customRender?.agreementAndSubmit ? (
|
|
187
|
+
customRender.agreementAndSubmit({
|
|
188
|
+
agreementCheckbox,
|
|
189
|
+
control,
|
|
190
|
+
errors,
|
|
191
|
+
formError,
|
|
192
|
+
buttonText: mergedTexts.button
|
|
193
|
+
})
|
|
194
|
+
) : (
|
|
195
|
+
<AgreementAndSubmit
|
|
196
|
+
agreementCheckbox={agreementCheckbox}
|
|
197
|
+
control={control}
|
|
198
|
+
errors={errors}
|
|
199
|
+
formError={formError}
|
|
200
|
+
buttonText={mergedTexts.button}
|
|
201
|
+
/>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
</form>
|
|
205
|
+
<DeleteConfirmationModal translations={mergedTexts.deletePopup} />
|
|
206
|
+
</>
|
|
207
|
+
);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const CardSelectionSection = ({
|
|
211
|
+
title,
|
|
212
|
+
cards,
|
|
213
|
+
selectedCard,
|
|
214
|
+
onSelect,
|
|
215
|
+
register,
|
|
216
|
+
errors,
|
|
217
|
+
dispatch
|
|
218
|
+
}) => (
|
|
219
|
+
<div className="border-solid border-gray-400 px-4 py-2">
|
|
220
|
+
<span className="text-black-800 text-lg font-medium">{title}</span>
|
|
221
|
+
<ul className="mt-4 text-xs w-full">
|
|
222
|
+
{cards?.map((card) => (
|
|
223
|
+
<li
|
|
224
|
+
key={card.token}
|
|
225
|
+
className="p-4 mb-2 border-2 border-gray-200 flex justify-between items-center cursor-pointer"
|
|
226
|
+
onClick={() => onSelect(card)}
|
|
227
|
+
>
|
|
228
|
+
<input
|
|
229
|
+
name="card"
|
|
230
|
+
type="radio"
|
|
231
|
+
checked={selectedCard?.token === card.token}
|
|
232
|
+
value={card.token}
|
|
233
|
+
id={card.token}
|
|
234
|
+
className="mr-2"
|
|
235
|
+
onChange={() => {}}
|
|
236
|
+
{...register('card')}
|
|
237
|
+
/>
|
|
238
|
+
<CardLabel card={card} />
|
|
239
|
+
<DeleteIcon
|
|
240
|
+
onClick={() => {
|
|
241
|
+
dispatch(setDeletionModalId(card.id));
|
|
242
|
+
dispatch(setDeletionModalVisible(true));
|
|
243
|
+
}}
|
|
244
|
+
/>
|
|
245
|
+
</li>
|
|
246
|
+
))}
|
|
247
|
+
</ul>
|
|
248
|
+
{errors.card && <ErrorText message={errors.card?.message} />}
|
|
249
|
+
</div>
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const CardLabel = ({ card }) => (
|
|
253
|
+
<label className="flex flex-col w-full cursor-pointer md:flex-row md:items-center md:justify-between">
|
|
254
|
+
<p className="w-full text-[10px] lg:w-1/3">{card.masked_card_number}</p>
|
|
255
|
+
<Image
|
|
256
|
+
className="w-8 h-6 object-contain flex items-center justify-center"
|
|
257
|
+
width={50}
|
|
258
|
+
height={50}
|
|
259
|
+
src={cardImages[getCreditCardType(card.masked_card_number)].src}
|
|
260
|
+
alt={card.name}
|
|
261
|
+
/>
|
|
262
|
+
</label>
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const DeleteIcon = ({ onClick }) => (
|
|
266
|
+
<span className="cursor-pointer p-1" onClick={onClick}>
|
|
267
|
+
<Icon name="close" size={12} />
|
|
268
|
+
</span>
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
const InstallmentSection = ({
|
|
272
|
+
title,
|
|
273
|
+
selectedCard,
|
|
274
|
+
installmentOptions,
|
|
275
|
+
translations,
|
|
276
|
+
errors
|
|
277
|
+
}) => (
|
|
278
|
+
<div className="border-solid border-gray-400 bg-white">
|
|
279
|
+
<div className="px-4 py-2">
|
|
280
|
+
<span className="text-black-800 text-lg font-medium">{title}</span>
|
|
281
|
+
</div>
|
|
282
|
+
<SavedCardInstallments
|
|
283
|
+
selectedCard={selectedCard}
|
|
284
|
+
installmentOptions={installmentOptions}
|
|
285
|
+
translations={translations}
|
|
286
|
+
error={errors}
|
|
287
|
+
/>
|
|
288
|
+
</div>
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
const AgreementAndSubmit = ({
|
|
292
|
+
agreementCheckbox,
|
|
293
|
+
control,
|
|
294
|
+
errors,
|
|
295
|
+
formError,
|
|
296
|
+
buttonText
|
|
297
|
+
}) => (
|
|
298
|
+
<div className="flex flex-col text-xs pb-4 px-4 sm:px-6">
|
|
299
|
+
{agreementCheckbox &&
|
|
300
|
+
cloneElement(agreementCheckbox, {
|
|
301
|
+
control,
|
|
302
|
+
error: errors.agreement,
|
|
303
|
+
fieldId: 'agreement'
|
|
304
|
+
})}
|
|
305
|
+
{formError && (
|
|
306
|
+
<ErrorText message={formError.non_field_errors ?? formError.status} />
|
|
307
|
+
)}
|
|
308
|
+
<Button
|
|
309
|
+
type="submit"
|
|
310
|
+
className="group uppercase mt-4 inline-flex items-center justify-center"
|
|
311
|
+
>
|
|
312
|
+
<span>{buttonText}</span>
|
|
313
|
+
<Icon name="chevron-end" size={12} className="ml-2 h-3" />
|
|
314
|
+
</Button>
|
|
315
|
+
</div>
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
const ErrorText = ({ message }) => (
|
|
319
|
+
<div className="w-full text-xs text-start px-1 mt-3 text-error">
|
|
320
|
+
{message}
|
|
321
|
+
</div>
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
export default SavedCardOption;
|