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.
Installation Method
You can use the following command to install the extension with the latest plugins:
npx @akinon/projectzero@latest --plugins
Props
texts
SavedCardOptionTexts
No
Translations and text content used throughout the component, such as titles, button text, error messages, and installment labels.
agreementCheckbox
ReactElement
No
A custom checkbox element that is rendered before submitting the form (e.g., terms and conditions checkbox).
customRender
{ cardSelectionSection, installmentSection, agreementAndSubmit }
No
An object that allows partial or full customization of internal sections by providing custom render functions.
formWrapperClassName
string
No
CSS class applied to the wrapper <form> element.
cardSelectionWrapperClassName
string
No
CSS class applied to the card selection section wrapper.
installmentWrapperClassName
string
No
CSS class applied to the installment and submit section wrapper.
formProps
React.FormHTMLAttributes<HTMLFormElement>
No
Native HTML form props to apply to the wrapper <form> element.
cardSelectionWrapperProps
React.HTMLAttributes<HTMLDivElement>
No
Props applied to the card selection wrapper <div>.
installmentWrapperProps
React.HTMLAttributes<HTMLDivElement>
No
Props applied to the installment wrapper <div>.
SavedCardOptionTexts
title
string
Title displayed above the card selection.
button
string
Submit button text.
installment
InstallmentTexts
Labels and headings for the installment section.
deletePopup
DeletePopupTexts
Translations for delete confirmation popup.
errors
ErrorTexts
Error messages used in form validation.
CustomRender
cardSelectionSection Props
title
string
Section title shown above the card list.
cards
any
Array of saved card objects fetched from state.
selectedCard
any
The currently selected card object.
onSelect
(card: any) => void
Function to call when a card is selected.
register
any
react-hook-form's register function for form integration.
errors
any
Object containing field errors.
dispatch
any
Redux dispatch function, passed for optional state updates.
installmentSection Props
title
string
Title for the installment section.
selectedCard
any
Currently selected card object.
installmentOptions
any
List of available installment options for the selected card.
translations
InstallmentTexts
Label texts (like "Per Month", "Total").
errors
any
Validation errors related to installment selection.
agreementAndSubmit Props
agreementCheckbox
ReactElement
undefined
control
any
react-hook-form control object, required for binding form state.
errors
any
Object containing validation errors.
buttonText
string
Submit button label (e.g., "Pay Now", "Continue").
Usage Examples
File Path:
views/checkout/steps/payment/options/saved-card.tsx
Default Usage
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;
CustomRender Usage
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;
Last updated
Was this helpful?