PurpleCheckout.tsx (23309B)
1 import { ArrowLeft, ArrowUpRight, CheckCircle, ChevronRight, Copy, Globe2, Loader2, LucideZapOff, Sparkles, Zap, ZapIcon, ZapOff } from "lucide-react"; 2 import { Button } from "../ui/Button"; 3 import { FormattedMessage, useIntl } from "react-intl"; 4 import Link from "next/link"; 5 import { motion } from "framer-motion"; 6 import Image from "next/image"; 7 import { PurpleIcon } from "../icons/PurpleIcon"; 8 import { RoundedContainerWithGradientBorder } from "../ui/RoundedContainerWithGradientBorder"; 9 import { MeshGradient5 } from "../effects/MeshGradient.5"; 10 import { useEffect, useRef, useState } from "react"; 11 import { NostrEvent, Relay, nip19 } from "nostr-tools" 12 import { QRCodeSVG } from 'qrcode.react'; 13 import { useInterval } from 'usehooks-ts' 14 import Lnmessage from 'lnmessage' 15 import { DAMUS_TESTFLIGHT_URL } from "@/lib/constants"; 16 import { 17 AlertDialog, 18 AlertDialogAction, 19 AlertDialogCancel, 20 AlertDialogContent, 21 AlertDialogDescription, 22 AlertDialogFooter, 23 AlertDialogHeader, 24 AlertDialogTitle, 25 AlertDialogTrigger, 26 } from "@/components/ui/AlertDialog"; 27 import { Info } from "lucide-react"; 28 import { ErrorDialog } from "../ErrorDialog"; 29 import { PurpleLayout } from "../PurpleLayout"; 30 import { AccountInfo, Profile, getProfile, getPurpleAccountInfo } from "@/utils/PurpleUtils"; 31 32 33 export function PurpleCheckout() { 34 const intl = useIntl() 35 const [lnCheckout, setLNCheckout] = useState<LNCheckout | null>(null) // The checkout object from the server 36 const [productTemplates, setProductTemplates] = useState<ProductTemplates | null>(null) // The different product options 37 const [pubkey, setPubkey] = useState<string | null>(null) // The pubkey of the user, if verified 38 const [profile, setProfile] = useState<Profile | undefined | null>(undefined) // The profile info fetched from the Damus relay 39 const [continueShowQRCodes, setContinueShowQRCodes] = useState<boolean>(false) // Whether the user wants to show a QR code for the final step 40 const [lnInvoicePaid, setLNInvoicePaid] = useState<boolean | undefined>(undefined) // Whether the ln invoice has been paid 41 const [waitingForInvoice, setWaitingForInvoice] = useState<boolean>(false) // Whether we are waiting for a response from the LN node about the invoice 42 const [error, setError] = useState<string | null>(null) // An error message to display to the user 43 const [existingAccountInfo, setExistingAccountInfo] = useState<AccountInfo | null | undefined>(undefined) // The account info fetched from the server 44 45 const [lnConnectionRetryCount, setLnConnectionRetryCount] = useState<number>(0) // The number of times we have tried to connect to the LN node 46 const lnConnectionRetryLimit = 5 // The maximum number of times we will try to connect to the LN node before displaying an error 47 const [lnWaitinvoiceRetryCount, setLnWaitinvoiceRetryCount] = useState<number>(0) // The number of times we have tried to check the invoice status 48 const lnWaitinvoiceRetryLimit = 5 // The maximum number of times we will try to check the invoice status before displaying an error 49 50 // MARK: - Functions 51 52 const fetchProfile = async () => { 53 if (!pubkey) { 54 return 55 } 56 try { 57 const profile = await getProfile(pubkey) 58 setProfile(profile) 59 } 60 catch (e) { 61 console.error(e) 62 setError("Failed to get profile info from the relay. Please wait a few minutes and refresh the page. If the problem persists, please contact support.") 63 } 64 } 65 66 const fetchAccountInfo = async () => { 67 if (!pubkey) { 68 setExistingAccountInfo(undefined) 69 return 70 } 71 try { 72 const accountInfo = await getPurpleAccountInfo(pubkey) 73 setExistingAccountInfo(accountInfo) 74 } 75 catch (e) { 76 console.error(e) 77 setError("Failed to get account info from our servers. Please wait a few minutes and refresh the page. If the problem persists, please contact support.") 78 } 79 } 80 81 const fetchProductTemplates = async () => { 82 try { 83 const response = await fetch(process.env.NEXT_PUBLIC_PURPLE_API_BASE_URL + "/products", { 84 method: 'GET', 85 headers: { 86 'Content-Type': 'application/json' 87 }, 88 }) 89 const data = await response.json() 90 setProductTemplates(data) 91 } 92 catch (e) { 93 console.error(e) 94 setError("Failed to get product list from our servers, please try again later in a few minutes. If the problem persists, please contact support.") 95 } 96 } 97 98 const refreshLNCheckout = async (id?: string) => { 99 if (!lnCheckout && !id) { 100 return 101 } 102 try { 103 const response = await fetch(process.env.NEXT_PUBLIC_PURPLE_API_BASE_URL + "/ln-checkout/" + (id || lnCheckout?.id), { 104 method: 'GET', 105 headers: { 106 'Content-Type': 'application/json' 107 }, 108 }) 109 const data: LNCheckout = await response.json() 110 setLNCheckout(data) 111 } 112 catch (e) { 113 console.error(e) 114 setError("Failed to get checkout info from our servers, please wait a few minutes and try to refresh this page. If the problem persists, please contact support.") 115 } 116 } 117 118 const selectProduct = async (productTemplateName: string) => { 119 try { 120 const response = await fetch(process.env.NEXT_PUBLIC_PURPLE_API_BASE_URL + "/ln-checkout", { 121 method: 'POST', 122 headers: { 123 'Content-Type': 'application/json' 124 }, 125 body: JSON.stringify({ product_template_name: productTemplateName }) 126 }) 127 const data: LNCheckout = await response.json() 128 setLNCheckout(data) 129 } 130 catch (e) { 131 console.error(e) 132 setError("Failed to begin the checkout process. Please wait a few minutes, refresh this page, and try again. If the problem persists, please contact support.") 133 } 134 } 135 136 const checkLNInvoice = async () => { 137 console.log("Checking LN invoice...") 138 if (!lnCheckout?.invoice?.bolt11) { 139 return 140 } 141 let ln = null 142 try { 143 ln = new Lnmessage({ 144 // The public key of the node you would like to connect to 145 remoteNodePublicKey: lnCheckout.invoice.connection_params.nodeid, 146 // The websocket proxy address of the node 147 wsProxy: `wss://${lnCheckout.invoice.connection_params.ws_proxy_address}`, 148 // The IP address of the node 149 ip: lnCheckout.invoice.connection_params.address, 150 // Protocol to use when connecting to the node 151 wsProtocol: 'wss:', 152 port: 9735, 153 }) 154 // TODO: This is a workaround due to a limitation in LNMessage URL formatting: (https://github.com/aaronbarnardsound/lnmessage/issues/52) 155 ln.wsUrl = `wss://${lnCheckout.invoice.connection_params.ws_proxy_address}/${lnCheckout.invoice.connection_params.address}` 156 await ln.connect() 157 setWaitingForInvoice(true) // Indicate that we are waiting for a response from the LN node 158 } 159 catch (e) { 160 console.error(e) 161 if (lnConnectionRetryCount >= lnConnectionRetryLimit) { 162 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.") 163 } 164 else { 165 setLnConnectionRetryCount(lnConnectionRetryCount + 1) 166 } 167 return 168 } 169 170 try { 171 if (!ln) { return } 172 const res: any = await ln.commando({ 173 method: 'waitinvoice', 174 params: { label: lnCheckout.invoice.label }, 175 rune: lnCheckout.invoice.connection_params.rune, 176 }) 177 setWaitingForInvoice(false) // Indicate that we are no longer waiting for a response from the LN node 178 setLNInvoicePaid(!res.error) 179 if (res.error) { 180 console.error(res.error) 181 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.") 182 } 183 } catch (e) { 184 setWaitingForInvoice(false) // Indicate that we are no longer waiting for a response from the LN node 185 console.error(e) 186 if (lnWaitinvoiceRetryCount >= lnWaitinvoiceRetryLimit) { 187 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.") 188 } 189 else { 190 setLnWaitinvoiceRetryCount(lnWaitinvoiceRetryCount + 1) 191 } 192 } 193 } 194 195 const tellServerToCheckLNInvoice = async () => { 196 try { 197 const response = await fetch(process.env.NEXT_PUBLIC_PURPLE_API_BASE_URL + "/ln-checkout/" + lnCheckout?.id + "/check-invoice", { 198 method: 'POST', 199 headers: { 200 'Content-Type': 'application/json' 201 }, 202 }) 203 const data: LNCheckout = await response.json() 204 setLNCheckout(data) 205 } 206 catch (e) { 207 console.error(e) 208 setError("Failed to finalize checkout. Please try refreshing the page. If the error persists, please copy the reference ID shown below and contact support.") 209 } 210 } 211 212 const pollState = async () => { 213 if (!lnCheckout) { 214 return 215 } 216 if (!lnCheckout.verified_pubkey) { 217 refreshLNCheckout() 218 } 219 else if (!lnCheckout.invoice?.paid && !waitingForInvoice) { 220 checkLNInvoice() 221 } 222 } 223 224 225 // MARK: - Effects and hooks 226 227 // Keep checking the state of things when needed 228 useInterval(pollState, 1000) 229 230 useEffect(() => { 231 if (lnCheckout && lnCheckout.verified_pubkey) { 232 // Load the profile if the user has verified their pubkey 233 setPubkey(lnCheckout.verified_pubkey) 234 } 235 // Set the query parameter on the URL to be the lnCheckout ID to avoid losing it on page refresh 236 if (lnCheckout) { 237 const url = new URL(window.location.href) 238 url.searchParams.set("id", lnCheckout.id) 239 window.history.replaceState({}, "", url.toString()) 240 } 241 }, [lnCheckout]) 242 243 // Load the profile when the pubkey changes 244 useEffect(() => { 245 if (pubkey) { 246 fetchProfile() 247 fetchAccountInfo() 248 } 249 }, [pubkey]) 250 251 // Load the products and the LN checkout (if there is one) on page load 252 useEffect(() => { 253 fetchProductTemplates() 254 // Check if there is a lnCheckout ID in the URL query parameters. If so, fetch the lnCheckout 255 const url = new URL(window.location.href) 256 const id = url.searchParams.get("id") 257 if (id) { 258 console.log("Found lnCheckout ID in URL query parameters. Fetching lnCheckout...") 259 refreshLNCheckout(id) 260 } 261 }, []) 262 263 // Tell server to check the invoice as soon as we notice it has been paid 264 useEffect(() => { 265 if (lnInvoicePaid === true) { 266 tellServerToCheckLNInvoice() 267 } 268 }, [lnInvoicePaid]) 269 270 // MARK: - Render 271 272 return (<> 273 <ErrorDialog error={error} setError={setError}> 274 {lnCheckout && lnCheckout.id && ( 275 <div className="flex items-center justify-between rounded-md bg-gray-200"> 276 <div className="text-xs text-gray-400 font-normal px-4 py-2"> 277 Reference: 278 </div> 279 <div className="w-full text-xs text-gray-500 font-normal px-4 py-2 overflow-x-scroll"> 280 {lnCheckout?.id} 281 </div> 282 <button 283 className="text-sm text-gray-500 font-normal px-4 py-2 active:text-gray-500/30 hover:text-gray-500/80 transition" 284 onClick={() => navigator.clipboard.writeText(lnCheckout?.id || "")} 285 > 286 <Copy /> 287 </button> 288 </div> 289 )} 290 </ErrorDialog> 291 <PurpleLayout> 292 <h2 className="text-2xl text-left text-purple-200 font-semibold break-keep mb-2"> 293 {intl.formatMessage({ id: "purple.checkout.title", defaultMessage: "Checkout" })} 294 </h2> 295 <div className="text-purple-200/70 text-normal text-left mb-6 font-semibold flex flex-col md:flex-row gap-3 rounded-lg bg-purple-200/10 p-3 items-center md:items-start"> 296 <Info className="w-6 h-6 shrink-0 mt-0 md:mt-1" /> 297 <div className="flex flex-col text-center md:text-left"> 298 <span className="text-normal md:text-lg mb-2"> 299 {intl.formatMessage({ id: "purple.checkout.description", defaultMessage: "New accounts and renewals" })} 300 </span> 301 <span className="text-xs text-purple-200/50"> 302 {intl.formatMessage({ id: "purple.checkout.description-2", defaultMessage: "Use this page to purchase a new account, or to renew an existing one. You will need the latest Damus version to complete the checkout." })} 303 </span> 304 </div> 305 </div> 306 <StepHeader 307 stepNumber={1} 308 title={intl.formatMessage({ id: "purple.checkout.step-1", defaultMessage: "Choose your plan" })} 309 done={lnCheckout?.product_template_name != null} 310 active={true} 311 /> 312 <div className="mt-3 mb-4 flex gap-2 items-center"> 313 {productTemplates ? Object.entries(productTemplates).map(([name, productTemplate]) => ( 314 <button 315 key={name} 316 className={`relative flex flex-col items-center justify-center p-3 pt-4 border rounded-lg ${name == lnCheckout?.product_template_name ? "border-green-500" : "border-purple-200/50"} disabled:opacity-50 disabled:cursor-not-allowed`} 317 onClick={() => selectProduct(name)} 318 disabled={lnCheckout?.verified_pubkey != null} 319 > 320 {productTemplate.special_label && ( 321 <div className="absolute top-0 right-0 -mt-4 -mr-2 bg-gradient-to-r from-damuspink-500 to-damuspink-600 rounded-full p-1 px-3"> 322 <div className="text-white text-xs font-semibold"> 323 {productTemplate.special_label} 324 </div> 325 </div> 326 )} 327 328 <div className="text-purple-200/50 font-normal text-sm"> 329 {productTemplate.description} 330 </div> 331 <div className="mt-1 text-purple-100/90 font-semibold text-lg"> 332 {productTemplate.amount_msat / 1000} sats 333 </div> 334 </button> 335 )) : ( 336 <div className="flex flex-col items-center justify-center"> 337 <div className="text-purple-200/50 font-normal text-sm flex items-center"> 338 <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 339 Loading... 340 </div> 341 </div> 342 )} 343 </div> 344 <StepHeader 345 stepNumber={2} 346 title={intl.formatMessage({ id: "purple.checkout.step-2", defaultMessage: "Verify your npub" })} 347 done={lnCheckout?.verified_pubkey != null} 348 active={lnCheckout?.product_template_name != null} 349 /> 350 {lnCheckout && !lnCheckout.verified_pubkey && <> 351 <QRCodeSVG value={"damus:purple:verify?id=" + lnCheckout.id} className="mt-6 w-[300px] h-[300px] max-w-full max-h-full mx-auto mb-6" /> 352 <Link href={"damus:purple:verify?id=" + lnCheckout.id} className="w-full md:w-auto opacity-70 hover:opacity-100 transition"> 353 <Button variant="link" className="w-full text-sm"> 354 {intl.formatMessage({ id: "purple.checkout.open-in-app", defaultMessage: "Open in Damus" })} 355 </Button> 356 </Link> 357 <div className="text-white/40 text-xs text-center mt-4 mb-6"> 358 {/* TODO: Localize later */} 359 Issues with this step? Please ensure you are running the latest Damus iOS version from <Link href={DAMUS_TESTFLIGHT_URL} className="text-damuspink-500 underline" target="_blank">TestFlight</Link> — or <Link href="mailto:support@damus.io" className="text-damuspink-500 underline">contact us</Link> 360 </div> 361 </> 362 } 363 {profile && 364 <div className="mt-2 mb-4 flex flex-col items-center"> 365 <div className="text-purple-200/50 font-normal text-sm"> 366 {existingAccountInfo === null || existingAccountInfo === undefined ? <> 367 {lnCheckout?.verified_pubkey && !lnCheckout?.invoice?.paid && intl.formatMessage({ id: "purple.checkout.purchasing-for", defaultMessage: "Verified. Purchasing Damus Purple for:" })} 368 {lnCheckout?.invoice?.paid && intl.formatMessage({ id: "purple.checkout.purchased-for", defaultMessage: "Purchased Damus Purple for:" })} 369 </> : <> 370 {lnCheckout?.verified_pubkey && !lnCheckout?.invoice?.paid && intl.formatMessage({ id: "purple.checkout.renewing-for", defaultMessage: "Verified. Renewing Damus Purple for:" })} 371 {lnCheckout?.invoice?.paid && intl.formatMessage({ id: "purple.checkout.renewed-for", defaultMessage: "Renewed Damus Purple for:" })} 372 </>} 373 </div> 374 <div className="mt-4 flex flex-col gap-1 items-center justify-center"> 375 <Image src={profile.picture || "https://robohash.org/" + profile.pubkey} width={64} height={64} className="rounded-full" alt={profile.name} /> 376 <div className="text-purple-100/90 font-semibold text-lg"> 377 {profile.name} 378 </div> 379 {existingAccountInfo !== null && existingAccountInfo !== undefined && ( 380 <div className="text-purple-200/50 font-normal flex items-center gap-2 bg-purple-300/10 rounded-full px-6 py-2 justify-center"> 381 <Sparkles className="w-4 h-4 shrink-0 text-purple-50" /> 382 <div className="flex flex-col"> 383 <div className="text-purple-200/90 font-semibold text-sm"> 384 {intl.formatMessage({ id: "purple-checkout.this-account-exists", defaultMessage: "Yay! We found your account" })} 385 </div> 386 <div className="text-purple-200/70 font-normal text-xs break-normal"> 387 {intl.formatMessage({ id: "purple-checkout.account-will-renew", defaultMessage: "Paying will renew or extend your membership." })} 388 </div> 389 </div> 390 </div> 391 )} 392 </div> 393 </div> 394 } 395 <StepHeader 396 stepNumber={3} 397 title={intl.formatMessage({ id: "purple.checkout.step-3", defaultMessage: "Lightning payment" })} 398 done={lnCheckout?.invoice?.paid === true} 399 active={lnCheckout?.verified_pubkey != null} 400 /> 401 {lnCheckout?.invoice?.bolt11 && !lnCheckout?.invoice?.paid && 402 <> 403 <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" /> 404 {/* Shows the bolt11 in for copy-paste with a copy and paste button */} 405 <div className="flex items-center justify-between rounded-md bg-purple-200/20"> 406 <div className="w-full text-sm text-purple-200/50 font-normal px-4 py-2 overflow-x-scroll"> 407 {lnCheckout.invoice.bolt11} 408 </div> 409 <button 410 className="text-sm text-purple-200/50 font-normal px-4 py-2 active:text-purple-200/30 hover:text-purple-200/80 transition" 411 onClick={() => navigator.clipboard.writeText(lnCheckout?.invoice?.bolt11 || "")} 412 > 413 <Copy /> 414 </button> 415 </div> 416 <Link href={"lightning:" + lnCheckout.invoice.bolt11} className="w-full md:w-auto opacity-70 hover:opacity-100 transition mt-4"> 417 <Button variant="link" className="w-full text-sm"> 418 {intl.formatMessage({ id: "purple.checkout.open-in-wallet", defaultMessage: "Open in wallet" })} 419 <ArrowUpRight className="text-damuspink-600 ml-2" /> 420 </Button> 421 </Link> 422 <div className="mt-6 text-purple-200/50 font-normal text-sm text-center flex justify-center"> 423 <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 424 {intl.formatMessage({ id: "purple.checkout.waiting-for-payment", defaultMessage: "Waiting for payment" })} 425 </div> 426 </> 427 } 428 {/* 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 */} 429 {lnCheckout?.invoice?.paid && lnCheckout?.completed && ( 430 <div className="flex flex-col items-center justify-center gap-3 mt-6"> 431 <CheckCircle className="w-16 h-16 text-green-500" /> 432 <div className="mt-3 mb-6 text-sm text-center text-green-500 font-bold"> 433 {intl.formatMessage({ id: "purple.checkout.payment-received", defaultMessage: "Payment received" })} 434 </div> 435 <Link 436 href={existingAccountInfo !== null && existingAccountInfo !== undefined ? `damus:purple:landing` : `damus:purple:welcome?id=${lnCheckout.id}`} 437 className="w-full text-sm flex justify-center" 438 > 439 <Button variant="default" className="w-full text-sm"> 440 {intl.formatMessage({ id: "purple.checkout.continue", defaultMessage: "Continue in the app" })} 441 <ChevronRight className="ml-1" /> 442 </Button> 443 </Link> 444 <button className="w-full text-sm text-damuspink-500 flex justify-center" onClick={() => setContinueShowQRCodes(!continueShowQRCodes)}> 445 {!continueShowQRCodes ? 446 intl.formatMessage({ id: "purple.checkout.continue.show-qr", defaultMessage: "Show QR code" }) 447 : intl.formatMessage({ id: "purple.checkout.continue.hide-qr", defaultMessage: "Hide QR code" }) 448 } 449 </button> 450 {continueShowQRCodes && ( 451 <> 452 <QRCodeSVG 453 value={existingAccountInfo !== null && existingAccountInfo !== undefined ? "damus:purple:landing" : "damus:purple:welcome?id=" + lnCheckout.id} 454 className="mt-6 w-[300px] h-[300px] max-w-full max-h-full mx-auto mb-6" 455 /> 456 </> 457 )} 458 <div className="text-white/40 text-xs text-center mt-4 mb-6"> 459 {/* TODO: Localize later */} 460 Issues with this step? Please ensure you are running the latest Damus iOS version from <Link href={DAMUS_TESTFLIGHT_URL} className="text-damuspink-500 underline" target="_blank">TestFlight</Link> — or <Link href="mailto:support@damus.io" className="text-damuspink-500 underline">contact us</Link> 461 </div> 462 </div> 463 )} 464 </PurpleLayout> 465 </>) 466 } 467 468 // MARK: - Helper components 469 470 function StepHeader({ stepNumber, title, done, active }: { stepNumber: number, title: string, done: boolean, active: boolean }) { 471 return (<> 472 <div className={`flex items-center mb-2 ${active ? "" : "opacity-50"}`}> 473 <div className={`flex items-center justify-center w-8 h-8 rounded-full ${done ? "bg-green-500" : active ? "bg-purple-600" : "bg-gray-500"} text-white text-sm font-semibold`}> 474 {done ? <CheckCircle className="w-4 h-4" /> : stepNumber} 475 </div> 476 <div className="ml-2 text-lg text-purple-200 font-semibold"> 477 {title} 478 </div> 479 </div> 480 </>) 481 } 482 483 // MARK: - Types 484 485 interface LNCheckout { 486 id: string, 487 verified_pubkey?: string, 488 product_template_name?: string, 489 invoice?: { 490 bolt11: string, 491 paid?: boolean, 492 label: string, 493 connection_params: { 494 nodeid: string, 495 address: string, 496 rune: string, 497 ws_proxy_address: string, 498 } 499 } 500 completed: boolean, 501 } 502 503 interface ProductTemplate { 504 description: string, 505 special_label?: string | null, 506 amount_msat: number, 507 expiry: number, 508 } 509 510 type ProductTemplates = Record<string, ProductTemplate>