IMP-154: Remove deprecated logic (#282)

This commit is contained in:
Ildar Galeev 2024-03-07 13:25:15 +07:00 committed by GitHub
parent b1c4e29b4c
commit 80a3c8a611
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
426 changed files with 130 additions and 10565 deletions

View File

@ -1,5 +1,4 @@
import delay from 'checkout/utils/delay';
import guid from 'checkout/utils/guid';
import { delay, guid } from '../../common/utils';
export type FetchCapiParams = {
endpoint: string;

View File

@ -1,7 +1,6 @@
import { getNocacheValue } from 'checkout/utils';
import { AppConfig } from './app-config';
import { fetchCapi } from './fetch-capi';
import { getNocacheValue } from '../../common/utils';
export const getAppConfig = (): Promise<AppConfig> =>
fetchCapi({ endpoint: `./appConfig.json?nocache=${getNocacheValue()}` });

View File

@ -1,6 +1,5 @@
import { getNocacheValue } from 'checkout/utils';
import { fetchCapi } from './fetch-capi';
import { getNocacheValue } from '../../common/utils';
export interface Env {
version: string;

View File

@ -1,7 +1,7 @@
import { Locale } from 'checkout/locale';
import { getNocacheValue } from 'checkout/utils';
import { fetchCapi } from './fetch-capi';
import { getNocacheValue } from '../../common/utils';
export const getLocale = (locale: string): Promise<Locale> =>
fetchCapi({

View File

@ -14,4 +14,3 @@ export * from './create-payment';
export * from './get-service-provider-by-id';
export * from './shorten-url';
export * from './get-env';
export * from './p2p';

View File

@ -1,32 +0,0 @@
import guid from 'checkout/utils/guid';
export type CompleteInfo = {
invoiceId: string;
paymentId: string;
payerTransactionId?: string;
};
export const complete = async (capiEndpoint: string, accessToken: string, info: CompleteInfo): Promise<void> => {
try {
const response = await fetch(`${capiEndpoint}/p2p/payments/complete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json;charset=utf-8',
Authorization: `Bearer ${accessToken}`,
'X-Request-ID': guid(),
},
body: JSON.stringify(info),
});
if (!response.ok) {
throw new Error(`API error: ${response.status} Endpoint: ${capiEndpoint}/p2p/payments/complete`);
}
} catch (error) {
if (error instanceof Error) {
// Handle network errors or other unexpected issues
throw new Error(`Network or unexpected error during the API request to ${capiEndpoint}: ${error.message}`);
}
// Fallback for non-Error exceptions
throw new Error(`An unexpected error occurred during the API request to ${capiEndpoint}.`);
}
};

View File

@ -1,14 +0,0 @@
import { Destination } from './model';
import { fetchCapi } from '../fetch-capi';
export const getDestinations = (
capiEndpoint: string,
accessToken: string,
invoiceID: string,
paymentID: string,
gatewayID: string,
): Promise<Destination[]> =>
fetchCapi({
endpoint: `${capiEndpoint}/p2p/payments/destinations?invoiceId=${invoiceID}&paymentId=${paymentID}&gatewayId=${gatewayID}`,
accessToken,
});

View File

@ -1,13 +0,0 @@
import { Gateway } from './model';
import { fetchCapi } from '../fetch-capi';
export const getGateways = (
capiEndpoint: string,
accessToken: string,
invoiceID: string,
paymentID: string,
): Promise<Gateway[]> =>
fetchCapi({
endpoint: `${capiEndpoint}/p2p/payments/gateways?invoiceId=${invoiceID}&paymentId=${paymentID}`,
accessToken,
});

View File

@ -1,4 +0,0 @@
export * from './get-destinations';
export * from './get-gateways';
export * from './complete';
export * from './model';

View File

@ -1,19 +0,0 @@
export type Gateway = {
id: string;
name: string;
};
export type DestinationBankCard = {
destinationType: 'BankCard';
pan: string;
bankName?: string;
};
export type DestinationSBP = {
destinationType: 'DestinationSBP';
phoneNumber: string;
bankName?: string;
recipientName?: string;
};
export type Destination = DestinationBankCard | DestinationSBP;

View File

@ -1,21 +0,0 @@
import styled from 'styled-components';
import { device } from 'checkout/utils/device';
export const AppWrapper = styled.div`
position: relative;
height: 100%;
min-height: 100%;
width: 100%;
@media ${device.desktop} {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
justify-content: center;
align-items: center;
height: auto;
padding: 45px 0;
box-sizing: border-box;
}
`;

View File

@ -1,51 +0,0 @@
import { useEffect, useState } from 'react';
import { ThemeProvider } from 'styled-components';
import { useInitApp, useTheme } from 'checkout/hooks';
import { InitParams } from 'checkout/initialize';
import { AppWrapper } from './app-wrapper';
import { GlobalStyle } from './global-style';
import { InitialContext } from './initial-context';
import { LayoutLoader } from './layout-loader';
import ModalContainer from './modal-container/modal-container';
import { ModalError } from './modal-error';
import { Overlay } from './overlay';
import { ResultContext } from './result-context';
export type AppProps = {
initParams: InitParams;
onComplete: () => void;
};
export function App({ initParams, onComplete }: AppProps) {
const theme = useTheme(initParams);
const { state, init } = useInitApp();
const [isComplete, setIsComplete] = useState(null);
useEffect(() => init(initParams), [initParams]);
useEffect(() => {
if (isComplete) {
onComplete();
}
}, [isComplete]);
return (
<ThemeProvider theme={theme}>
<GlobalStyle theme={theme} />
<AppWrapper>
<Overlay />
{state.status === 'PRISTINE' && <LayoutLoader />}
{state.status === 'SUCCESS' && (
<InitialContext.Provider value={state.data}>
<ResultContext.Provider value={{ setIsComplete }}>
<ModalContainer />
</ResultContext.Provider>
</InitialContext.Provider>
)}
{state.status === 'FAILURE' && <ModalError error={state.error} />}
</AppWrapper>
</ThemeProvider>
);
}

View File

@ -1,31 +0,0 @@
import { createGlobalStyle } from 'styled-components';
import { device } from 'checkout/utils/device';
export const GlobalStyle = createGlobalStyle`
body,
html,
#app {
margin: 0;
position: relative;
height: auto;
min-height: 100%;
width: 100%;
min-width: 320px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
&._loading {
height: 100%;
}
@media ${device.desktop} {
height: 100%;
min-width: 680px;
}
}
* {
font-family: ${({ theme }) => theme.font.family};
}
`;

View File

@ -1 +0,0 @@
export * from './app';

View File

@ -1,5 +0,0 @@
import { createContext } from 'react';
import { InitialData } from 'checkout/hooks';
export const InitialContext = createContext<InitialData>(null);

View File

@ -1,42 +0,0 @@
import styled, { keyframes } from 'styled-components';
import { device } from 'checkout/utils/device';
import { Loader } from '../ui/loader';
const growth = keyframes`
from {
transform: scale(0);
}
to {
transform: scale(1);
}
`;
const LayoutLoaderWrapper = styled.div`
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
@media ${device.desktop} {
position: relative;
height: 100%;
width: 100%;
top: 0;
left: 0;
display: flex;
flex-wrap: nowrap;
flex-direction: row;
align-items: center;
justify-content: center;
transform: translate(0, 0);
animation: ${growth} 0.5s;
}
`;
export const LayoutLoader = () => (
<LayoutLoaderWrapper>
<Loader />
</LayoutLoaderWrapper>
);

View File

@ -1 +0,0 @@
export * from './modal-container';

View File

@ -1,151 +0,0 @@
import { motion } from 'framer-motion';
import { lazy, useContext, useEffect, useMemo, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import styled from 'styled-components';
import { InvoiceChangeType } from 'checkout/backend';
import { ErrorBoundaryFallback } from 'checkout/components/ui';
import { ModalName, ModalState, ResultFormInfo, ResultType } from 'checkout/hooks';
import { PayableInvoiceData, useInvoiceEvents, useModal } from 'checkout/hooks';
import isNil from 'checkout/utils/is-nil';
import { ModalContext } from './modal-context';
import { PayableInvoiceContext } from './payable-invoice-context';
import { useInteractionModel } from './use-interaction-model';
import { InitialContext } from '../initial-context';
const Modal = lazy(() => import('./modal/modal'));
const UserInteractionModal = lazy(() => import('./user-interaction-modal/user-interaction-modal'));
const Container = styled.div`
height: 100%;
position: relative;
`;
const Modals = ({ modalState }: { modalState: ModalState[] }) => {
const activeModalName = useMemo(() => modalState.find((modal) => modal.active).name, [modalState]);
return (
<ErrorBoundary fallback={<ErrorBoundaryFallback />}>
{activeModalName === ModalName.modalForms && <Modal />}
{activeModalName === ModalName.modalInteraction && <UserInteractionModal />}
</ErrorBoundary>
);
};
const ModalContainer = () => {
const {
appConfig: { capiEndpoint },
initConfig,
model: { serviceProviders, invoice, invoiceAccessToken },
availablePaymentMethods,
} = useContext(InitialContext);
const {
modalState,
toInitialState,
goToFormInfo,
prepareToPay,
prepareToRetry,
forgetPaymentAttempt,
setViewInfoError,
toInteractionState,
} = useModal({
integrationType: initConfig.integrationType,
availablePaymentMethods,
serviceProviders,
});
const [payableInvoiceData, setPayableInvoiceData] = useState<PayableInvoiceData>(null);
const { eventsState, startPolling, searchEventsChange } = useInvoiceEvents(capiEndpoint, payableInvoiceData);
const { interactionModel, setPaymentInteraction, setPaymentStarted } = useInteractionModel();
useEffect(() => {
if (initConfig.integrationType === 'invoice') {
setPayableInvoiceData({
invoice: {
id: invoice.id,
dueDate: invoice.dueDate,
externalID: invoice.externalID,
},
invoiceAccessToken,
});
}
}, []);
useEffect(() => {
if (isNil(payableInvoiceData)) return;
if (eventsState.status === 'PRISTINE') {
startPolling();
}
if (eventsState.status === 'POLLING_SUCCESS') {
const change = eventsState.payload;
switch (change.changeType) {
case InvoiceChangeType.InvoiceCreated:
if (initConfig.integrationType === 'invoice') {
toInitialState();
}
if (initConfig.integrationType === 'invoiceTemplate') {
prepareToPay();
}
break;
case InvoiceChangeType.PaymentInteractionRequested:
if (initConfig.skipUserInteraction) {
goToFormInfo(new ResultFormInfo(ResultType.hookTimeout));
break;
}
setPaymentInteraction(change);
searchEventsChange('PaymentStarted');
break;
case InvoiceChangeType.InvoiceStatusChanged:
case InvoiceChangeType.PaymentStatusChanged:
goToFormInfo(
new ResultFormInfo(ResultType.hookProcessed, {
change,
}),
);
break;
}
}
if (eventsState.status === 'POLLING_TIMEOUT') {
goToFormInfo(new ResultFormInfo(ResultType.hookTimeout));
}
if (eventsState.status === 'EVENT_CHANGE_FOUND') {
setPaymentStarted(eventsState.payload);
}
if (eventsState.status === 'FAILURE') {
goToFormInfo(
new ResultFormInfo(ResultType.hookError, {
error: eventsState.error,
}),
);
}
}, [payableInvoiceData, eventsState]);
useEffect(() => {
if (isNil(interactionModel)) return;
toInteractionState(interactionModel);
}, [interactionModel]);
return (
<motion.div animate={{ opacity: 1 }} initial={{ opacity: 0 }} transition={{ duration: 1 }}>
<Container>
<ModalContext.Provider
value={{
modalState,
goToFormInfo,
prepareToPay,
prepareToRetry,
forgetPaymentAttempt,
setViewInfoError,
}}
>
<PayableInvoiceContext.Provider value={{ payableInvoiceData, setPayableInvoiceData }}>
{/* eslint-disable-next-line react/jsx-max-depth */}
<Modals modalState={modalState}></Modals>
</PayableInvoiceContext.Provider>
</ModalContext.Provider>
</Container>
</motion.div>
);
};
export default ModalContainer;

View File

@ -1,13 +0,0 @@
import { createContext } from 'react';
import { FormInfo, ModalState } from 'checkout/hooks';
import { Direction } from 'checkout/hooks/use-modal';
export const ModalContext = createContext<{
modalState: ModalState[];
goToFormInfo: (formInfo: FormInfo, direction?: Direction) => void;
prepareToPay: () => void;
prepareToRetry: (resetFormData: boolean) => void;
forgetPaymentAttempt: () => void;
setViewInfoError: (hasError: boolean) => void;
}>(null);

View File

@ -1,26 +0,0 @@
import * as React from 'react';
import { useContext } from 'react';
import { ReactSVG } from 'react-svg';
import styled from 'styled-components';
import { InitialContext } from '../../initial-context';
const FooterWrapper = styled.footer`
padding: 16px 4px;
display: flex;
flex-direction: row-reverse;
`;
export const Footer: React.FC = () => {
const { initConfig, appConfig } = useContext(InitialContext);
const initConfigBrandless = initConfig.brandless;
const appConfigBrandless = appConfig.brandless;
if (appConfigBrandless && initConfigBrandless) {
return <></>;
}
return (
<FooterWrapper>
<ReactSVG src="/assets/logo.svg" />
</FooterWrapper>
);
};

View File

@ -1,22 +0,0 @@
import styled from 'styled-components';
import { device } from 'checkout/utils/device';
export const FormBlock = styled.div`
position: relative;
height: 100%;
width: 100%;
background: ${({ theme }) => theme.form.background};
@media ${device.desktop} {
height: auto;
min-height: auto;
width: 680px;
border-radius: 16px;
display: flex;
flex-wrap: nowrap;
flex-direction: row;
padding: 30px;
box-sizing: border-box;
}
`;

View File

@ -1,88 +0,0 @@
import { useContext, useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import { Gateway } from 'checkout/backend';
import { ApiExtensionFormInfo } from 'checkout/hooks';
import isNil from 'checkout/utils/is-nil';
import { CompletePayment } from './complete-payment';
import { Destinations } from './destinations';
import { GatewaySelector } from './gateway-selector';
import { InitialContext } from '../../../../initial-context';
import { ModalContext } from '../../../modal-context';
import { PayableInvoiceContext } from '../../../payable-invoice-context';
import { FormLoader } from '../form-loader';
import { useActiveModalForm } from '../use-active-modal-form';
const FormContainer = styled.div`
display: flex;
flex-direction: column;
gap: 24px;
min-height: 500px;
justify-content: space-between;
`;
const SelectorContainer = styled.div`
display: flex;
flex-direction: column;
gap: 24px;
`;
const ApiExtensionForm = ({ onMount }: { onMount: () => void }) => {
const {
payableInvoiceData: { invoiceAccessToken, invoice },
} = useContext(PayableInvoiceContext);
const { appConfig, locale } = useContext(InitialContext);
const { modalState } = useContext(ModalContext);
const { paymentID } = useActiveModalForm<ApiExtensionFormInfo>(modalState);
const [gateway, setGateway] = useState<Gateway | null>(null);
const [destinationStatus, setDestinationStatus] = useState<string | null>(null);
const [completeStatus, setCompleteStatus] = useState<string | null>(null);
const isLoader = useMemo(() => completeStatus === 'SUCCESS' || completeStatus === 'LOADING', [completeStatus]);
useEffect(() => {
onMount();
}, []);
return (
<>
<FormContainer>
<SelectorContainer>
<GatewaySelector
capiEndpoint={appConfig.capiEndpoint}
invoiceAccessToken={invoiceAccessToken}
invoiceID={invoice.id}
locale={locale}
paymentID={paymentID}
onSelect={setGateway}
></GatewaySelector>
{!isNil(gateway) && (
<Destinations
capiEndpoint={appConfig.capiEndpoint}
gatewayID={gateway?.id}
getDestinationsStatusChanged={setDestinationStatus}
invoiceAccessToken={invoiceAccessToken}
invoiceID={invoice.id}
locale={locale}
paymentID={paymentID}
></Destinations>
)}
</SelectorContainer>
{!isNil(gateway) && destinationStatus === 'SUCCESS' && (
<CompletePayment
capiEndpoint={appConfig.capiEndpoint}
invoiceAccessToken={invoiceAccessToken}
invoiceID={invoice.id}
locale={locale}
paymentID={paymentID}
onCompleteStatusChanged={setCompleteStatus}
/>
)}
</FormContainer>
{isLoader && <FormLoader />}
</>
);
};
export default ApiExtensionForm;

View File

@ -1,58 +0,0 @@
import styled from 'styled-components';
export const Container = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
`;
export const Row = styled.div<{ $gap?: number }>`
display: flex;
justify-content: space-between;
gap: ${({ $gap }) => `${$gap}px` || 0};
`;
export const Label = styled.p`
font-weight: 400;
font-size: 14px;
line-height: 16px;
margin: 0;
`;
export const Value = styled.p`
font-weight: 500;
font-size: 14px;
line-height: 16px;
margin: 0;
text-align: end;
`;
export const Info = styled.p`
font-weight: 500;
font-size: 14px;
line-height: 18px;
margin: 0;
`;
export const Alert = styled.div`
font-weight: 400;
font-size: 14px;
background-color: ${({ theme }) => theme.alert.background};
padding: 12px;
border-radius: 8px;
ul {
margin: 0;
padding: 0 0 0 16px;
li {
line-height: 18px;
margin: 0 0 8px 0;
}
}
p {
margin: 0;
line-height: 18px;
}
`;

View File

@ -1,53 +0,0 @@
import { useEffect } from 'react';
import styled from 'styled-components';
import { Button } from 'checkout/components/ui';
import { useComplete } from 'checkout/hooks/p2p';
import { Locale } from 'checkout/locale';
import isNil from 'checkout/utils/is-nil';
import { Info } from './common-components';
const Container = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
`;
export type CompletePaymentProps = {
locale: Locale;
capiEndpoint: string;
invoiceAccessToken: string;
invoiceID: string;
paymentID: string;
onCompleteStatusChanged?: (status: string) => void;
};
export const CompletePayment = ({
locale,
capiEndpoint,
invoiceAccessToken,
invoiceID,
paymentID,
onCompleteStatusChanged,
}: CompletePaymentProps) => {
const {
state: { status },
complete,
} = useComplete(capiEndpoint, invoiceAccessToken, invoiceID, paymentID);
useEffect(() => {
if (isNil(onCompleteStatusChanged)) return;
onCompleteStatusChanged(status);
}, [status]);
return (
<Container>
<Info>{locale['form.p2p.complete.info']}</Info>
<Button color="primary" onClick={complete}>
{locale['form.p2p.complete.button']}
</Button>
{status === 'FAILURE' && <div>{locale['form.p2p.error']}</div>}
</Container>
);
};

View File

@ -1,48 +0,0 @@
import { motion } from 'framer-motion';
import styled from 'styled-components';
export const Container = styled.div`
cursor: pointer;
height: 16px;
width: 16px;
position: relative;
display: flex;
justify-content: center;
align-items: center;
`;
const copy = (copyValue: string) => {
const tempInput = document.createElement('input');
tempInput.setAttribute('value', copyValue);
tempInput.style.position = 'absolute';
tempInput.style.left = '-1000px';
document.body.appendChild(tempInput);
tempInput.select();
document.execCommand('copy');
document.body.removeChild(tempInput);
};
export type CopyToClipboardProps = {
copyValue: string;
};
export const CopyToClipboard = ({ copyValue }: CopyToClipboardProps) => {
const handleClick = () => {
copy(copyValue);
};
return (
<Container onClick={handleClick}>
<motion.svg
fill="currentColor"
height="16"
transition={{ duration: 0.3 }}
viewBox="0 0 16 16"
whileTap={{ translateY: 3 }}
width="16"
>
<path d="M4 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2zm2-1a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1zM2 5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-1h1v1a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h1v1z" />
</motion.svg>
</Container>
);
};

View File

@ -1,28 +0,0 @@
import { DestinationBankCard } from 'checkout/backend/p2p';
import { Locale } from 'checkout/locale';
import { CopyToClipboard } from './copy-to-clipboard';
import { Container, Label, Row, Value } from '../common-components';
export type DestinationInfoBankCardInfo = {
locale: Locale;
destination: DestinationBankCard;
};
export const DestinationInfoBankCard = ({ locale, destination }: DestinationInfoBankCardInfo) => (
<Container>
<Row>
<Label>{locale['form.p2p.destination.bank.card.pan']}</Label>
<Row $gap={8}>
<Value>{destination.pan}</Value>
<CopyToClipboard copyValue={destination.pan} />
</Row>
</Row>
{destination?.bankName && (
<Row>
<Label>{locale['form.p2p.destination.bank.name']}</Label>
<Value>{destination.bankName}</Value>
</Row>
)}
</Container>
);

View File

@ -1,34 +0,0 @@
import { DestinationSBP } from 'checkout/backend';
import { Locale } from 'checkout/locale';
import { CopyToClipboard } from './copy-to-clipboard';
import { Container, Label, Row, Value } from '../common-components';
export type DestinationInfoSpbProps = {
locale: Locale;
destination: DestinationSBP;
};
export const DestinationInfoSpb = ({ locale, destination }: DestinationInfoSpbProps) => (
<Container>
<Row>
<Label>{locale['form.p2p.destination.spb.phone']}</Label>
<Row $gap={8}>
<Value>{destination.phoneNumber}</Value>
<CopyToClipboard copyValue={destination.phoneNumber} />
</Row>
</Row>
{destination?.bankName && (
<Row>
<Label>{locale['form.p2p.destination.spb.bank.name']}</Label>
<Value>{destination.bankName}</Value>
</Row>
)}
{destination?.recipientName && (
<Row>
<Label>{locale['form.p2p.destination.spb.recipient']}</Label>
<Value>{destination.recipientName}</Value>
</Row>
)}
</Container>
);

View File

@ -1,44 +0,0 @@
import { useContext, useMemo } from 'react';
import { Destination } from 'checkout/backend';
import { Locale } from 'checkout/locale';
import { formatAmount } from 'checkout/utils';
import { DestinationInfoBankCard } from './destination-info-bank-card';
import { DestinationInfoSpb } from './destination-info-spb';
import { InitialContext } from '../../../../../initial-context';
import { Info, Container, Row, Label, Value, Alert } from '../common-components';
type DestinationInfoProps = {
locale: Locale;
destination: Destination;
};
export const DestinationInfo = ({ locale, destination }: DestinationInfoProps) => {
const { amountInfo } = useContext(InitialContext);
const formattedAmount = useMemo(() => formatAmount(amountInfo), [amountInfo]);
return (
<Container>
<Alert>
<ul>
{locale['form.p2p.alert.li'].map((value, key) => (
<li key={key}>{value}</li>
))}
</ul>
<p>{locale['form.p2p.alert.p']}</p>
</Alert>
<Info>{locale['form.p2p.destination.info']}</Info>
<Row>
<Label>{locale['form.p2p.destination.amount']}</Label>
<Value>{formattedAmount}</Value>
</Row>
{destination.destinationType === 'BankCard' && (
<DestinationInfoBankCard destination={destination} locale={locale} />
)}
{destination.destinationType === 'DestinationSBP' && (
<DestinationInfoSpb destination={destination} locale={locale} />
)}
</Container>
);
};

View File

@ -1,49 +0,0 @@
import { useEffect } from 'react';
import { useDestinations } from 'checkout/hooks';
import { Locale } from 'checkout/locale';
import isNil from 'checkout/utils/is-nil';
import { DestinationInfo } from './destination-info';
export type DestinationsProps = {
locale: Locale;
capiEndpoint: string;
invoiceAccessToken: string;
invoiceID: string;
paymentID: string;
gatewayID: string;
getDestinationsStatusChanged?: (status: string) => void;
};
export const Destinations = ({
locale,
capiEndpoint,
invoiceAccessToken,
invoiceID,
paymentID,
gatewayID,
getDestinationsStatusChanged,
}: DestinationsProps) => {
const { state, getDestinations } = useDestinations(capiEndpoint, invoiceAccessToken, invoiceID, paymentID);
useEffect(() => {
getDestinations(gatewayID);
}, [gatewayID]);
useEffect(() => {
if (isNil(getDestinationsStatusChanged)) return;
getDestinationsStatusChanged(state.status);
}, [state]);
return (
<>
{state.status === 'LOADING' && <div>{locale['form.p2p.loading']}</div>}
{state.status === 'FAILURE' && <div>{locale['form.p2p.error']}</div>}
{state.status === 'SUCCESS' &&
state.data.map((destination, i) => (
<DestinationInfo key={i} destination={destination} locale={locale} />
))}
</>
);
};

View File

@ -1,62 +0,0 @@
import { useEffect, useState } from 'react';
import { Gateway } from 'checkout/backend';
import { Select } from 'checkout/components/ui';
import { useGateways } from 'checkout/hooks/p2p';
import { Locale } from 'checkout/locale';
export type GatewaySelectorProps = {
locale: Locale;
capiEndpoint: string;
invoiceAccessToken: string;
invoiceID: string;
paymentID: string;
onSelect: (gateway: Gateway | null) => void;
};
export const GatewaySelector = ({
capiEndpoint,
invoiceAccessToken,
invoiceID,
paymentID,
locale,
onSelect,
}: GatewaySelectorProps) => {
const { state, getGateways } = useGateways(capiEndpoint, invoiceAccessToken, invoiceID, paymentID);
const [isGatewaySelected, setIsGatewaySelected] = useState<boolean>(false);
useEffect(() => {
getGateways();
}, []);
useEffect(() => {
if (state.status !== 'SUCCESS') return;
state.data.length === 1 && onSelect(state.data[0]);
setIsGatewaySelected(true);
}, [state]);
return (
<>
{state.status === 'PRISTINE' && <div>{locale['form.p2p.loading']}</div>}
{state.status === 'FAILURE' && <div>{locale['form.p2p.error']}</div>}
{state.status === 'SUCCESS' && !isGatewaySelected && (
<Select
dirty={false}
error={false}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
const found = state.data.find((gateway) => gateway.id === e.target.value);
onSelect(found);
setIsGatewaySelected(true);
}}
>
<option value="">{locale['form.p2p.select.destination']}</option>
{state.data.map((gateway, i) => (
<option key={i} value={gateway.id}>
{gateway.name}
</option>
))}
</Select>
)}
</>
);
};

View File

@ -1,7 +0,0 @@
export type CardFormInputs = {
cardNumber: string;
expireDate: string;
secureCode: string;
cardHolder: string;
amount: string;
};

View File

@ -1,120 +0,0 @@
import { useContext, useEffect } from 'react';
import { useForm, SubmitHandler } from 'react-hook-form';
import { ResultFormInfo, ResultType } from 'checkout/hooks';
import { PaymentMethodName, useCreatePayment } from 'checkout/hooks';
import { isEmptyObject } from 'checkout/utils/is-empty-object';
import { CardFormInputs } from './card-form-inputs';
import { CardHolder, CardNumber, ExpireDate, SecureCode } from './fields';
import { isSecureCodeAvailable } from './is-secure-code-available';
import { InitialContext } from '../../../../initial-context';
import { ModalContext } from '../../../modal-context';
import { Amount } from '../common-fields';
import { toAmountConfig, toCardHolderConfig } from '../fields-config';
import { FormGroup } from '../form-group';
import { Header } from '../header';
import { PayButton } from '../pay-button';
const CardForm = ({ onMount }: { onMount: () => void }) => {
const {
locale,
initConfig,
model: { invoiceTemplate },
} = useContext(InitialContext);
const { setViewInfoError, goToFormInfo, prepareToPay } = useContext(ModalContext);
const { createPaymentState, setFormData } = useCreatePayment();
const {
register,
handleSubmit,
watch,
formState: { errors, dirtyFields, isSubmitted },
} = useForm<CardFormInputs>({ mode: 'onChange' });
const cardHolder = toCardHolderConfig(initConfig.requireCardHolder);
const amount = toAmountConfig(initConfig, invoiceTemplate);
useEffect(() => {
onMount();
}, []);
useEffect(() => {
if (isSubmitted && !isEmptyObject(errors)) {
setViewInfoError(true);
}
}, [isSubmitted, errors]);
useEffect(() => {
if (createPaymentState.status === 'FAILURE') {
goToFormInfo(
new ResultFormInfo(ResultType.hookError, {
error: createPaymentState.error,
}),
);
}
}, [createPaymentState]);
const isSecureCode = isSecureCodeAvailable(watch('cardNumber'));
const onSubmit: SubmitHandler<CardFormInputs> = (values) => {
prepareToPay();
setFormData({ method: PaymentMethodName.BankCard, values });
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Header title={locale['form.header.pay.card.label']} />
<FormGroup>
<CardNumber
fieldError={errors.cardNumber}
isDirty={dirtyFields.cardNumber}
locale={locale}
register={register}
watch={watch}
/>
</FormGroup>
<FormGroup $gap={10}>
<ExpireDate
fieldError={errors.expireDate}
isDirty={dirtyFields.expireDate}
locale={locale}
register={register}
/>
{isSecureCode && (
<SecureCode
cardNumber={watch('cardNumber')}
fieldError={errors.secureCode}
isDirty={dirtyFields.secureCode}
locale={locale}
obscureCardCvv={initConfig?.obscureCardCvv}
register={register}
/>
)}
</FormGroup>
{cardHolder.visible && (
<FormGroup>
<CardHolder
fieldError={errors.cardHolder}
isDirty={dirtyFields.cardHolder}
locale={locale}
register={register}
/>
</FormGroup>
)}
{amount.visible && (
<FormGroup>
<Amount
cost={amount.cost}
fieldError={errors.amount}
isDirty={dirtyFields.amount}
locale={locale}
localeCode={initConfig.locale}
register={register}
/>
</FormGroup>
)}
<PayButton />
</form>
);
};
export default CardForm;

View File

@ -1,35 +0,0 @@
import { FieldError, UseFormRegister } from 'react-hook-form';
import { Input } from 'checkout/components';
import { Locale } from 'checkout/locale';
import isNil from 'checkout/utils/is-nil';
import { formatCardHolder } from './format-card-holder';
import { validateCardHolder } from './validate-card-holder';
import { ReactComponent as UserIcon } from '../../../../../../../ui/icon/user.svg';
import { CardFormInputs } from '../../card-form-inputs';
export type CardHolderProps = {
register: UseFormRegister<CardFormInputs>;
locale: Locale;
fieldError: FieldError;
isDirty: boolean;
};
export const CardHolder = ({ register, locale, fieldError, isDirty }: CardHolderProps) => (
<Input
{...register('cardHolder', {
required: true,
validate: (value) => !validateCardHolder(value) || 'Card holder is invalid',
})}
autoComplete="cc-name"
dirty={isDirty}
error={!isNil(fieldError)}
icon={<UserIcon />}
id="card-holder-input"
mark={true}
placeholder={locale['form.input.cardholder.placeholder']}
spellCheck={false}
onInput={formatCardHolder}
/>
);

View File

@ -1,10 +0,0 @@
import { FormEvent } from 'react';
import { safeVal } from 'checkout/utils';
export const formatCardHolder = (e: FormEvent<HTMLInputElement>) => {
const target = e.currentTarget;
let value = target.value;
value = value.toUpperCase();
return safeVal(value, target);
};

View File

@ -1,7 +0,0 @@
import { isContainCardNumber } from 'checkout/utils';
const CARD_HOLDER_REGEXP = /^[a-zA-Z0-9 .,'/-]+$/;
export function validateCardHolder(value: string): boolean {
return !value || !value.trim() || !CARD_HOLDER_REGEXP.test(value) || isContainCardNumber(value);
}

View File

@ -1,52 +0,0 @@
import { FieldError, UseFormRegister, UseFormWatch } from 'react-hook-form';
import styled from 'styled-components';
import { Input } from 'checkout/components';
import { CardTypeIcon } from 'checkout/components/ui/card-type-icon';
import { Locale } from 'checkout/locale';
import isNil from 'checkout/utils/is-nil';
import { formatCardNumber } from './format-card-number';
import { validateCardNumber } from './validate-card-number';
import { ReactComponent as CardIcon } from '../../../../../../../ui/icon/card.svg';
import { CardFormInputs } from '../../card-form-inputs';
const InputContainer = styled.div`
width: 100%;
position: relative;
`;
const CardNumberInput = styled(Input)`
input {
padding-right: 50px !important;
}
`;
export type CardNumberProps = {
register: UseFormRegister<CardFormInputs>;
watch: UseFormWatch<CardFormInputs>;
locale: Locale;
fieldError: FieldError;
isDirty: boolean;
};
export const CardNumber = ({ register, locale, fieldError, isDirty, watch }: CardNumberProps) => (
<InputContainer>
<CardNumberInput
{...register('cardNumber', {
required: true,
validate: (value) => !validateCardNumber(value) || 'Card number is invalid',
})}
autoComplete="cc-number"
dirty={isDirty}
error={!isNil(fieldError)}
icon={<CardIcon />}
id="card-number-input"
mark={true}
placeholder={locale['form.input.card.placeholder']}
type="tel"
onInput={formatCardNumber}
/>
<CardTypeIcon cardNumber={watch('cardNumber')} />
</InputContainer>
);

View File

@ -1,27 +0,0 @@
import { number } from 'card-validator';
import { FormEvent } from 'react';
import { replaceFullWidthChars, safeVal } from 'checkout/utils';
function format(num: string): string {
num = num.replace(/\D/g, '');
const { card } = number(num);
if (!card) {
return num;
}
const upperLength = card.lengths[card.lengths.length - 1];
num = num.slice(0, upperLength);
const nums = num.split('');
for (const gap of card.gaps.reverse()) {
nums.splice(gap, 0, ' ');
}
return nums.join('').trim();
}
export function formatCardNumber(e: FormEvent<HTMLInputElement>): number {
const target = e.currentTarget;
let value = target.value;
value = replaceFullWidthChars(value);
value = format(value);
return safeVal(value, target);
}

View File

@ -1,5 +0,0 @@
import * as cardValidator from 'card-validator';
export function validateCardNumber(value: string): boolean {
return !cardValidator.number(value).isValid;
}

View File

@ -1,35 +0,0 @@
import { FieldError, UseFormRegister } from 'react-hook-form';
import { Input } from 'checkout/components';
import { Locale } from 'checkout/locale';
import isNil from 'checkout/utils/is-nil';
import { formatExpiry } from './format-expiry';
import { validateExpireDate } from './validate-expire-date';
import { ReactComponent as CalendarIcon } from '../../../../../../../ui/icon/calendar.svg';
import { CardFormInputs } from '../../card-form-inputs';
export type ExpireDateProps = {
register: UseFormRegister<CardFormInputs>;
locale: Locale;
fieldError: FieldError;
isDirty: boolean;
};
export const ExpireDate = ({ register, locale, fieldError, isDirty }: ExpireDateProps) => (
<Input
{...register('expireDate', {
required: true,
validate: (value) => !validateExpireDate(value) || 'Exp date is invalid',
})}
autoComplete="cc-exp"
dirty={isDirty}
error={!isNil(fieldError)}
icon={<CalendarIcon />}
id="expire-date-input"
mark={true}
placeholder={locale['form.input.expiry.placeholder']}
type="tel"
onInput={formatExpiry}
/>
);

View File

@ -1,33 +0,0 @@
import { FormEvent } from 'react';
import { replaceFullWidthChars, safeVal } from 'checkout/utils';
function format(expiry: string): string {
const parts = expiry.match(/^\D*(\d{1,2})(\D+)?(\d{1,4})?/);
if (!parts) {
return '';
}
let mon = parts[1] || '';
let sep = parts[2] || '';
const year = parts[3] || '';
if (year.length > 0) {
sep = ' / ';
} else if (sep === ' /') {
mon = mon.substring(0, 1);
sep = '';
} else if (mon.length === 2 || sep.length > 0) {
sep = ' / ';
} else if (mon.length === 1 && mon !== '0' && mon !== '1') {
mon = '0' + mon;
sep = ' / ';
}
return mon + sep + year;
}
export function formatExpiry(e: FormEvent<HTMLInputElement>): number {
const target = e.currentTarget;
let value = target.value;
value = replaceFullWidthChars(value);
value = format(value);
return safeVal(value, target);
}

View File

@ -1,23 +0,0 @@
export interface ExpiryDate {
month: number;
year: number;
}
export function cardExpiryVal(value: string): ExpiryDate {
let month;
let prefix;
let year;
let ref;
(ref = value.split(/[\s/]+/, 2)), (month = ref[0]), (year = ref[1]);
if ((year != null ? year.length : void 0) === 2 && /^\d+$/.test(year)) {
prefix = new Date().getFullYear();
prefix = prefix.toString().slice(0, 2);
year = prefix + year;
}
month = parseInt(month, 10);
year = parseInt(year, 10);
return {
month,
year,
};
}

View File

@ -1,10 +0,0 @@
import { cardExpiryVal } from './card-expiry-val';
import { validateCardExpiry } from './validate-card-expiry';
export function validateExpireDate(value: any): boolean {
if (!value) {
return true;
}
const formatVal = cardExpiryVal(value);
return !validateCardExpiry(formatVal);
}

View File

@ -1,37 +0,0 @@
import { ExpiryDate } from './card-expiry-val';
export function validateCardExpiry({ month, year }: ExpiryDate): boolean {
if (!(month && year)) {
return false;
}
const newMonth = month + '';
let newYear = year + '';
if (!/^\d+$/.test(newMonth)) {
return false;
}
if (!/^\d+$/.test(newYear)) {
return false;
}
if (!(1 <= month && month <= 12)) {
return false;
}
if (newYear.length === 2) {
if (year < 70) {
newYear = '20' + year;
} else {
newYear = '19' + year;
}
}
if (newYear.length !== 4) {
return false;
}
// TODO: Bring back the validation for the date when the world goes okay.
// const expiry = new Date(year, month);
// const currentTime = new Date();
// expiry.setMonth(expiry.getMonth() - 1);
// expiry.setMonth(expiry.getMonth() + 1, 1);
// return expiry > currentTime;
return true;
}

View File

@ -1,4 +0,0 @@
export * from './card-holder';
export * from './card-number';
export * from './expire-date';
export * from './secure-code';

View File

@ -1,11 +0,0 @@
import { number } from 'card-validator';
import { replaceFullWidthChars } from 'checkout/utils';
export function formatCVC(value: string, cardNumber: string): string {
value = replaceFullWidthChars(value);
const { card } = number(cardNumber);
const size = card?.code?.size || 4;
value = value.replace(/\D/g, '').slice(0, size);
return value;
}

View File

@ -1,49 +0,0 @@
import { number } from 'card-validator';
import { FieldError, UseFormRegister } from 'react-hook-form';
import { Input } from 'checkout/components';
import { Locale } from 'checkout/locale';
import { safeVal } from 'checkout/utils';
import isNil from 'checkout/utils/is-nil';
import { formatCVC } from './format-cvc';
import { validateSecureCode } from './validate-secure-code';
import { ReactComponent as LockIcon } from '../../../../../../../ui/icon/lock.svg';
import { CardFormInputs } from '../../card-form-inputs';
export interface SecureCodeProps {
register: UseFormRegister<CardFormInputs>;
locale: Locale;
obscureCardCvv: boolean;
cardNumber: string;
fieldError: FieldError;
isDirty: boolean;
}
const getPlaceholder = (cardNumber: string | null, locale: Locale) => {
const name = number(cardNumber)?.card?.code.name;
return name || locale['form.input.secure.placeholder'];
};
export const SecureCode = ({ cardNumber, locale, obscureCardCvv, register, fieldError, isDirty }: SecureCodeProps) => (
<Input
{...register('secureCode', {
required: true,
validate: (value) => !validateSecureCode(value, { cardNumber }) || 'Secure code is invalid',
})}
autoComplete="cc-csc"
dirty={isDirty}
error={!isNil(fieldError)}
icon={<LockIcon />}
id="secure-code-input"
mark={true}
placeholder={getPlaceholder(cardNumber, locale)}
type={obscureCardCvv ? 'password' : 'tel'}
onInput={(e) => {
const target = e.currentTarget;
const value = target.value;
const formatted = formatCVC(value, cardNumber);
return safeVal(formatted, target);
}}
/>
);

View File

@ -1,6 +0,0 @@
import { cvv, number } from 'card-validator';
export function validateSecureCode(value: string, { cardNumber }: { cardNumber: string }): boolean {
const { card } = number(cardNumber);
return !(card ? cvv(value, card.code.size) : cvv(value)).isValid;
}

View File

@ -1 +0,0 @@
export * from './card-form';

View File

@ -1,32 +0,0 @@
import { number } from 'card-validator';
import { isSecureCodeAvailable } from './is-secure-code-available';
jest.mock('card-validator');
describe('isSecureCodeAvailable', () => {
it('returns false for Uzcard', () => {
(number as jest.Mock).mockReturnValue({ card: { type: 'uzcard' } });
expect(isSecureCodeAvailable('some-uzcard-number')).toBe(false);
});
it('returns false for Humo card', () => {
(number as jest.Mock).mockReturnValue({ card: { type: 'humo' } });
expect(isSecureCodeAvailable('some-humo-number')).toBe(false);
});
it('returns true for other card types', () => {
(number as jest.Mock).mockReturnValue({ card: { type: 'visa' } });
expect(isSecureCodeAvailable('some-visa-number')).toBe(true);
});
it('returns true for invalid card number', () => {
(number as jest.Mock).mockReturnValue(null);
expect(isSecureCodeAvailable('invalid-number')).toBe(true);
});
it('returns true when card number is null or undefined', () => {
expect(isSecureCodeAvailable(null)).toBe(true);
expect(isSecureCodeAvailable(undefined)).toBe(true);
});
});

View File

@ -1,6 +0,0 @@
import { number } from 'card-validator';
export const isSecureCodeAvailable = (cardNumber: string): boolean => {
const cardType = number(cardNumber)?.card?.type;
return !['uzcard', 'humo'].includes(cardType);
};

View File

@ -1,37 +0,0 @@
import { FieldError, UseFormRegister } from 'react-hook-form';
import { InvoiceTemplateLineCostRange, InvoiceTemplateLineCostUnlim } from 'checkout/backend';
import { Input } from 'checkout/components';
import { Locale } from 'checkout/locale';
import isNil from 'checkout/utils/is-nil';
import { formatAmount } from './format-amount';
import { getPlaceholder } from './get-placeholder';
import { validateAmount } from './validate-amount';
import { ReactComponent as AmountIcon } from '../../../../../../ui/icon/amount.svg';
export type AmountProps = {
register: UseFormRegister<any>;
cost: InvoiceTemplateLineCostRange | InvoiceTemplateLineCostUnlim;
locale: Locale;
localeCode: string;
fieldError: FieldError;
isDirty: boolean;
};
export const Amount = ({ register, locale, fieldError, cost, localeCode, isDirty }: AmountProps) => (
<Input
{...register('amount', {
required: true,
validate: (value) => !validateAmount(value, cost) || 'Amount is invalid',
})}
dirty={isDirty}
error={!isNil(fieldError)}
icon={<AmountIcon />}
id="amount-input"
mark={true}
placeholder={getPlaceholder(cost, locale['form.input.amount.placeholder'], localeCode)}
type="tel"
onInput={formatAmount}
/>
);

View File

@ -1,52 +0,0 @@
import { FormEvent } from 'react';
import { replaceFullWidthChars, safeVal } from 'checkout/utils';
const createNumArr = (num: string): string[] => {
let numTempArr;
if (/^\d+(\.\d+)?$/.test(num)) {
numTempArr = num.split('.');
} else {
numTempArr = num.split(',');
}
return numTempArr;
};
const getNumType = (num: string): string => (/^\d+(\.\d+)?$/.test(num) ? '.' : ',');
const format = (num: string): string => {
let result = num.replace(/\s/g, '');
const formatReg = /\B(?=(\d{3})+(?!\d))/g;
if (/^\d+([.,])?$/.test(result)) {
result = result.replace(formatReg, ' ');
const lastChar = result.charAt(result.length - 1);
const isLastCharDot = lastChar === '.';
const isLastCharComma = lastChar === ',';
const isLastCharSpace = lastChar === ' ';
result = isLastCharDot || isLastCharComma || isLastCharSpace ? result + ' ' : result;
} else if (/^\d+([.,]\d+)?$/.test(result)) {
const numTempArr = createNumArr(result);
numTempArr[1] = numTempArr[1].slice(0, 2);
result = numTempArr.join(getNumType(result) + ' ').replace(formatReg, ' ');
} else if (result.length > 1) {
result = result.slice(0, -1).replace(formatReg, ' ');
result = createNumArr(result)
.join(getNumType(result) + ' ')
.replace(formatReg, ' ');
} else {
result = '';
}
return result;
};
export function formatAmount(e: FormEvent<HTMLInputElement>): number {
const target = e.currentTarget;
let value = target.value;
const nativeEvent = e.nativeEvent as any;
value = replaceFullWidthChars(value);
if (nativeEvent.inputType === 'deleteContentBackward') {
return safeVal(value, target);
} else {
return safeVal(format(value), target);
}
}

View File

@ -1,37 +0,0 @@
import { InvoiceTemplateLineCostRange, InvoiceTemplateLineCostUnlim } from 'checkout/backend';
import { formatAmount } from 'checkout/utils';
const toUnlimPlaceholder = (localeString: string, currency: string): string => `${localeString} ${currency}`;
const toRangePlaceholder = (cost: InvoiceTemplateLineCostRange, locale: string): string => {
const range = cost.range;
const lower = formatAmount({
minorValue: range.lowerBound,
currencyCode: cost.currency,
status: 'final',
locale,
});
const upper = formatAmount({
minorValue: range.upperBound,
currencyCode: cost.currency,
status: 'final',
locale,
});
return `${lower} - ${upper}`;
};
export const getPlaceholder = (
cost: InvoiceTemplateLineCostRange | InvoiceTemplateLineCostUnlim,
localeString: string,
localeCode: string,
): string => {
if (!cost) {
return;
}
switch (cost.costType) {
case 'InvoiceTemplateLineCostUnlim':
return toUnlimPlaceholder(localeString, 'RUB'); // TODO unlim cost type does't support currency
case 'InvoiceTemplateLineCostRange':
return toRangePlaceholder(cost as InvoiceTemplateLineCostRange, localeCode);
}
};

View File

@ -1,30 +0,0 @@
import { CostType, InvoiceTemplateLineCostRange, InvoiceTemplateLineCostUnlim } from 'checkout/backend';
import isNumber from 'checkout/utils/is-number';
import toNumber from 'checkout/utils/to-number';
function validate(amount: number, min?: number, max?: number): boolean {
if (!amount || !isNumber(amount) || amount <= 0) {
return true;
}
if (min && max) {
return !(amount >= min && amount <= max);
}
return false;
}
export const validateAmount = (
value: string,
cost: InvoiceTemplateLineCostRange | InvoiceTemplateLineCostUnlim,
): boolean => {
if (value) {
value = value.replace(/\s/g, '').replace(/,/g, '.');
}
const binded = validate.bind(null, toNumber(value) * 100);
switch (cost.costType) {
case CostType.InvoiceTemplateLineCostRange:
const range = (cost as InvoiceTemplateLineCostRange).range;
return binded(range.lowerBound, range.upperBound);
case CostType.InvoiceTemplateLineCostUnlim:
return binded();
}
};

View File

@ -1,30 +0,0 @@
import { FieldError, FieldErrorsImpl, Merge, UseFormRegister } from 'react-hook-form';
import { Input } from 'checkout/components';
import { Locale } from 'checkout/locale';
import { formatEmail, validateEmail } from 'checkout/utils';
import isNil from 'checkout/utils/is-nil';
export type EmailProps = {
register: UseFormRegister<any>;
locale: Locale;
fieldError: FieldError | Merge<FieldError, FieldErrorsImpl<any>>;
isDirty: boolean;
};
export const Email = ({ register, locale, fieldError, isDirty }: EmailProps) => (
<Input
{...register('email', {
required: true,
validate: (value) => !validateEmail(value) || 'Email is invalid',
})}
autoComplete="email"
dirty={isDirty}
error={!isNil(fieldError)}
id="email-input"
mark={true}
placeholder={locale['form.input.email.placeholder']}
type="email"
onInput={formatEmail}
/>
);

View File

@ -1,3 +0,0 @@
export * from './email';
export * from './amount';
export * from './phone';

View File

@ -1,31 +0,0 @@
import { FieldError, FieldErrorsImpl, Merge, UseFormRegister } from 'react-hook-form';
import { Input } from 'checkout/components';
import { Locale } from 'checkout/locale';
import { formatPhoneNumber, validatePhone } from 'checkout/utils';
import isNil from 'checkout/utils/is-nil';
export interface PhoneProps {
register: UseFormRegister<any>;
locale: Locale;
fieldError: FieldError | Merge<FieldError, FieldErrorsImpl<any>>;
isDirty: boolean;
}
export const Phone = ({ register, locale, fieldError, isDirty }: PhoneProps) => (
<Input
{...register('phoneNumber', {
required: true,
validate: (value) => !validatePhone(value) || 'Phone number is invalid',
})}
autoComplete="tel"
dirty={isDirty}
error={!isNil(fieldError)}
id="phone-input"
mark={true}
placeholder={locale['form.input.phone.placeholder']}
type="tel"
onFocus={formatPhoneNumber}
onInput={formatPhoneNumber}
/>
);

View File

@ -1,8 +0,0 @@
import styled from 'styled-components';
export const Divider = styled.div`
height: 1px;
background-color: ${({ theme }) => theme.divider};
margin: 30px auto;
width: 40%;
`;

View File

@ -1,24 +0,0 @@
import { InvoiceTemplateLineCostRange, InvoiceTemplateLineCostUnlim } from 'checkout/backend';
export interface ItemConfig {
visible: boolean;
}
export interface AmountConfig extends ItemConfig {
cost?: InvoiceTemplateLineCostRange | InvoiceTemplateLineCostUnlim;
}
export interface EmailConfig extends ItemConfig {
value?: string;
}
export interface PhoneNumberConfig extends ItemConfig {
value?: string;
}
export interface FieldsConfig {
amount: AmountConfig;
email: EmailConfig;
cardHolder: ItemConfig;
phoneNumber: PhoneNumberConfig;
}

View File

@ -1,2 +0,0 @@
export * from './fields-config';
export * from './to-fields-config';

View File

@ -1,59 +0,0 @@
import {
InvoiceTemplate,
InvoiceTemplateLineCostRange,
InvoiceTemplateLineCostUnlim,
InvoiceTemplateSingleLine,
} from 'checkout/backend';
import { InitConfig } from 'checkout/config';
import { AmountConfig, EmailConfig, FieldsConfig, PhoneNumberConfig } from './fields-config';
const toSingleLineAmountConfig = (c: InvoiceTemplateSingleLine): AmountConfig => {
const result = { visible: false } as AmountConfig;
switch (c.price.costType) {
case 'InvoiceTemplateLineCostUnlim':
result.visible = true;
result.cost = c.price as InvoiceTemplateLineCostUnlim;
break;
case 'InvoiceTemplateLineCostRange':
result.visible = true;
result.cost = c.price as InvoiceTemplateLineCostRange;
break;
}
return result;
};
const toTemplateAmountConfig = (c: InitConfig, t: InvoiceTemplate): AmountConfig => {
switch (t.details.templateType) {
case 'InvoiceTemplateSingleLine':
return c.amount ? { visible: false } : toSingleLineAmountConfig(t.details as InvoiceTemplateSingleLine);
}
return { visible: false };
};
export const toAmountConfig = (c: InitConfig, template: InvoiceTemplate): AmountConfig => {
switch (c.integrationType) {
case 'invoiceTemplate':
return toTemplateAmountConfig(c, template);
}
return { visible: false };
};
export const toPhoneNumberConfig = (phoneNumber: string): PhoneNumberConfig => {
return phoneNumber ? { visible: false, value: phoneNumber } : { visible: true };
};
export const toEmailConfig = (email: string): EmailConfig => {
return email ? { visible: false, value: email } : { visible: true };
};
export const toCardHolderConfig = (requireCardHolder: boolean | null) => ({
visible: requireCardHolder === null ? true : requireCardHolder,
});
export const toFieldsConfig = (c: InitConfig, t: InvoiceTemplate): FieldsConfig => ({
amount: toAmountConfig(c, t),
email: toEmailConfig(c.email),
cardHolder: toCardHolderConfig(c.requireCardHolder),
phoneNumber: toPhoneNumberConfig(c.phoneNumber),
});

View File

@ -1,143 +0,0 @@
import { motion } from 'framer-motion';
import { lazy, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import styled from 'styled-components';
import { ErrorBoundaryFallback } from 'checkout/components/ui';
import { FormName, ModalForms, ModalName, SlideDirection } from 'checkout/hooks';
import { findNamed } from 'checkout/utils';
import { device } from 'checkout/utils/device';
import { FormLoader } from './form-loader';
import NoAvailablePaymentMethodForm from './no-available-payment-method-form/no-available-payment-method-form';
import RedirectForm from './redirect-form/redirect-form';
import ResultForm from './result-form/result-form';
import { ModalContext } from '../../modal-context';
const Container = styled.div`
padding: 16px;
@media ${device.desktop} {
width: 360px;
padding: 0;
}
`;
const Form = styled.div<{ height?: number }>`
position: relative;
background: #fff;
border-radius: 16px;
border: 1px solid ${({ theme }) => theme.form.border};
padding: 16px;
@media ${device.desktop} {
padding: 24px;
}
overflow: hidden;
transition: height 0.3s;
height: ${({ height }) => (height ? `${height}px` : 'auto')};
`;
const PaymentMethods = lazy(() => import('./payment-methods/payment-methods'));
const CardForm = lazy(() => import('./card-form/card-form'));
const WalletForm = lazy(() => import('./wallet-form/wallet-form'));
const WalletProviders = lazy(() => import('./wallet-providers/wallet-providers'));
const PaymentTerminalForm = lazy(() => import('./payment-terminal-form/payment-terminal-form'));
const PaymentTerminalSelectorForm = lazy(
() => import('./payment-terminal-selector-form/payment-terminal-selector-form'),
);
const QrCodeInteractionForm = lazy(() => import('./qr-code-interaction-form/qr-code-interaction-form'));
const ApiExtensionForm = lazy(() => import('./api-extension-form/api-extension-form'));
const renderForm = (name: FormName, onMount: () => void) => {
switch (name) {
case FormName.paymentMethods:
return <PaymentMethods onMount={onMount} />;
case FormName.cardForm:
return <CardForm onMount={onMount} />;
case FormName.walletForm:
return <WalletForm onMount={onMount} />;
case FormName.walletProviders:
return <WalletProviders onMount={onMount} />;
case FormName.resultForm:
return <ResultForm onMount={onMount} />;
case FormName.noAvailablePaymentMethodForm:
return <NoAvailablePaymentMethodForm onMount={onMount} />;
case FormName.redirectForm:
return <RedirectForm onMount={onMount} />;
case FormName.paymentTerminalForm:
return <PaymentTerminalForm onMount={onMount} />;
case FormName.paymentTerminalSelector:
return <PaymentTerminalSelectorForm onMount={onMount} />;
case FormName.qrCodeInteractionForm:
return <QrCodeInteractionForm onMount={onMount} />;
case FormName.apiExtensionForm:
return <ApiExtensionForm onMount={onMount} />;
default:
return null;
}
};
const toInitialPos = (slideDirection: SlideDirection): number => {
switch (slideDirection) {
case SlideDirection.left:
return -300;
case SlideDirection.right:
return 300;
default:
return 0;
}
};
const DEFAULT_HEIGHT_PX = 300;
export const FormContainer = () => {
const contentElement = useRef(null);
const [height, setHeight] = useState(0);
const { modalState } = useContext(ModalContext);
const {
formName,
viewInfo: { slideDirection, inProcess },
} = useMemo(() => {
const found = findNamed(modalState, ModalName.modalForms) as ModalForms;
return {
formName: found.formsInfo.find((item) => item.active)?.name,
viewInfo: found.viewInfo,
};
}, [modalState]);
const onMount = useCallback(() => {
const elHight = contentElement.current?.clientHeight || 0;
if (elHight !== height) {
setHeight(elHight);
}
}, [contentElement, height, setHeight]);
useEffect(() => {
setHeight(contentElement.current?.clientHeight || DEFAULT_HEIGHT_PX);
}, []);
return (
<Container>
<Form height={height}>
<motion.div
key={formName}
ref={contentElement}
animate={{ x: 0 }}
initial={{ x: toInitialPos(slideDirection) }}
transition={{ duration: 0.3 }}
>
<ErrorBoundary fallback={<ErrorBoundaryFallback />}>{renderForm(formName, onMount)}</ErrorBoundary>
{inProcess && <FormLoader />}
</motion.div>
</Form>
</Container>
);
};

View File

@ -1,12 +0,0 @@
import styled from 'styled-components';
export const FormGroup = styled.div<{
direction?: 'column' | 'row';
$gap?: number;
}>`
display: flex;
flex-wrap: nowrap;
flex-direction: ${({ direction }) => direction || 'row'};
margin-bottom: 10px;
gap: ${({ $gap }) => `${$gap}px` || 0};
`;

View File

@ -1,33 +0,0 @@
import { motion } from 'framer-motion';
import styled from 'styled-components';
import { Loader } from 'checkout/components';
const fadeIn = {
hidden: { opacity: 0 },
show: { opacity: 1, transition: { duration: 0.5 } },
exit: { opacity: 0, transition: { duration: 0.5 } },
};
const LoaderWrapper = styled.div`
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
border-radius: 16px;
display: flex;
flex-wrap: nowrap;
flex-direction: row;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.9);
`;
export const FormLoader = () => (
<motion.div animate="show" exit="exit" initial="hidden" variants={fadeIn}>
<LoaderWrapper key="form-loader" id="form-loader">
<Loader />
</LoaderWrapper>
</motion.div>
);

View File

@ -1 +0,0 @@
export * from './form-loader';

View File

@ -1,23 +0,0 @@
import {
KnownProviderCategories,
PaymentMethod,
PaymentMethodName,
PaymentTerminalPaymentMethod,
} from 'checkout/hooks';
import isNil from 'checkout/utils/is-nil';
export const getAvailableTerminalPaymentMethod = (
availablePaymentMethods: PaymentMethod[],
category: KnownProviderCategories,
): PaymentTerminalPaymentMethod | null => {
if (isNil(category)) {
return null;
}
const found = availablePaymentMethods.find((m) => {
if (m.name !== PaymentMethodName.PaymentTerminal) {
return false;
}
return (m as PaymentTerminalPaymentMethod).category === category;
});
return found ? (found as PaymentTerminalPaymentMethod) : null;
};

View File

@ -1,11 +0,0 @@
import styled from 'styled-components';
export const HeaderWrapper = styled.div`
margin-bottom: 20px;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
position: relative;
height: 20px;
`;

View File

@ -1,34 +0,0 @@
import { useContext } from 'react';
import { ChevronButton } from 'checkout/components';
import { FormInfo, ModalForms, ModalName, ModalState } from 'checkout/hooks';
import { Direction } from 'checkout/hooks';
import { findInfoWithPrevious, findNamed } from 'checkout/utils';
import { ModalContext } from '../../../modal-context';
import { HeaderWrapper } from '../header-wrapper';
import { Title } from '../title';
const getDestination = (modals: ModalState[]): FormInfo => {
const modalForms = findNamed(modals, ModalName.modalForms) as ModalForms;
const withPrevious = findInfoWithPrevious(modals);
return withPrevious ? (findNamed(modalForms.formsInfo, withPrevious.previous) as FormInfo) : null;
};
export const Header = ({ title }: { title: string }) => {
const { modalState, goToFormInfo } = useContext(ModalContext);
const destination = getDestination(modalState);
return (
<HeaderWrapper>
{destination && (
<ChevronButton
id="desktop-back-btn"
type="left"
onClick={() => goToFormInfo(destination, Direction.back)}
/>
)}
<Title>{title}</Title>
</HeaderWrapper>
);
};

View File

@ -1 +0,0 @@
export * from './header';

View File

@ -1,2 +0,0 @@
export * from './form-container';
export * from './common-fields';

View File

@ -1 +0,0 @@
export * from './no-available-payment-method-form';

View File

@ -1,25 +0,0 @@
import { useContext, useEffect } from 'react';
import styled from 'styled-components';
import { InitialContext } from '../../../../initial-context';
import { Text } from '../text';
const Container = styled.div`
padding: 80px 0;
`;
const NoAvailablePaymentMethodForm = ({ onMount }: { onMount: () => void }) => {
const { locale } = useContext(InitialContext);
useEffect(() => {
onMount();
}, []);
return (
<Container>
<Text centered={true}>{locale['info.modal.no.available.payment.method']}</Text>
</Container>
);
};
export default NoAvailablePaymentMethodForm;

View File

@ -1 +0,0 @@
export * from './pay-button';

View File

@ -1,29 +0,0 @@
import { useContext } from 'react';
import styled from 'styled-components';
import { Button } from 'checkout/components';
import { AmountInfo } from 'checkout/hooks';
import { Locale } from 'checkout/locale';
import { formatAmount } from 'checkout/utils';
import { InitialContext } from '../../../../initial-context';
const PayButtonWrapper = styled(Button)`
margin-top: 20px;
`;
const toLabel = (locale: Locale, amountInfo: AmountInfo): string => {
const amount = formatAmount(amountInfo);
const amountLabel = amount ? ` ${amount}` : '';
return `${locale['form.button.pay.label']}${amountLabel}`;
};
export const PayButton = () => {
const { locale, amountInfo } = useContext(InitialContext);
const label = toLabel(locale, amountInfo);
return (
<PayButtonWrapper color="primary" id="pay-btn" type="submit">
{label}
</PayButtonWrapper>
);
};

View File

@ -1 +0,0 @@
export * from './payment-methods';

View File

@ -1,24 +0,0 @@
import { useContext } from 'react';
import { PaymentMethodIcon, PaymentMethodTitle } from 'checkout/components/ui';
import { CardFormInfo, FormName } from 'checkout/hooks';
import { Method } from './method';
import { InitialContext } from '../../../../../initial-context';
import { ModalContext } from '../../../../modal-context';
export const BankCard = () => {
const { locale } = useContext(InitialContext);
const { goToFormInfo } = useContext(ModalContext);
const onClick = () => {
goToFormInfo(new CardFormInfo(FormName.paymentMethods));
};
return (
<Method id="bank-card-payment-method" onClick={onClick}>
<PaymentMethodIcon name="bank-card" />
<PaymentMethodTitle>{locale['form.payment.method.name.card.label']}</PaymentMethodTitle>
</Method>
);
};

View File

@ -1,11 +0,0 @@
import styled from 'styled-components';
export const Description = styled.p`
font-weight: 400;
font-size: 13px;
color: ${({ theme }) => theme.font.primaryColor};
letter-spacing: 0.1px;
line-height: 17px;
padding: 4px 0 0;
margin: 0;
`;

View File

@ -1,17 +0,0 @@
import styled from 'styled-components';
export const Method = styled.li`
border-radius: 8px;
border: 1px solid ${({ theme }) => theme.paymentMethodItem.border};
padding: 20px;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
margin-bottom: 10px;
transition: all 0.3s;
cursor: pointer;
&:hover {
border-color: ${({ theme }) => theme.paymentMethodItem.hover};
}
`;

View File

@ -1,18 +0,0 @@
import styled from 'styled-components';
import { PaymentMethod } from 'checkout/hooks';
import { Methods } from './methods';
const List = styled.ul`
margin: 0;
padding: 0;
list-style: none;
min-height: 266px;
`;
export const MethodsList = ({ methods }: { methods: PaymentMethod[] }) => (
<List>
<Methods methods={methods} />
</List>
);

View File

@ -1,38 +0,0 @@
import {
DigitalWalletPaymentMethod,
PaymentMethod,
PaymentMethodName,
PaymentTerminalPaymentMethod,
} from 'checkout/hooks';
import { assertUnreachable } from 'checkout/utils';
import { BankCard } from './bank-card';
import { PaymentTerminalMethodItems } from './payment-terminal-method-items';
import { Wallets } from './wallets';
import { WalletProviderPaymentMethodItem } from '../../wallet-provider-payment-method-item';
const Method = ({ method }: { method: PaymentMethod }) => {
switch (method.name) {
case PaymentMethodName.BankCard:
return <BankCard />;
case PaymentMethodName.PaymentTerminal:
return <PaymentTerminalMethodItems method={method as PaymentTerminalPaymentMethod} />;
case PaymentMethodName.DigitalWallet:
const { serviceProviders } = method as DigitalWalletPaymentMethod;
if (serviceProviders.length === 1) {
return <WalletProviderPaymentMethodItem serviceProvider={serviceProviders[0]} />;
}
return <Wallets />;
default:
assertUnreachable(method.name);
return null;
}
};
export const Methods = ({ methods }: { methods: PaymentMethod[] }) => (
<>
{methods.map((method, index) => (
<Method key={index} method={method} />
))}
</>
);

View File

@ -1,13 +0,0 @@
import * as React from 'react';
import { KnownProviderCategories } from 'checkout/hooks';
export const CategoryContent: React.FC<{
category: KnownProviderCategories;
}> = ({ category }) => {
switch (category) {
case 'netbanking':
return <img height="68px" src="/assets/inb-logo.jpg" width="106px"></img>;
}
return <div>{category}</div>;
};

View File

@ -1,18 +0,0 @@
import * as React from 'react';
import { PaymentTerminalPaymentMethod } from 'checkout/hooks';
import { CategoryContent } from './category-content';
import { MetadataContent } from './metadata-content';
export const Content: React.FC<{
method: PaymentTerminalPaymentMethod;
localeCode: string;
}> = ({ method, localeCode }) => {
if (method.serviceProviders.length === 1) {
return <MetadataContent localeCode={localeCode} serviceProvider={method.serviceProviders[0]} />;
}
if (method.serviceProviders.length > 1) {
return <CategoryContent category={method.category} />;
}
};

View File

@ -1,17 +0,0 @@
import * as React from 'react';
import { ServiceProvider } from 'checkout/backend';
import { getMetadata, MetadataLogo, MetadataTitle } from 'checkout/components';
export const MetadataContent: React.FC<{
serviceProvider: ServiceProvider;
localeCode: string;
}> = ({ serviceProvider, localeCode }) => {
const { logo, title } = getMetadata(serviceProvider);
return (
<>
{title && <MetadataTitle localeCode={localeCode} metadata={title} />}
{logo && <MetadataLogo metadata={logo} />}
</>
);
};

View File

@ -1,83 +0,0 @@
import { useContext, useEffect } from 'react';
import { PaymentMethodName, ServiceProvider, ServiceProviderContactInfo } from 'checkout/backend';
import { getMetadata, PaymentMethodItemContainer } from 'checkout/components/ui';
import {
FormName,
PaymentTerminalFormInfo,
PaymentTerminalSelectorFormInfo,
ResultFormInfo,
ResultType,
} from 'checkout/hooks';
import { PaymentTerminalPaymentMethod, useCreatePayment, PaymentTerminalFormValues } from 'checkout/hooks';
import isNil from 'checkout/utils/is-nil';
import { Content } from './content';
import { InitialContext } from '../../../../../../initial-context';
import { ModalContext } from '../../../../../modal-context';
export interface PaymentTerminalMethodItemProps {
method: PaymentTerminalPaymentMethod;
}
const isRequiredEmail = (contactInfo: ServiceProviderContactInfo, emailPrefilled: boolean): boolean =>
!isNil(contactInfo) && contactInfo.email === true && !emailPrefilled;
const isRequiredPhoneNumber = (contactInfo: ServiceProviderContactInfo, phoneNumberPrefilled: boolean): boolean =>
!isNil(contactInfo) && contactInfo.phoneNumber === true && !phoneNumberPrefilled;
const isRequiredPaymentTerminalForm = (
serviceProvider: ServiceProvider,
emailPrefilled: boolean,
phoneNumberPrefilled: boolean,
): boolean => {
const { form, contactInfo } = getMetadata(serviceProvider);
return (
!isNil(form) ||
isRequiredEmail(contactInfo, emailPrefilled) ||
isRequiredPhoneNumber(contactInfo, phoneNumberPrefilled)
);
};
export const PaymentTerminalMethodItem = ({ method }: PaymentTerminalMethodItemProps) => {
const { initConfig } = useContext(InitialContext);
const { goToFormInfo, prepareToPay } = useContext(ModalContext);
const emailPrefilled = !!initConfig.email;
const phoneNumberPrefilled = !!initConfig.phoneNumber;
const { createPaymentState, setFormData } = useCreatePayment();
const onClick = () => {
if (method.serviceProviders.length === 1) {
const serviceProvider = method.serviceProviders[0];
if (isRequiredPaymentTerminalForm(serviceProvider, emailPrefilled, phoneNumberPrefilled)) {
goToFormInfo(new PaymentTerminalFormInfo(serviceProvider.id, FormName.paymentMethods));
} else {
prepareToPay();
setFormData({
method: PaymentMethodName.PaymentTerminal,
values: {
provider: serviceProvider.id,
} as PaymentTerminalFormValues,
});
}
}
if (method.serviceProviders.length > 1) {
goToFormInfo(new PaymentTerminalSelectorFormInfo(method.category, FormName.paymentMethods));
}
};
useEffect(() => {
if (createPaymentState.status === 'FAILURE') {
const error = createPaymentState.error;
goToFormInfo(new ResultFormInfo(ResultType.hookError, { error }));
}
}, [createPaymentState]);
return (
<PaymentMethodItemContainer id={`${Math.floor(Math.random() * 100)}-payment-method-item`} onClick={onClick}>
<Content localeCode={initConfig.locale} method={method} />
</PaymentMethodItemContainer>
);
};

View File

@ -1,23 +0,0 @@
import { KnownProviderCategories, PaymentTerminalPaymentMethod } from 'checkout/hooks';
import { assertUnreachable } from 'checkout/utils';
import { PaymentTerminalMethodItem } from './payment-terminal-method-item';
export interface PaymentTerminalMethodItemsProps {
method: PaymentTerminalPaymentMethod;
}
export const PaymentTerminalMethodItems = ({ method }: PaymentTerminalMethodItemsProps) => {
switch (method.category) {
case KnownProviderCategories.UPI:
case KnownProviderCategories.PIX:
case KnownProviderCategories.PaymentTerminal:
case KnownProviderCategories.DigitalWallet:
case KnownProviderCategories.NetBanking:
case KnownProviderCategories.OnlineBanking:
return <PaymentTerminalMethodItem method={method} />;
default:
assertUnreachable(method.category);
return null;
}
};

View File

@ -1,3 +0,0 @@
import { FormInfo } from 'checkout/hooks';
export type SetFormInfoAction = (formInfo: FormInfo) => any;

View File

@ -1,7 +0,0 @@
import styled from 'styled-components';
export const Text = styled.div`
display: flex;
flex-direction: column;
flex-wrap: nowrap;
`;

View File

@ -1,27 +0,0 @@
import { useContext } from 'react';
import { PaymentMethodIcon, PaymentMethodTitle } from 'checkout/components/ui';
import { FormName, WalletProvidersFormInfo } from 'checkout/hooks';
import { Method } from './method';
import { Text } from './text';
import { InitialContext } from '../../../../../initial-context';
import { ModalContext } from '../../../../modal-context';
export const Wallets = () => {
const { locale } = useContext(InitialContext);
const { goToFormInfo } = useContext(ModalContext);
const onClick = () => {
goToFormInfo(new WalletProvidersFormInfo(FormName.paymentMethods));
};
return (
<Method id="wallets-payment-method" onClick={onClick}>
<PaymentMethodIcon name="wallets" />
<Text>
<PaymentMethodTitle>{locale['form.payment.method.name.wallet.label']}</PaymentMethodTitle>
</Text>
</Method>
);
};

Some files were not shown because too many files have changed in this diff Show More