@coin-voyage/paykit
Version:
Seamless crypto payments. Onboard users from any chain, any coin into your app with one click.
225 lines (224 loc) • 9.82 kB
JavaScript
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { getFiatPaymentData } from "@coin-voyage/shared/payment";
import { PayOrderMode, PayOrderStatus } from "@coin-voyage/shared/types";
import { loadStripeOnramp } from "@stripe/crypto/pure";
import { useQuery } from "@tanstack/react-query";
import { memo, useCallback, useEffect, useMemo, useRef } from "react";
import { AlertIcon } from "../../../assets/icons";
import useLocales from "../../../hooks/useLocales";
import styled from "../../../styles/styled";
import { ROUTE } from "../../../types/routes";
import usePayContext from "../../contexts/pay";
import Button from "../../ui/Button";
import { ModalBody, ModalContent, ModalH1, PageContent } from "../../ui/Modal/styles";
import { OrderHeader } from "../../ui/OrderHeader";
import PoweredByFooter from "../../ui/PoweredByFooter";
import { Spinner } from "../../ui/Spinner";
export default function CardPayment() {
const { triggerResize } = usePayContext();
const { data, isLoading, error, refetch } = useCardPaymentData();
useEffect(() => {
triggerResize();
}, [triggerResize, isLoading, data?.session_id, data?.client_secret, error]);
return (_jsxs(PageContent, { children: [_jsx(OrderHeader, { minified: true }), _jsx(CardPaymentContent, { paymentData: data, isLoading: isLoading, error: error instanceof Error ? error : null, onRetry: () => {
void refetch();
} }), _jsx(PoweredByFooter, {})] }));
}
function CardPaymentContent({ paymentData, isLoading, error, onRetry, }) {
const locales = useLocales();
const { paymentState, setRoute } = usePayContext();
const { payOrder } = paymentState;
if (!payOrder) {
return (_jsx(StatusCard, { title: locales.selectPayToAddressWaitingScreen_unavailable_h1, body: "We couldn't load this card payment.", actionLabel: locales.back, onAction: () => setRoute(ROUTE.SELECT_METHOD) }));
}
if (payOrder.status === PayOrderStatus.EXPIRED) {
return _jsx(ExpiredCardPayment, {});
}
if (isLoading) {
return _jsx(StatusCard, { loading: true, title: locales.requesting_payment_h1, body: "Preparing secure Stripe checkout..." });
}
if (error || !paymentData) {
return (_jsx(StatusCard, { warning: true, title: locales.selectPayToAddressWaitingScreen_unavailable_h1, body: error?.message ?? "We couldn't start the Stripe checkout for this order.", actionLabel: locales.tryAgain, onAction: onRetry }));
}
return _jsx(StripeOnrampCheckout, { paymentData: paymentData });
}
function ExpiredCardPayment() {
const locales = useLocales();
const { paymentState, setOpen } = usePayContext();
const isDeposit = paymentState.payOrder?.mode === PayOrderMode.DEPOSIT;
const body = isDeposit
? locales.payWithTokenScreen_expired_p
: "This checkout has expired. Please restart checkout from the merchant.";
return (_jsx(StatusCard, { warning: true, title: locales.payWithTokenScreen_expired_h1, body: body, actionLabel: isDeposit ? locales.refresh : locales.close, onAction: () => {
if (isDeposit) {
void paymentState.copyDepositPayOrder();
return;
}
setOpen(false);
} }));
}
function StatusCard({ title, body, actionLabel, onAction, loading = false, warning = false, }) {
return (_jsxs(ModalContent, { "$center": true, style: {
marginLeft: 24,
marginRight: 24,
paddingTop: 24,
paddingBottom: 24,
}, children: [loading ? _jsx(Spinner, {}) : warning ? _jsx(AlertIcon, {}) : null, _jsx(ModalH1, { "$warning": warning, children: title }), _jsx(ModalBody, { children: body }), actionLabel && onAction ? (_jsx(ActionRow, { children: _jsx(Button, { onClick: onAction, children: actionLabel }) })) : null] }));
}
function StripeOnrampCheckout({ paymentData }) {
const { paymentState, mode, triggerResize } = usePayContext();
const { data: stripeOnramp, isLoading, error, refetch } = useStripeOnramp(paymentData.stripe_publishable_key);
const refreshOrderRef = useLatestRef(paymentState.refreshOrder);
const refreshDebounceRef = useRef(null);
const lastStatusRef = useRef(null);
const theme = useMemo(() => {
return mode === "dark" ? "dark" : "light";
}, [mode]);
const handleSessionUpdate = useCallback((status) => {
if (status === lastStatusRef.current)
return;
lastStatusRef.current = status;
const shouldRefresh = status === "fulfillment_complete" || status === "rejected";
if (!shouldRefresh) {
return;
}
if (refreshDebounceRef.current) {
window.clearTimeout(refreshDebounceRef.current);
}
refreshDebounceRef.current = window.setTimeout(() => {
void refreshOrderRef.current();
}, 500);
}, [refreshOrderRef]);
useEffect(() => {
return () => {
if (refreshDebounceRef.current) {
window.clearTimeout(refreshDebounceRef.current);
}
};
}, []);
if (error) {
return (_jsx(StatusCard, { warning: true, title: "Stripe unavailable", body: error.message, actionLabel: "Try again", onAction: () => {
void refetch();
} }));
}
return (_jsxs(OnrampShell, { children: [isLoading ? (_jsx(OnrampOverlay, { children: _jsx(Spinner, {}) })) : null, stripeOnramp ? (_jsx(OnrampSession, { stripeOnramp: stripeOnramp, clientSecret: paymentData.client_secret, theme: theme, onUiLoaded: triggerResize, onSessionUpdate: handleSessionUpdate }, paymentData.client_secret)) : null] }));
}
const OnrampSession = memo(function OnrampSession({ stripeOnramp, clientSecret, theme, onUiLoaded, onSessionUpdate, }) {
const mountNodeRef = useRef(null);
const onUiLoadedRef = useLatestRef(onUiLoaded);
const onSessionUpdateRef = useLatestRef(onSessionUpdate);
useEffect(() => {
const mountNode = mountNodeRef.current;
if (!mountNode) {
return;
}
mountNode.replaceChildren();
const session = stripeOnramp.createSession({
clientSecret,
appearance: theme ? { theme } : undefined,
});
const handleUiLoaded = () => {
onUiLoadedRef.current?.();
};
const handleSessionUpdated = (event) => {
onSessionUpdateRef.current?.(event.payload.session.status);
};
session.addEventListener("onramp_ui_loaded", handleUiLoaded);
session.addEventListener("onramp_session_updated", handleSessionUpdated);
session.mount(mountNode);
return () => {
session.removeEventListener("onramp_ui_loaded", handleUiLoaded);
session.removeEventListener("onramp_session_updated", handleSessionUpdated);
mountNode.replaceChildren();
};
}, [clientSecret, onSessionUpdateRef, onUiLoadedRef, stripeOnramp, theme]);
return _jsx(OnrampMount, { ref: mountNodeRef });
});
function useCardPaymentData() {
const { paymentState } = usePayContext();
const { payOrder, payWithCard } = paymentState;
const paymentData = getFiatPaymentData(payOrder?.payment);
const queryKey = useMemo(() => ["card-payment", payOrder?.id ?? null], [payOrder?.id]);
const query = useQuery({
queryKey,
enabled: Boolean(payOrder?.id) && !paymentData,
staleTime: 10 * 1000,
retry: false,
refetchOnWindowFocus: false,
refetchOnMount: false,
queryFn: async () => {
if (!payOrder) {
throw new Error("Missing pay order");
}
const paymentDetails = await payWithCard();
const fiatPaymentData = getFiatPaymentData(paymentDetails.data);
if (!fiatPaymentData) {
throw new Error("Stripe onramp session is unavailable for this pay order");
}
return fiatPaymentData;
},
});
return {
...query,
data: paymentData ?? query.data,
};
}
function useStripeOnramp(publishableKey) {
return useQuery({
queryKey: ["stripe-onramp", publishableKey],
enabled: Boolean(publishableKey),
staleTime: Infinity,
retry: false,
queryFn: async () => {
if (!publishableKey) {
throw new Error("Missing Stripe publishable key.");
}
const instance = await getStripeOnrampPromise(publishableKey);
if (!instance) {
throw new Error("Stripe Onramp is only available in the browser.");
}
return instance;
},
});
}
const stripeOnrampPromiseCache = new Map();
function getStripeOnrampPromise(publishableKey) {
const cached = stripeOnrampPromiseCache.get(publishableKey);
if (cached) {
return cached;
}
const nextPromise = loadStripeOnramp(publishableKey);
stripeOnrampPromiseCache.set(publishableKey, nextPromise);
return nextPromise;
}
function useLatestRef(value) {
const ref = useRef(value);
useEffect(() => {
ref.current = value;
}, [value]);
return ref;
}
const OnrampShell = styled.div `
position: relative;
min-height: 480px;
border-radius: 10px;
overflow: hidden;
background: var(--ck-body-background-secondary);
border: 1px solid var(--ck-body-divider-secondary, var(--ck-body-divider));
`;
const OnrampMount = styled.div `
min-height: 480px;
`;
const OnrampOverlay = styled.div `
position: absolute;
inset: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--ck-body-background) 88%, transparent);
`;
const ActionRow = styled.div `
width: 100%;
margin-top: 8px;
`;