@akinon/pz-saved-card 1.57.0
Sign up to get free protection for your applications and to get access to all the features.
- 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;
|