@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 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;