# Saved Card

Provides a secure and user-friendly interface to let customers store, select, and reuse previously saved credit cards during the checkout process. Supports custom rendering, form validation, and installment selection.

## <mark style="color:red;">**Installation Method**</mark>

You can use the following command to install the extension with the latest plugins:

```bash
npx @akinon/projectzero@latest --plugins
```

### <mark style="color:red;">**Props**</mark>

<table><thead><tr><th width="150.78515625">Prop</th><th width="190.62109375">Type</th><th width="89.33984375">Required</th><th>Description</th></tr></thead><tbody><tr><td>texts</td><td>SavedCardOptionTexts</td><td>No</td><td>Translations and text content used throughout the component, such as titles, button text, error messages, and installment labels.</td></tr><tr><td>agreementCheckbox</td><td>ReactElement</td><td>No</td><td>A custom checkbox element that is rendered before submitting the form (e.g., terms and conditions checkbox).</td></tr><tr><td>customRender</td><td>{ cardSelectionSection, installmentSection, agreementAndSubmit }</td><td>No</td><td>An object that allows partial or full customization of internal sections by providing custom render functions.</td></tr><tr><td>formWrapperClassName</td><td>string</td><td>No</td><td>CSS class applied to the wrapper &#x3C;form> element.</td></tr><tr><td>cardSelectionWrapperClassName</td><td>string</td><td>No</td><td>CSS class applied to the card selection section wrapper.</td></tr><tr><td>installmentWrapperClassName</td><td>string</td><td>No</td><td>CSS class applied to the installment and submit section wrapper.</td></tr><tr><td>formProps</td><td>React.FormHTMLAttributes&#x3C;HTMLFormElement></td><td>No</td><td>Native HTML form props to apply to the wrapper &#x3C;form> element.</td></tr><tr><td>cardSelectionWrapperProps</td><td>React.HTMLAttributes&#x3C;HTMLDivElement></td><td>No</td><td>Props applied to the card selection wrapper &#x3C;div>.</td></tr><tr><td>installmentWrapperProps</td><td>React.HTMLAttributes&#x3C;HTMLDivElement></td><td>No</td><td>Props applied to the installment wrapper &#x3C;div>.</td></tr></tbody></table>

### <mark style="color:red;">**SavedCardOptionTexts**</mark>

<table><thead><tr><th width="134.40234375">Field</th><th width="165.51171875">Type</th><th>Description</th></tr></thead><tbody><tr><td>title</td><td>string</td><td>Title displayed above the card selection.</td></tr><tr><td>button</td><td>string</td><td>Submit button text.</td></tr><tr><td>installment</td><td>InstallmentTexts</td><td>Labels and headings for the installment section.</td></tr><tr><td>deletePopup</td><td>DeletePopupTexts</td><td>Translations for delete confirmation popup.</td></tr><tr><td>errors</td><td>ErrorTexts</td><td>Error messages used in form validation.</td></tr></tbody></table>

### <mark style="color:red;">**CustomRender**</mark>

#### **cardSelectionSection Props**

<table><thead><tr><th width="182.05859375" align="center">Prop</th><th width="176.87890625" align="center">Type</th><th>Description</th></tr></thead><tbody><tr><td align="center">title</td><td align="center">string</td><td>Section title shown above the card list.</td></tr><tr><td align="center">cards</td><td align="center">any</td><td>Array of saved card objects fetched from state.</td></tr><tr><td align="center">selectedCard</td><td align="center">any</td><td>The currently selected card object.</td></tr><tr><td align="center">onSelect</td><td align="center">(card: any) => void</td><td>Function to call when a card is selected.</td></tr><tr><td align="center">register</td><td align="center">any</td><td>react-hook-form's register function for form integration.</td></tr><tr><td align="center">errors</td><td align="center">any</td><td>Object containing field errors.</td></tr><tr><td align="center">dispatch</td><td align="center">any</td><td>Redux dispatch function, passed for optional state updates.</td></tr></tbody></table>

#### **installmentSection Props**

<table><thead><tr><th width="180.015625" align="center">Prop</th><th width="173.37890625" align="center">Type</th><th>Description</th></tr></thead><tbody><tr><td align="center">title</td><td align="center">string</td><td>Title for the installment section.</td></tr><tr><td align="center">selectedCard</td><td align="center">any</td><td>Currently selected card object.</td></tr><tr><td align="center">installmentOptions</td><td align="center">any</td><td>List of available installment options for the selected card.</td></tr><tr><td align="center">translations</td><td align="center">InstallmentTexts</td><td>Label texts (like "Per Month", "Total").</td></tr><tr><td align="center">errors</td><td align="center">any</td><td>Validation errors related to installment selection.</td></tr></tbody></table>

#### **agreementAndSubmit Props**

