Files
bodyshop/client/src/components/card-payment-modal/card-payment-modal.component.jsx

445 lines
15 KiB
JavaScript

import { CopyFilled, DeleteFilled } from "@ant-design/icons";
import { useLazyQuery, useMutation } from "@apollo/client/react";
import { Button, Card, Col, Form, Input, message, Row, Space, Spin, Statistic } from "antd";
import axios from "axios";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { INSERT_PAYMENT_RESPONSE, QUERY_RO_AND_OWNER_BY_JOB_PKS } from "../../graphql/payment_response.queries";
import { getCurrentUser, logImEXEvent } from "../../firebase/firebase.utils";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { insertAuditTrail } from "../../redux/application/application.actions";
import { toggleModalVisible } from "../../redux/modals/modals.actions";
import { selectCardPayment } from "../../redux/modals/modals.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import CurrencyFormItemComponent from "../form-items-formatted/currency-form-item.component";
import JobSearchSelectComponent from "../job-search-select/job-search-select.component";
const mapStateToProps = createStructuredSelector({
cardPaymentModal: selectCardPayment,
bodyshop: selectBodyshop,
currentUser: getCurrentUser
});
const mapDispatchToProps = (dispatch) => ({
insertAuditTrail: ({ jobid, operation, type }) =>
dispatch(
insertAuditTrail({
jobid,
operation,
type
})
),
toggleModalVisible: () => dispatch(toggleModalVisible("cardPayment"))
});
const CardPaymentModalComponent = ({
bodyshop,
currentUser,
cardPaymentModal,
toggleModalVisible,
insertAuditTrail
}) => {
const { context, actions } = cardPaymentModal;
const [form] = Form.useForm();
const [paymentLink, setPaymentLink] = useState();
const isMountedRef = useRef(true);
const [loading, setLoading] = useState(false);
const [insertPaymentResponse] = useMutation(INSERT_PAYMENT_RESPONSE);
const { t } = useTranslation();
const notification = useNotification();
const [loadRoAndOwnerByJobPks, { data, loading: queryLoading, error: queryError, refetch, called }] = useLazyQuery(
QUERY_RO_AND_OWNER_BY_JOB_PKS,
{
fetchPolicy: "network-only",
}
);
const safeRefetchRoAndOwner = useCallback(
(vars) => {
// First run: execute the lazy query
if (!called) return loadRoAndOwnerByJobPks({ variables: vars });
// Subsequent runs: refetch expects the variables object directly (not { variables: ... })
return refetch(vars);
},
[called, loadRoAndOwnerByJobPks, refetch]
);
useEffect(() => {
return () => {
isMountedRef.current = false;
};
}, []);
const setLoadingSafe = useCallback((value) => {
if (isMountedRef.current) setLoading(value);
}, []);
// Watch form payments so we can query jobs when all jobids are filled (without side effects during render)
const payments = Form.useWatch(["payments"], form);
const jobids = useMemo(() => {
if (!Array.isArray(payments) || payments.length === 0) return [];
return payments.map((p) => p?.jobid).filter(Boolean);
}, [payments]);
const allJobIdsFilled = useMemo(() => {
if (!Array.isArray(payments) || payments.length === 0) return false;
return payments.every((p) => !!p?.jobid);
}, [payments]);
const lastJobidsKeyRef = useRef("");
useEffect(() => {
if (!allJobIdsFilled) return;
const nextKey = jobids.join("|");
if (!nextKey || nextKey === lastJobidsKeyRef.current) return;
lastJobidsKeyRef.current = nextKey;
safeRefetchRoAndOwner({ jobids });
}, [allJobIdsFilled, jobids, safeRefetchRoAndOwner]);
const collectIPayFields = () => {
const iPayFields = document.querySelectorAll(".ipayfield");
const iPayData = {};
iPayFields.forEach((field) => {
iPayData[field.dataset.ipayname] = field.value;
});
return iPayData;
};
const SetIntellipayCallbackFunctions = () => {
console.log("*** Set IntelliPay callback functions.");
const isLikelyUserCancel = (response) => {
const reason = String(response?.declinereason ?? "").toLowerCase();
// Heuristics: adjust if IntelliPay gives you a known cancel code/message
return (
reason.includes("cancel") ||
reason.includes("canceled") ||
reason.includes("closed") ||
// many gateways won't have a paymentid if user cancels before submitting
!response?.paymentid
);
};
window.intellipay.runOnClose(() => {
// This is the path for Cancel / X
try {
// If IntelliPay uses this flag, clear it so initialize() won't reopen unexpectedly
window.intellipay.isAutoOpen = false;
} catch {
// ignore
}
// Optional: if IntelliPay needs re-init after close, uncomment:
// try { window.intellipay.initialize?.(); } catch {}
setLoadingSafe(false);
});
window.intellipay.runOnApproval(() => {
// keep your existing behavior
setTimeout(() => {
if (actions?.refetch) actions.refetch();
setLoadingSafe(false);
toggleModalVisible();
}, 750);
});
window.intellipay.runOnNonApproval(async (response) => {
try {
// If cancel is reported as "non-approval", don't record it as a failed payment
if (isLikelyUserCancel(response)) return;
const { payments } = form.getFieldsValue();
await insertPaymentResponse({
variables: {
paymentResponse: payments.map((payment) => ({
amount: payment.amount,
bodyshopid: bodyshop.id,
jobid: payment.jobid,
declinereason: response.declinereason,
ext_paymentid: response.paymentid?.toString?.() ?? null,
successful: false,
response
}))
}
});
payments.forEach((payment) =>
insertAuditTrail({
jobid: payment.jobid,
operation: AuditTrailMapping.failedpayment(),
type: "failedpayment"
})
);
} finally {
// IMPORTANT: always clear loading, even on errors
setLoadingSafe(false);
}
});
};
const handleIntelliPayCharge = async () => {
setLoading(true);
// Validate
try {
await form.validateFields();
} catch {
setLoadingSafe(false);
return;
}
const iPayData = collectIPayFields();
const { payments } = form.getFieldsValue();
try {
logImEXEvent("payment_cc_lightbox");
const response = await axios.post("/intellipay/lightbox_credentials", {
bodyshop,
refresh: !!window.intellipay,
paymentSplitMeta: form.getFieldsValue(),
iPayData: iPayData,
comment: btoa(JSON.stringify({ payments, userEmail: currentUser.email }))
});
if (window.intellipay) {
// Use Function constructor instead of eval for security (still executes dynamic code but safer)
// IntelliPay provides initialization code that must be executed
Function(response.data)();
pollForIntelliPay(() => {
SetIntellipayCallbackFunctions();
window.intellipay.autoOpen();
});
} else {
const rg = document.createRange();
const node = rg.createContextualFragment(response.data);
document.documentElement.appendChild(node);
pollForIntelliPay(() => {
SetIntellipayCallbackFunctions();
window.intellipay.isAutoOpen = true;
window.intellipay.initialize();
});
}
} catch {
notification.error({
title: t("job_payments.notifications.error.openingip")
});
setLoadingSafe(false);
}
};
const handleIntelliPayChargeShortLink = async () => {
setLoading(true);
// Validate
try {
await form.validateFields();
} catch {
setLoadingSafe(false);
return;
}
const iPayData = collectIPayFields();
try {
const { payments } = form.getFieldsValue();
logImEXEvent("payment_cc_shortlink");
const response = await axios.post("/intellipay/generate_payment_url", {
bodyshop,
amount: payments.reduce((acc, val) => acc + (val?.amount || 0), 0),
account: payments && data?.jobs?.length > 0 ? data.jobs.map((j) => j.ro_number).join(", ") : null,
comment: btoa(JSON.stringify({ payments, userEmail: currentUser.email })),
paymentSplitMeta: form.getFieldsValue(),
iPayData: iPayData
});
if (response?.data?.shorUrl) {
setPaymentLink(response.data.shorUrl);
await navigator.clipboard.writeText(response.data.shorUrl);
message.success(t("general.actions.copied"));
}
setLoadingSafe(false);
} catch {
notification.error({
title: t("job_payments.notifications.error.openingip")
});
setLoadingSafe(false);
}
};
return (
<Card title="Card Payment">
<Spin spinning={loading}>
<Form
form={form}
layout="vertical"
initialValues={{
payments: context.jobid ? [{ jobid: context.jobid }] : []
}}
>
<Form.List name={["payments"]}>
{(fields, { add, remove }) => (
<div>
{fields.map((field, index) => (
<Form.Item key={field.key}>
<Row gutter={[16, 16]}>
<Col span={16}>
<Form.Item
key={`${index}jobid`}
label={t("jobs.fields.ro_number")}
name={[field.name, "jobid"]}
rules={[{ required: true }]}
>
<JobSearchSelectComponent notExported={false} clm_no />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item
key={`${index}amount`}
label={t("payments.fields.amount")}
name={[field.name, "amount"]}
rules={[{ required: true }]}
>
<CurrencyFormItemComponent />
</Form.Item>
</Col>
<Col span={2}>
<DeleteFilled style={{ margin: "1rem" }} onClick={() => remove(field.name)} />
</Col>
</Row>
</Form.Item>
))}
<Form.Item>
<Button type="dashed" onClick={() => add()} style={{ width: "100%" }}>
{t("general.actions.add")}
</Button>
</Form.Item>
</div>
)}
</Form.List>
{/* Hidden IntelliPay fields driven by watched payments + query result. IMPORTANT: no refetch() here (avoid side effects during render). */}
<Input
className="ipayfield"
data-ipayname="account"
type="hidden"
value={data?.jobs?.length > 0 ? data.jobs.map((j) => j.ro_number).join(", ") : null}
/>
<Input
className="ipayfield"
data-ipayname="email"
type="hidden"
value={data?.jobs?.length > 0 ? (data.jobs.find((j) => j.ownr_ea)?.ownr_ea ?? null) : null}
/>
<Form.Item
shouldUpdate={(prevValues, curValues) =>
prevValues.payments?.map((p) => p?.amount).join() !== curValues.payments?.map((p) => p?.amount).join()
}
>
{() => {
const { payments } = form.getFieldsValue();
const totalAmountToCharge = payments?.reduce((acc, val) => acc + (val?.amount || 0), 0);
return (
<Space style={{ float: "right" }}>
<Statistic title="Amount To Charge" value={totalAmountToCharge} precision={2} />
<Input
className="ipayfield"
data-ipayname="amount"
type="hidden"
value={totalAmountToCharge?.toFixed(2)}
/>
<Input
className="ipayfield"
data-ipayname="comment"
type="hidden"
value={btoa(JSON.stringify(payments))}
hidden
/>
<Button
type="primary"
// data-ipayname="submit"
className="ipayfield"
loading={queryLoading || loading}
disabled={!(totalAmountToCharge > 0)}
onClick={handleIntelliPayCharge}
>
{t("job_payments.buttons.proceedtopayment")}
</Button>
<Space orientation="vertical" align="center">
<Button
type="primary"
// data-ipayname="submit"
className="ipayfield"
loading={queryLoading || loading}
disabled={!(totalAmountToCharge > 0)}
onClick={handleIntelliPayChargeShortLink}
>
{t("job_payments.buttons.create_short_link")}
</Button>
</Space>
</Space>
);
}}
</Form.Item>
</Form>
{paymentLink && (
<Space
style={{ cursor: "pointer", float: "right" }}
align="end"
onClick={() => {
navigator.clipboard.writeText(paymentLink);
message.success(t("general.actions.copied"));
}}
>
<div>{paymentLink}</div>
<CopyFilled />
</Space>
)}
{queryError ? (
<div style={{ marginTop: 12 }}>
<span style={{ color: "red" }}>{queryError.message}</span>
</div>
) : null}
</Spin>
</Card>
);
};
export default connect(mapStateToProps, mapDispatchToProps)(CardPaymentModalComponent);
// Poll for window.IntelliPay.fixAmount for 5 seconds. If it doesn't come up, just try anyways to force the possible error.
function pollForIntelliPay(callbackFunction) {
const timeout = 5000;
const interval = 150;
const startTime = Date.now();
function checkFixAmount() {
if (window.intellipay?.fixAmount) {
callbackFunction();
return;
}
if (Date.now() - startTime >= timeout) {
console.log("Stopped polling IntelliPay after 5 seconds. Attempting to set functions anyways.");
callbackFunction();
return;
}
setTimeout(checkFixAmount, interval);
}
checkFixAmount();
}