UNPKG

@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
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; `;