<table><thead><tr><th width="178.14453125" align="center">Prop</th><th width="143.96875" align="center">Type</th><th>Description</th></tr></thead><tbody><tr><td align="center">agreementCheckbox</td><td align="center">ReactElement</td><td>undefined</td></tr><tr><td align="center">control</td><td align="center">any</td><td>react-hook-form control object, required for binding form state.</td></tr><tr><td align="center">errors</td><td align="center">any</td><td>Object containing validation errors.</td></tr><tr><td align="center">buttonText</td><td align="center">string</td><td>Submit button label (e.g., "Pay Now", "Continue").</td></tr></tbody></table>

## <mark style="color:red;">**Usage Examples**</mark>

**File Path:**

```bash
views/checkout/steps/payment/options/saved-card.tsx
```

### <mark style="color:red;">**Default Usage**</mark>

```javascript
import PluginModule, { Component } from '@akinon/next/components/plugin-module';

const SavedCard = () => {
 return (
   <PluginModule
     component={Component.SavedCard}
     props={{
       texts: {
         title: 'Pay with Saved Card',
         button: 'Pay Now'
       }
     }}
   />
 );
};

export default SavedCard;
```

### <mark style="color:red;">CustomRender Usage</mark>

```javascript
import {
 CardSelectionSectionProps,
 InstallmentSectionProps
} from '@akinon/pz-saved-card/src/types';
import React, { useEffect, useState } from 'react';
import { Image } from '@akinon/next/components';
import { cardImages } from '@akinon/pz-saved-card/src/views/saved-card-option';
import clsx from 'clsx';
import { twMerge } from 'tailwind-merge';
import { useSetSavedCardInstallmentOptionMutation } from '@akinon/pz-saved-card/src/redux/api';
import { getPosError } from '@akinon/next/utils';
import { useAppSelector } from '@akinon/next/redux/hooks';
import { RootState } from '@theme/redux/store';
import { useLocalization } from '@akinon/next/hooks';
import { SwiperPagination, SwiperReact, SwiperSlide } from '@theme/components';
import PluginModule, { Component } from '@akinon/next/components/plugin-module';

const getCreditCardType = (maskedCardNumber: string): string => {
 const cardNumber = maskedCardNumber.replace(/\D/g, '');

 if (/^4/.test(cardNumber)) {
   return 'visa';
 } else if (/^5[1-5]/.test(cardNumber) || /^2[2-7]/.test(cardNumber)) {
   return 'mastercard';
 } else if (/^3[47]/.test(cardNumber)) {
   return 'amex';
 } else if (/^9/.test(cardNumber)) {
   return 'troy';
 } else {
   return 'other';
 }
};

function maskCreditCard(cardNumber: string, value: string): string {
 const cleanNumber = cardNumber.replace(/\D/g, '');

 const lastFourDigits = cleanNumber.slice(-4);

 let cardType = 'Unknown';
 if (cleanNumber.startsWith('4')) {
   cardType = 'Visa';
 } else if (cleanNumber.startsWith('5') || cleanNumber.startsWith('2')) {
   cardType = 'Mastercard';
 } else if (cleanNumber.startsWith('3')) {
   cardType = 'American Express';
 } else if (cleanNumber.startsWith('6')) {
   cardType = 'Discover';
 }

 if (value == 'lastFourDigits') return `* ${lastFourDigits}`;
 if ((value = 'cardType')) return cardType;
}

const SavedCard = () => {
 const [viewCounter, setViewCounter] = useState(3);
 const [formError, setFormError] = useState(null);

 const handleViewMore = () => {
   viewCounter === 3 ? setViewCounter(99) : setViewCounter(3);
 };

 useEffect(() => {
   const posErrors = getPosError();

   if (posErrors) {
     setFormError(posErrors);
   }
 }, []);

 return (
   <PluginModule
     component={Component.SavedCard}
     props={{
       texts: {
         title: 'Pay with Saved Card',
         button: 'Pay Now'
       },
       formProps: {
         id: 'paymentForm'
       },
       cardSelectionWrapperClassName: 'w-full',
       installmentWrapperClassName: '',
       customRender: {
         cardSelectionSection: ({
           cards,
           onSelect,
           selectedCard,
           register,
           errors
         }: CardSelectionSectionProps) => {
           if (cards?.length === 0) {
             return (
               <div className="flex items-center gap-2.5 px-[1.875rem] py-2">
                 <span
                   id="saved-card-error"
                   className="text-sm font-bold text-gray-620"
                 >
                   No Card Added yet.
                 </span>
               </div>
             );
           }

           return (
             <div className="relative ms-[30px]">
               <SwiperReact
                 modules={[SwiperPagination]}
                 pagination={{
                   el: '.swiper-pagination',
                   type: 'bullets',
                   clickable: true,
                   bulletClass: 'swiper-pagination-bullet bg-black',
                   bulletActiveClass:
                     'swiper-pagination-bullet-active !bg-white !w-[16px] md:!w-[70px] !rounded-[10px]'
                 }}
                 slidesPerView={'auto'}
                 spaceBetween={16}
               >
                 {cards.slice(0, viewCounter).map((card) => (
                   <SwiperSlide key={card.token} className="!w-auto">
                     <li
                       className={clsx(
                         'relative flex h-[91px] min-w-[137px] cursor-pointer flex-col justify-between rounded-xl border border-gray-380 bg-white p-3.5',
                         {
                           'border-primary': selectedCard?.token === card.token
                         }
                       )}
                       onClick={() => onSelect(card)}
                     >
                       <div className="flex justify-between">
                         <input
                           name="card"
                           type="radio"
                           checked={selectedCard?.token === card.token}
                           value={card.token}
                           id={card.token}
                           onChange={() => {}}
                           {...register('card')}
                           className={twMerge(
                             'h-4 w-4 appearance-none rounded-full border border-primary ring-1 ring-transparent transition-all',
                             'checked:border-4 checked:border-primary-foreground checked:bg-primary checked:ring-primary'
                           )}
                         />
                         <Image
                           width={35}
                           height={24}
                           src={
                             cardImages[
                               getCreditCardType(card.masked_card_number)
                             ].src
                           }
                           alt={card.name}
                           className="object-contain"
                         />
                       </div>
                       <label className="flex flex-col">
                         <p className="text-xs font-semibold text-gray-620">
                           {maskCreditCard(
                             card.masked_card_number,
                             'cardType'
                           )}
                         </p>
                         <p className="text-end text-base font-semibold">
                           {maskCreditCard(
                             card.masked_card_number,
                             'lastFourDigits'
                           )}
                         </p>
                       </label>
                     </li>
                   </SwiperSlide>
                 ))}
               </SwiperReact>

               {cards && cards?.length > 3 && (
                 <p
                   className="mt-5 cursor-pointer text-base font-medium text-primary underline"
                   onClick={handleViewMore}
                 >
                   {viewCounter == 3 ? 'View All' : 'View Less'}
                 </p>
               )}
               {errors.card && (
                 <div className="mt-3 w-full px-1 text-start text-xs text-error-650">
                   {errors.card?.message}
                 </div>
               )}
               {formError?.non_field_errors && (
                 <div
                   className="mt-3 w-full px-1 text-start text-xs text-error"
                   data-testid="checkout-form-error"
                 >
                   {formError.non_field_errors}
                 </div>
               )}
               {formError?.status && (
                 <div
                   className="mt-3 w-full px-1 text-start text-xs text-error"
                   data-testid="checkout-form-error"
                 >
                   {formError.status}
                 </div>
               )}
             </div>
           );
         },
         installmentSection: ({
           selectedCard,
           installmentOptions
         }: InstallmentSectionProps) => {
           const [selectedCardToken, setSelectedCardToken] = useState(null);
           const [setInstallment] = useSetSavedCardInstallmentOptionMutation();
           const { currency } = useLocalization();
           const { basket } = useAppSelector(
             (state: RootState) => state.checkout.preOrder
           );

           const sendBankaciRequest = async (saved_card) => {
             const basket_id = basket?.pk;
             const basket_modified_date = basket?.modified_date;
             const bankaci_req = {
               basket_id: basket_id,
               basket_modified_date: basket_modified_date,
               masked_card_number: saved_card.masked_card_number,
               card_token: saved_card.token,
               currency: currency
             };
             const bankaci_response = await fetch('/api/bankaci/saved_card', {
               method: 'POST',
               headers: {
                 'Content-Type': 'application/json'
               },
               body: JSON.stringify(bankaci_req)
             });
             if (bankaci_response.status !== 200) {
               return;
             }
             const bankaci_json = await bankaci_response.json();

             return bankaci_json;
           };

           useEffect(() => {
             if (
               selectedCard &&
               installmentOptions.length > 0 &&
               selectedCardToken !== selectedCard?.token
             ) {
               sendBankaciRequest(selectedCard).finally(() => {
                 const firstOptionPk = installmentOptions[0].pk;
                 setInstallment({
                   installment: firstOptionPk
                 });
                 setSelectedCardToken(selectedCard?.token);
               });
             }
           }, [installmentOptions, setInstallment, selectedCard]);
           return <></>;
         },
         agreementAndSubmit: () => (
           <div className="mt-4 lg:hidden">
             <button
               className="hidden"
               id="checkout-saved-card-place-order"
             ></button>
           </div>
         )
       }
     }}
   />
 );
};

export default SavedCard;

```
