@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 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
@@ -0,0 +1,4 @@
1
+ import savedCardReducer from './redux/reducer';
2
+ import SavedCardOption from './views/saved-card-option';
3
+
4
+ export { savedCardReducer, SavedCardOption };
@@ -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;