Step3Payment.tsx (7784B)
1 import { ArrowUpRight, CheckCircle, Copy, Loader2 } from "lucide-react"; 2 import { Button } from "@/components/ui/Button"; 3 import { useIntl } from "react-intl"; 4 import Link from "next/link"; 5 import { useEffect, useState } from "react"; 6 import { QRCodeSVG } from 'qrcode.react'; 7 import { useInterval } from 'usehooks-ts' 8 import Lnmessage from 'lnmessage' 9 import { LNCheckout } from "./Types"; 10 import { StepHeader } from "./StepHeader"; 11 import CopiableUrl from "@/components/ui/CopiableUrl"; 12 13 export interface Step3PaymentProps { 14 lnCheckout: LNCheckout | null 15 setLNCheckout: (checkout: LNCheckout | null) => void 16 setError: (error: string) => void 17 successView: React.ReactNode 18 } 19 20 export function Step3Payment(props: Step3PaymentProps) { 21 const intl = useIntl() 22 const { lnCheckout, setLNCheckout } = props 23 const [lnInvoicePaid, setLNInvoicePaid] = useState<boolean | undefined>(undefined) // Whether the ln invoice has been paid 24 const [waitingForInvoice, setWaitingForInvoice] = useState<boolean>(false) // Whether we are waiting for a response from the LN node about the invoice 25 26 const [lnConnectionRetryCount, setLnConnectionRetryCount] = useState<number>(0) // The number of times we have tried to connect to the LN node 27 const lnConnectionRetryLimit = 5 // The maximum number of times we will try to connect to the LN node before displaying an error 28 const [lnWaitinvoiceRetryCount, setLnWaitinvoiceRetryCount] = useState<number>(0) // The number of times we have tried to check the invoice status 29 const lnWaitinvoiceRetryLimit = 5 // The maximum number of times we will try to check the invoice status before displaying an error 30 31 const step1Done = props.lnCheckout?.product_template_name != null 32 const step2Done = props.lnCheckout?.verified_pubkey != null 33 const step3Done = props.lnCheckout?.invoice?.paid == true 34 35 // MARK: - Functions 36 37 const checkLNInvoice = async () => { 38 console.log("Checking LN invoice...") 39 if (!lnCheckout?.invoice?.bolt11) { 40 return 41 } 42 let ln = null 43 try { 44 ln = new Lnmessage({ 45 // The public key of the node you would like to connect to 46 remoteNodePublicKey: lnCheckout.invoice.connection_params.nodeid, 47 // The websocket proxy address of the node 48 wsProxy: `wss://${lnCheckout.invoice.connection_params.ws_proxy_address}`, 49 // The IP address of the node 50 ip: lnCheckout.invoice.connection_params.address, 51 // Protocol to use when connecting to the node 52 wsProtocol: 'wss:', 53 port: 9735, 54 }) 55 // TODO: This is a workaround due to a limitation in LNMessage URL formatting: (https://github.com/aaronbarnardsound/lnmessage/issues/52) 56 ln.wsUrl = `wss://${lnCheckout.invoice.connection_params.ws_proxy_address}/${lnCheckout.invoice.connection_params.address}` 57 await ln.connect() 58 setWaitingForInvoice(true) // Indicate that we are waiting for a response from the LN node 59 } 60 catch (e) { 61 console.error(e) 62 if (lnConnectionRetryCount >= lnConnectionRetryLimit) { 63 props.setError("Failed to connect to the Lightning node. Please refresh this page, and try again in a few minutes. If the problem persists, please contact support.") 64 } 65 else { 66 setLnConnectionRetryCount(lnConnectionRetryCount + 1) 67 } 68 return 69 } 70 71 try { 72 if (!ln) { return } 73 const res: any = await ln.commando({ 74 method: 'waitinvoice', 75 params: { label: lnCheckout.invoice.label }, 76 rune: lnCheckout.invoice.connection_params.rune, 77 }) 78 setWaitingForInvoice(false) // Indicate that we are no longer waiting for a response from the LN node 79 setLNInvoicePaid(!res.error) 80 if (res.error) { 81 console.error(res.error) 82 props.setError("The lightning payment failed. If you haven't paid yet, please start a new checkout from the beginning and try again. If you have already paid, please copy the reference ID shown below and contact support.") 83 } 84 } catch (e) { 85 setWaitingForInvoice(false) // Indicate that we are no longer waiting for a response from the LN node 86 console.error(e) 87 if (lnWaitinvoiceRetryCount >= lnWaitinvoiceRetryLimit) { 88 props.setError("There was an error checking the lightning payment status. If you haven't paid yet, please wait a few minutes, refresh the page, and try again. If you have already paid, please copy the reference ID shown below and contact support.") 89 } 90 else { 91 setLnWaitinvoiceRetryCount(lnWaitinvoiceRetryCount + 1) 92 } 93 } 94 } 95 96 const tellServerToCheckLNInvoice = async () => { 97 try { 98 const response = await fetch(process.env.NEXT_PUBLIC_PURPLE_API_BASE_URL + "/ln-checkout/" + lnCheckout?.id + "/check-invoice", { 99 method: 'POST', 100 headers: { 101 'Content-Type': 'application/json' 102 }, 103 }) 104 const data: LNCheckout = await response.json() 105 setLNCheckout(data) 106 } 107 catch (e) { 108 console.error(e) 109 props.setError("Failed to finalize checkout. Please try refreshing the page. If the error persists, please copy the reference ID shown below and contact support.") 110 } 111 } 112 113 const pollState = async () => { 114 if (!lnCheckout) { 115 return 116 } 117 if (lnCheckout.invoice && !lnCheckout.invoice?.paid && !waitingForInvoice) { 118 checkLNInvoice() 119 } 120 } 121 122 // MARK: - Effects and hooks 123 124 // Keep checking the state of things when needed 125 useInterval(pollState, 1000) 126 127 // Tell server to check the invoice as soon as we notice it has been paid 128 useEffect(() => { 129 if (lnInvoicePaid === true) { 130 tellServerToCheckLNInvoice() 131 } 132 }, [lnInvoicePaid]) 133 134 // MARK: - Render 135 136 return (<> 137 <StepHeader 138 stepNumber={3} 139 title={intl.formatMessage({ id: "purple.checkout.step-3", defaultMessage: "Lightning payment" })} 140 done={lnCheckout?.invoice?.paid === true} 141 active={lnCheckout?.verified_pubkey != null} 142 /> 143 {lnCheckout?.invoice?.bolt11 && !lnCheckout?.invoice?.paid && 144 <> 145 <QRCodeSVG value={"lightning:" + lnCheckout.invoice.bolt11} className="mt-6 w-[300px] h-[300px] max-w-full max-h-full mx-auto mb-6 border-[5px] border-white bg-white" /> 146 {/* Shows the bolt11 in for copy-paste with a copy and paste button */} 147 <CopiableUrl url={lnCheckout.invoice.bolt11} /> 148 <Link href={"lightning:" + lnCheckout.invoice.bolt11} className="w-full md:w-auto opacity-70 hover:opacity-100 transition mt-4"> 149 <Button variant="link" className="w-full text-sm"> 150 {intl.formatMessage({ id: "purple.checkout.open-in-wallet", defaultMessage: "Open in wallet" })} 151 <ArrowUpRight className="text-damuspink-600 ml-2" /> 152 </Button> 153 </Link> 154 <div className="mt-6 text-purple-200/50 font-normal text-sm text-center flex justify-center"> 155 <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 156 {intl.formatMessage({ id: "purple.checkout.waiting-for-payment", defaultMessage: "Waiting for payment" })} 157 </div> 158 </> 159 } 160 {/* We use the lnCheckout object to check payment status (NOT lnInvoicePaid) to display the confirmation message, because the server is the ultimate source of truth */} 161 {lnCheckout?.invoice?.paid && lnCheckout?.completed && ( 162 <div className="flex flex-col items-center justify-center gap-3 mt-6"> 163 <CheckCircle className="w-16 h-16 text-green-500" /> 164 <div className="mt-3 mb-6 text-sm text-center text-green-500 font-bold"> 165 {intl.formatMessage({ id: "purple.checkout.payment-received", defaultMessage: "Payment received" })} 166 </div> 167 {props.successView} 168 </div> 169 )} 170 </>) 171 }