2021-10-07 16:43:51 +02:00
|
|
|
import React, { FunctionComponent, ReactNode, useEffect, useRef, useState } from 'react';
|
2021-04-08 15:21:24 +02:00
|
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import WalletLib from '../../lib/wallet';
|
|
|
|
import { WalletInfo } from '../wallet-info';
|
2021-04-09 08:39:03 +02:00
|
|
|
import { FabModal, ModalSize } from '../base/fab-modal';
|
|
|
|
import { HtmlTranslate } from '../base/html-translate';
|
2021-06-17 17:08:22 +02:00
|
|
|
import { CustomAsset, CustomAssetName } from '../../models/custom-asset';
|
2021-05-21 18:25:18 +02:00
|
|
|
import { ShoppingCart } from '../../models/payment';
|
2021-04-08 15:21:24 +02:00
|
|
|
import { PaymentSchedule } from '../../models/payment-schedule';
|
|
|
|
import { User } from '../../models/user';
|
|
|
|
import CustomAssetAPI from '../../api/custom-asset';
|
|
|
|
import PriceAPI from '../../api/price';
|
|
|
|
import WalletAPI from '../../api/wallet';
|
2021-06-01 11:01:38 +02:00
|
|
|
import { Invoice } from '../../models/invoice';
|
2021-06-01 11:24:43 +02:00
|
|
|
import SettingAPI from '../../api/setting';
|
|
|
|
import { SettingName } from '../../models/setting';
|
2022-04-01 17:48:32 +02:00
|
|
|
import { GoogleTagManager } from '../../models/gtm';
|
2021-06-01 11:24:43 +02:00
|
|
|
import { ComputePriceResult } from '../../models/price';
|
|
|
|
import { Wallet } from '../../models/wallet';
|
2021-06-30 15:32:10 +02:00
|
|
|
import FormatLib from '../../lib/format';
|
2021-04-08 15:21:24 +02:00
|
|
|
|
|
|
|
export interface GatewayFormProps {
|
|
|
|
onSubmit: () => void,
|
2021-06-01 11:01:38 +02:00
|
|
|
onSuccess: (result: Invoice|PaymentSchedule) => void,
|
2021-04-08 15:21:24 +02:00
|
|
|
onError: (message: string) => void,
|
|
|
|
customer: User,
|
|
|
|
operator: User,
|
|
|
|
className?: string,
|
2021-06-30 15:32:10 +02:00
|
|
|
paymentSchedule?: PaymentSchedule,
|
2021-05-21 18:25:18 +02:00
|
|
|
cart?: ShoppingCart,
|
2021-10-15 17:31:01 +02:00
|
|
|
updateCart?: (cart: ShoppingCart) => void,
|
2021-04-08 15:21:24 +02:00
|
|
|
formId: string,
|
|
|
|
}
|
|
|
|
|
2021-04-09 08:40:46 +02:00
|
|
|
interface AbstractPaymentModalProps {
|
2021-04-08 15:21:24 +02:00
|
|
|
isOpen: boolean,
|
|
|
|
toggleModal: () => void,
|
2021-06-01 11:01:38 +02:00
|
|
|
afterSuccess: (result: Invoice|PaymentSchedule) => void,
|
2021-10-07 16:43:51 +02:00
|
|
|
onError: (message: string) => void,
|
2021-05-21 18:25:18 +02:00
|
|
|
cart: ShoppingCart,
|
2021-10-15 17:31:01 +02:00
|
|
|
updateCart?: (cart: ShoppingCart) => void,
|
2021-04-08 15:21:24 +02:00
|
|
|
currentUser: User,
|
2021-06-28 18:17:11 +02:00
|
|
|
schedule?: PaymentSchedule,
|
2021-04-08 15:21:24 +02:00
|
|
|
customer: User,
|
|
|
|
logoFooter: ReactNode,
|
|
|
|
GatewayForm: FunctionComponent<GatewayFormProps>,
|
|
|
|
formId: string,
|
|
|
|
className?: string,
|
|
|
|
formClassName?: string,
|
2021-06-30 15:32:10 +02:00
|
|
|
title?: string,
|
|
|
|
preventCgv?: boolean,
|
2021-06-30 16:35:25 +02:00
|
|
|
preventScheduleInfo?: boolean,
|
2021-06-30 15:32:10 +02:00
|
|
|
modalSize?: ModalSize,
|
2021-04-08 15:21:24 +02:00
|
|
|
}
|
|
|
|
|
2022-04-01 17:48:32 +02:00
|
|
|
declare const GTM: GoogleTagManager;
|
|
|
|
|
2021-04-08 15:21:24 +02:00
|
|
|
/**
|
|
|
|
* This component is an abstract modal that must be extended by each payment gateway to include its payment form.
|
2021-04-09 12:09:54 +02:00
|
|
|
*
|
2022-01-17 15:24:07 +01:00
|
|
|
* This component must not be called directly but must be extended for each implemented payment gateway.
|
2021-04-09 12:09:54 +02:00
|
|
|
* @see https://reactjs.org/docs/composition-vs-inheritance.html
|
2021-04-08 15:21:24 +02:00
|
|
|
*/
|
2021-10-15 17:31:01 +02:00
|
|
|
export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, currentUser, schedule, customer, logoFooter, GatewayForm, formId, className, formClassName, title, preventCgv, preventScheduleInfo, modalSize }) => {
|
2021-04-08 15:21:24 +02:00
|
|
|
// customer's wallet
|
2021-06-01 11:24:43 +02:00
|
|
|
const [wallet, setWallet] = useState<Wallet>(null);
|
2021-04-08 15:21:24 +02:00
|
|
|
// server-computed price with all details
|
2021-06-01 11:24:43 +02:00
|
|
|
const [price, setPrice] = useState<ComputePriceResult>(null);
|
2021-04-08 15:21:24 +02:00
|
|
|
// remaining price = total price - wallet amount
|
2021-06-01 11:24:43 +02:00
|
|
|
const [remainingPrice, setRemainingPrice] = useState<number>(0);
|
2021-04-08 15:21:24 +02:00
|
|
|
// is the component ready to display?
|
2021-06-01 11:24:43 +02:00
|
|
|
const [ready, setReady] = useState<boolean>(false);
|
2021-04-08 15:21:24 +02:00
|
|
|
// errors to display in the UI (gateway errors mainly)
|
2021-06-01 11:24:43 +02:00
|
|
|
const [errors, setErrors] = useState<string>(null);
|
2021-04-08 15:21:24 +02:00
|
|
|
// are we currently processing the payment (ie. the form was submit, but the process is still running)?
|
2021-06-01 11:24:43 +02:00
|
|
|
const [submitState, setSubmitState] = useState<boolean>(false);
|
2021-04-08 15:21:24 +02:00
|
|
|
// did the user accepts the terms of services (CGV)?
|
2021-06-01 11:24:43 +02:00
|
|
|
const [tos, setTos] = useState<boolean>(false);
|
|
|
|
// currently active payment gateway
|
|
|
|
const [gateway, setGateway] = useState<string>(null);
|
2021-06-17 17:08:22 +02:00
|
|
|
// the sales conditions
|
|
|
|
const [cgv, setCgv] = useState<CustomAsset>(null);
|
2021-10-07 16:43:51 +02:00
|
|
|
// is the component mounted
|
|
|
|
const mounted = useRef(false);
|
2021-04-08 15:21:24 +02:00
|
|
|
|
|
|
|
const { t } = useTranslation('shared');
|
|
|
|
|
2021-06-01 11:24:43 +02:00
|
|
|
/**
|
2021-06-30 15:32:10 +02:00
|
|
|
* When the component loads first, get the name of the currently active payment modal
|
2021-06-01 11:24:43 +02:00
|
|
|
*/
|
|
|
|
useEffect(() => {
|
2021-10-07 16:43:51 +02:00
|
|
|
mounted.current = true;
|
2021-06-17 17:08:22 +02:00
|
|
|
CustomAssetAPI.get(CustomAssetName.CgvFile).then(asset => setCgv(asset));
|
|
|
|
SettingAPI.get(SettingName.PaymentGateway).then((setting) => {
|
2021-06-01 11:24:43 +02:00
|
|
|
// we capitalize the first letter of the name
|
2022-02-07 13:48:01 +01:00
|
|
|
if (setting.value) {
|
|
|
|
setGateway(setting.value.replace(/^\w/, (c) => c.toUpperCase()));
|
|
|
|
}
|
2021-07-01 12:34:10 +02:00
|
|
|
});
|
2021-10-07 16:43:51 +02:00
|
|
|
|
|
|
|
return () => { mounted.current = false; };
|
2021-06-01 11:24:43 +02:00
|
|
|
}, []);
|
|
|
|
|
2021-04-08 15:21:24 +02:00
|
|
|
/**
|
|
|
|
* On each display:
|
|
|
|
* - Refresh the wallet
|
|
|
|
* - Refresh the price
|
|
|
|
* - Refresh the remaining price
|
|
|
|
*/
|
|
|
|
useEffect(() => {
|
2021-05-21 18:25:18 +02:00
|
|
|
if (!cart) return;
|
|
|
|
WalletAPI.getByUser(cart.customer_id).then((wallet) => {
|
2021-04-08 15:21:24 +02:00
|
|
|
setWallet(wallet);
|
2021-05-21 18:25:18 +02:00
|
|
|
PriceAPI.compute(cart).then((res) => {
|
2021-04-08 15:21:24 +02:00
|
|
|
setPrice(res);
|
2021-06-30 15:32:10 +02:00
|
|
|
setRemainingPrice(new WalletLib(wallet).computeRemainingPrice(res.price));
|
2021-04-08 15:21:24 +02:00
|
|
|
setReady(true);
|
2021-07-01 12:34:10 +02:00
|
|
|
});
|
|
|
|
});
|
2021-05-21 18:25:18 +02:00
|
|
|
}, [cart]);
|
2021-04-08 15:21:24 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Check if there is currently an error to display
|
|
|
|
*/
|
|
|
|
const hasErrors = (): boolean => {
|
|
|
|
return errors !== null;
|
2021-07-01 12:34:10 +02:00
|
|
|
};
|
2021-04-08 15:21:24 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Check if the user accepts the Terms of Sales document
|
|
|
|
*/
|
|
|
|
const hasCgv = (): boolean => {
|
2021-06-30 15:32:10 +02:00
|
|
|
return cgv != null && !preventCgv;
|
2021-07-01 12:34:10 +02:00
|
|
|
};
|
2021-04-08 15:21:24 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Triggered when the user accepts or declines the Terms of Sales
|
|
|
|
*/
|
|
|
|
const toggleTos = (): void => {
|
|
|
|
setTos(!tos);
|
2021-07-01 12:34:10 +02:00
|
|
|
};
|
2021-04-08 15:21:24 +02:00
|
|
|
|
|
|
|
/**
|
2021-06-30 16:35:25 +02:00
|
|
|
* Check if we must display the info box about the payment schedule
|
2021-04-08 15:21:24 +02:00
|
|
|
*/
|
2021-06-30 16:35:25 +02:00
|
|
|
const hasPaymentScheduleInfo = (): boolean => {
|
|
|
|
return schedule !== undefined && !preventScheduleInfo;
|
2021-07-01 12:34:10 +02:00
|
|
|
};
|
2021-04-08 15:21:24 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Set the component as 'currently submitting'
|
|
|
|
*/
|
|
|
|
const handleSubmit = (): void => {
|
|
|
|
setSubmitState(true);
|
2021-07-01 12:34:10 +02:00
|
|
|
};
|
2021-04-08 15:21:24 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
* After sending the form with success, process the resulting payment method
|
|
|
|
*/
|
2021-06-01 11:01:38 +02:00
|
|
|
const handleFormSuccess = async (result: Invoice|PaymentSchedule): Promise<void> => {
|
2021-04-08 15:21:24 +02:00
|
|
|
setSubmitState(false);
|
2022-03-22 14:00:59 +01:00
|
|
|
GTM.trackPurchase(result.id, result.total);
|
2021-04-08 15:21:24 +02:00
|
|
|
afterSuccess(result);
|
2021-07-01 12:34:10 +02:00
|
|
|
};
|
2021-04-08 15:21:24 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
* When the payment form raises an error, it is handled by this callback which display it in the modal.
|
|
|
|
*/
|
|
|
|
const handleFormError = (message: string): void => {
|
2021-10-07 16:43:51 +02:00
|
|
|
if (mounted.current) {
|
|
|
|
setSubmitState(false);
|
|
|
|
setErrors(message);
|
|
|
|
} else {
|
|
|
|
onError(message);
|
|
|
|
}
|
2021-07-01 12:34:10 +02:00
|
|
|
};
|
2021-04-08 15:21:24 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Check the form can be submitted.
|
|
|
|
* => We're not currently already submitting the form, and, if the terms of service are enabled, the user must agree with them.
|
|
|
|
*/
|
|
|
|
const canSubmit = (): boolean => {
|
|
|
|
let terms = true;
|
|
|
|
if (hasCgv()) { terms = tos; }
|
|
|
|
return !submitState && terms;
|
2021-07-01 12:34:10 +02:00
|
|
|
};
|
2021-04-08 15:21:24 +02:00
|
|
|
|
2021-06-30 15:32:10 +02:00
|
|
|
/**
|
|
|
|
* Build the modal title. If the provided title is a shared translation key, interpolate it through the
|
|
|
|
* translation service. Otherwise, just display the provided string.
|
|
|
|
*/
|
|
|
|
const getTitle = (): string => {
|
|
|
|
if (title.match(/^app\.shared\./)) {
|
|
|
|
return t(title);
|
|
|
|
}
|
|
|
|
return title;
|
2021-07-01 12:34:10 +02:00
|
|
|
};
|
2021-04-08 15:21:24 +02:00
|
|
|
|
|
|
|
return (
|
2021-06-30 15:32:10 +02:00
|
|
|
<FabModal title={getTitle()}
|
2021-07-01 12:34:10 +02:00
|
|
|
isOpen={isOpen}
|
|
|
|
toggleModal={toggleModal}
|
|
|
|
width={modalSize}
|
|
|
|
closeButton={false}
|
|
|
|
customFooter={logoFooter}
|
|
|
|
className={`payment-modal ${className || ''}`}>
|
2021-04-08 15:21:24 +02:00
|
|
|
{ready && <div>
|
2021-05-21 18:25:18 +02:00
|
|
|
<WalletInfo cart={cart} currentUser={currentUser} wallet={wallet} price={price?.price} />
|
2021-04-08 15:21:24 +02:00
|
|
|
<GatewayForm onSubmit={handleSubmit}
|
2021-07-01 12:34:10 +02:00
|
|
|
onSuccess={handleFormSuccess}
|
|
|
|
onError={handleFormError}
|
|
|
|
operator={currentUser}
|
|
|
|
className={`gateway-form ${formClassName || ''}`}
|
|
|
|
formId={formId}
|
|
|
|
cart={cart}
|
2021-10-15 17:31:01 +02:00
|
|
|
updateCart={updateCart}
|
2021-07-01 12:34:10 +02:00
|
|
|
customer={customer}
|
|
|
|
paymentSchedule={schedule}>
|
2021-04-08 15:21:24 +02:00
|
|
|
{hasErrors() && <div className="payment-errors">
|
|
|
|
{errors}
|
|
|
|
</div>}
|
2021-06-30 16:35:25 +02:00
|
|
|
{hasPaymentScheduleInfo() && <div className="payment-schedule-info">
|
2021-10-07 16:43:51 +02:00
|
|
|
<HtmlTranslate trKey="app.shared.payment.payment_schedule_html" options={{ DEADLINES: `${schedule.items.length}`, GATEWAY: gateway }} />
|
2021-04-08 15:21:24 +02:00
|
|
|
</div>}
|
|
|
|
{hasCgv() && <div className="terms-of-sales">
|
|
|
|
<input type="checkbox" id="acceptToS" name="acceptCondition" checked={tos} onChange={toggleTos} required />
|
|
|
|
<label htmlFor="acceptToS">{ t('app.shared.payment.i_have_read_and_accept_') }
|
2021-07-01 12:34:10 +02:00
|
|
|
<a href={cgv.custom_asset_file_attributes.attachment_url} target="_blank" rel="noreferrer">
|
2021-04-08 15:21:24 +02:00
|
|
|
{ t('app.shared.payment._the_general_terms_and_conditions') }
|
|
|
|
</a>
|
|
|
|
</label>
|
|
|
|
</div>}
|
|
|
|
</GatewayForm>
|
|
|
|
{!submitState && <button type="submit"
|
2021-07-01 12:34:10 +02:00
|
|
|
disabled={!canSubmit()}
|
|
|
|
form={formId}
|
|
|
|
className="validate-btn">
|
2021-10-22 15:43:33 +02:00
|
|
|
{remainingPrice > 0 && t('app.shared.payment.confirm_payment_of_', { AMOUNT: FormatLib.price(remainingPrice) })}
|
|
|
|
{remainingPrice === 0 && t('app.shared.payment.validate')}
|
2021-04-08 15:21:24 +02:00
|
|
|
</button>}
|
|
|
|
{submitState && <div className="payment-pending">
|
|
|
|
<div className="fa-2x">
|
|
|
|
<i className="fas fa-circle-notch fa-spin" />
|
|
|
|
</div>
|
|
|
|
</div>}
|
|
|
|
</div>}
|
|
|
|
</FabModal>
|
|
|
|
);
|
2021-07-01 12:34:10 +02:00
|
|
|
};
|
2021-06-30 15:32:10 +02:00
|
|
|
|
|
|
|
AbstractPaymentModal.defaultProps = {
|
|
|
|
title: 'app.shared.payment.online_payment',
|
|
|
|
preventCgv: false,
|
2021-06-30 16:35:25 +02:00
|
|
|
preventScheduleInfo: false,
|
2021-06-30 15:32:10 +02:00
|
|
|
modalSize: ModalSize.medium
|
|
|
|
};
|