damus.io

damus.io website
git clone git://jb55.com/damus.io
Log | Files | Refs | README | LICENSE

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>