ReceiveInvoice.tsx raw

   1  import {
   2    AlertTriangleIcon,
   3    ArrowLeftIcon,
   4    CopyIcon,
   5    LinkIcon,
   6    PlusIcon,
   7    ReceiptTextIcon,
   8  } from "lucide-react";
   9  import React from "react";
  10  import { Link } from "react-router";
  11  import { toast } from "sonner";
  12  import AppHeader from "src/components/AppHeader";
  13  import { CurrencyInputField } from "src/components/CurrencyInputField";
  14  import ExternalLink from "src/components/ExternalLink";
  15  import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount";
  16  import FormattedFiatAmount from "src/components/FormattedFiatAmount";
  17  import Loading from "src/components/Loading";
  18  import LottieSuccess from "src/components/LottieSuccess";
  19  import LowReceivingCapacityAlert from "src/components/LowReceivingCapacityAlert";
  20  import QRCode from "src/components/QRCode";
  21  import {
  22    Accordion,
  23    AccordionContent,
  24    AccordionItem,
  25    AccordionTrigger,
  26  } from "src/components/ui/accordion";
  27  import { Alert, AlertDescription } from "src/components/ui/alert";
  28  import { Button } from "src/components/ui/button";
  29  import {
  30    Card,
  31    CardContent,
  32    CardFooter,
  33    CardHeader,
  34    CardTitle,
  35  } from "src/components/ui/card";
  36  import { ExternalLinkButton } from "src/components/ui/custom/external-link-button";
  37  import { LinkButton } from "src/components/ui/custom/link-button";
  38  import { LoadingButton } from "src/components/ui/custom/loading-button";
  39  import { Input } from "src/components/ui/input";
  40  import { Label } from "src/components/ui/label";
  41  import { useAlbyMe } from "src/hooks/useAlbyMe";
  42  import { useBalances } from "src/hooks/useBalances";
  43  import { useChannels } from "src/hooks/useChannels";
  44  
  45  import { useInfo } from "src/hooks/useInfo";
  46  import { useTransaction } from "src/hooks/useTransaction";
  47  import { copyToClipboard } from "src/lib/clipboard";
  48  import { cn } from "src/lib/utils";
  49  import ReceiveToSelect from "src/screens/wallet/receive/ReceiveToSelect";
  50  import { CreateInvoiceRequest, Transaction } from "src/types";
  51  import { request } from "src/utils/request";
  52  
  53  export default function ReceiveInvoice() {
  54    const { data: info, hasChannelManagement } = useInfo();
  55    const { data: me } = useAlbyMe();
  56    const { data: balances } = useBalances();
  57    const { data: channels } = useChannels();
  58  
  59    const [isLoading, setLoading] = React.useState(false);
  60    const [jitChannelRequestFailed, setJitChannelRequestFailed] =
  61      React.useState(false);
  62    const [amountSat, setAmountSat] = React.useState<string>("");
  63    const [description, setDescription] = React.useState<string>("");
  64    const [toAppId, setToAppId] = React.useState<number>();
  65    const [transaction, setTransaction] = React.useState<Transaction | null>(
  66      null
  67    );
  68    const { data: invoiceData } = useTransaction(
  69      transaction ? transaction.paymentHash : "",
  70      true
  71    );
  72    const paymentDone = !!invoiceData?.settledAt;
  73    const jitChannelsEnabled = !!info?.jitChannelsEnabled;
  74    const configuredLsps2Source = info?.jitChannelsLiquiditySource;
  75    const lsps2Source = jitChannelsEnabled ? configuredLsps2Source : undefined;
  76    const lsps2MinimumPaymentSizeSat = React.useMemo(() => {
  77      if (jitChannelsEnabled && info?.jitChannelsMinPaymentSizeMsat) {
  78        return Math.ceil(info.jitChannelsMinPaymentSizeMsat / 1000);
  79      }
  80      return undefined;
  81    }, [info?.jitChannelsMinPaymentSizeMsat, jitChannelsEnabled]);
  82    // only enforce the minimum on the input when the user has no channels yet -
  83    // their first channel must meet the minimum size.
  84    const jitMinimumReceiveSat = channels?.length
  85      ? undefined
  86      : lsps2MinimumPaymentSizeSat;
  87    const lsps2MaximumPaymentSizeSat = React.useMemo(() => {
  88      if (jitChannelsEnabled && info?.jitChannelsMaxPaymentSizeMsat) {
  89        return Math.floor(info.jitChannelsMaxPaymentSizeMsat / 1000);
  90      }
  91      return undefined;
  92    }, [info?.jitChannelsMaxPaymentSizeMsat, jitChannelsEnabled]);
  93    const jitMaximumReceiveSat =
  94      hasChannelManagement && lsps2Source
  95        ? lsps2MaximumPaymentSizeSat
  96        : !lsps2Source && hasChannelManagement
  97          ? balances?.lightning.totalReceivableSat
  98          : undefined;
  99    const totalReceivableMsat = balances?.lightning.totalReceivableMsat ?? 0;
 100    const requestedAmountMsat = +amountSat * 1000 || transaction?.amountMsat || 0;
 101    const isNearReceivingCapacity =
 102      !!hasChannelManagement && requestedAmountMsat >= 0.8 * totalReceivableMsat;
 103    const isJitReceiveInvoice =
 104      !!hasChannelManagement &&
 105      !!lsps2Source &&
 106      !!transaction &&
 107      transaction.amountMsat > totalReceivableMsat;
 108    const displayedJitFeeMsat = paymentDone
 109      ? (invoiceData?.feesPaidMsat ?? 0)
 110      : (transaction?.feesPaidMsat ?? 0);
 111  
 112    if (!balances || !info || (info.albyAccountConnected && !me)) {
 113      return <Loading />;
 114    }
 115  
 116    const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
 117      event.preventDefault();
 118  
 119      try {
 120        setLoading(true);
 121        setJitChannelRequestFailed(false);
 122        const invoice = await request<Transaction>("/api/invoices", {
 123          method: "POST",
 124          headers: {
 125            "Content-Type": "application/json",
 126          },
 127          body: JSON.stringify({
 128            amountMsat: (parseInt(amountSat) || 0) * 1000,
 129            description,
 130            toAppId,
 131          } as CreateInvoiceRequest),
 132        });
 133  
 134        if (invoice) {
 135          setTransaction(invoice);
 136          setAmountSat("");
 137          setDescription("");
 138          toast("Successfully created invoice");
 139        }
 140      } catch (e) {
 141        const requestedAmountSat = parseInt(amountSat) || 0;
 142        // the user already has channels but this amount exceeds their receiving
 143        // capacity (so a new channel is needed) and is below the LSP's minimum
 144        // channel size - the receive may have failed because the amount was too
 145        // small to open a second channel, so add a hint alongside the error.
 146        const likelyTooSmallForNewChannel =
 147          jitChannelsEnabled &&
 148          !!channels?.length &&
 149          !!lsps2MinimumPaymentSizeSat &&
 150          requestedAmountSat < lsps2MinimumPaymentSizeSat &&
 151          requestedAmountSat * 1000 > totalReceivableMsat;
 152        let description = "" + e;
 153        if (likelyTooSmallForNewChannel) {
 154          description += `\n\nThis amount is over your receiving capacity and may be too small to open a new Lightning channel. Try receiving at least ${new Intl.NumberFormat().format(
 155            lsps2MinimumPaymentSizeSat as number
 156          )} sats, or lower the amount to fit your current capacity.`;
 157        }
 158        toast.error("Failed to create invoice", {
 159          description,
 160        });
 161        if (lsps2Source) {
 162          setJitChannelRequestFailed(true);
 163        }
 164        console.error(e);
 165      } finally {
 166        setLoading(false);
 167      }
 168    };
 169  
 170    const copy = () => {
 171      copyToClipboard(transaction?.invoice as string);
 172    };
 173  
 174    const newChannelFeeAlert = (
 175      <p className="text-sm text-muted-foreground text-center">
 176        Includes a <FormattedBitcoinAmount amountMsat={displayedJitFeeMsat} />{" "}
 177        channel fee.{" "}
 178        <ExternalLink
 179          to="https://guides.getalby.com/user-guide/alby-hub/faq/what-are-just-in-time-channels"
 180          className="underline"
 181        >
 182          Learn more
 183        </ExternalLink>
 184      </p>
 185    );
 186  
 187    return (
 188      <div className="grid gap-5">
 189        <AppHeader
 190          pageTitle={transaction ? "Lightning Invoice" : "Create Invoice"}
 191          title={transaction ? "Lightning Invoice" : "Create Invoice"}
 192        />
 193        <div className="flex flex-col md:flex-row gap-12">
 194          <div className="w-full md:max-w-lg grid gap-6">
 195            {!lsps2Source && !transaction && isNearReceivingCapacity && (
 196              <LowReceivingCapacityAlert />
 197            )}
 198            <div>
 199              {transaction ? (
 200                <Card>
 201                  {!paymentDone ? (
 202                    <>
 203                      <CardHeader>
 204                        <CardTitle className="flex items-center justify-center gap-2">
 205                          <Loading variant="loader" />
 206                          <p>Waiting for Payment...</p>
 207                        </CardTitle>
 208                      </CardHeader>
 209                      <CardContent className="flex flex-col items-center gap-6">
 210                        <QRCode
 211                          value={transaction.invoice}
 212                          paymentType="lightning"
 213                        />
 214                        <div className="flex flex-col gap-1 items-center">
 215                          <p className="text-2xl font-medium slashed-zero">
 216                            <FormattedBitcoinAmount
 217                              amountMsat={transaction.amountMsat}
 218                            />
 219                          </p>
 220                          <FormattedFiatAmount
 221                            amountSat={transaction.amountSat}
 222                            className="text-xl"
 223                          />
 224                        </div>
 225                        {isJitReceiveInvoice && displayedJitFeeMsat >= 1000 && (
 226                          <div className="w-full">{newChannelFeeAlert}</div>
 227                        )}
 228                      </CardContent>
 229                      <CardFooter className="flex flex-col gap-3">
 230                        <Button
 231                          className="w-full"
 232                          onClick={copy}
 233                          variant="outline"
 234                        >
 235                          <CopyIcon className="size-4" />
 236                          Copy Invoice
 237                        </Button>
 238                      </CardFooter>
 239                    </>
 240                  ) : (
 241                    <>
 242                      <CardHeader>
 243                        <CardTitle className="text-center">
 244                          Payment Received
 245                        </CardTitle>
 246                      </CardHeader>
 247                      <CardContent className="flex flex-col items-center gap-6">
 248                        <LottieSuccess />
 249                        <div className="flex flex-col gap-1 items-center">
 250                          <p className="text-2xl font-medium slashed-zero">
 251                            <FormattedBitcoinAmount
 252                              amountMsat={transaction.amountMsat}
 253                            />
 254                          </p>
 255                          <FormattedFiatAmount
 256                            amountSat={transaction.amountSat}
 257                            className="text-xl"
 258                          />
 259                        </div>
 260                      </CardContent>
 261                      <CardFooter className="flex flex-col gap-3 pt-2">
 262                        <Button
 263                          onClick={() => {
 264                            setTransaction(null);
 265                          }}
 266                          variant="outline"
 267                          className="w-full"
 268                        >
 269                          <PlusIcon className="size-4" />
 270                          Create Another Invoice
 271                        </Button>
 272                        <LinkButton
 273                          to="/wallet"
 274                          variant="link"
 275                          className="w-full"
 276                        >
 277                          <ArrowLeftIcon className="size-4" />
 278                          Back to Wallet
 279                        </LinkButton>
 280                      </CardFooter>
 281                    </>
 282                  )}
 283                </Card>
 284              ) : (
 285                <form onSubmit={handleSubmit} className="grid gap-6">
 286                  <CurrencyInputField
 287                    id="amount"
 288                    valueSat={amountSat}
 289                    onValueSatChange={setAmountSat}
 290                    minSat={jitMinimumReceiveSat ?? 1}
 291                    onInvalid={(e) => {
 292                      if (
 293                        jitMinimumReceiveSat &&
 294                        e.currentTarget.validity.rangeUnderflow
 295                      ) {
 296                        e.currentTarget.setCustomValidity(
 297                          `You need to receive at least ${new Intl.NumberFormat().format(
 298                            jitMinimumReceiveSat
 299                          )} sats to open your first lightning channel`
 300                        );
 301                      } else if (
 302                        jitMaximumReceiveSat &&
 303                        e.currentTarget.validity.rangeOverflow
 304                      ) {
 305                        e.currentTarget.setCustomValidity(
 306                          lsps2Source
 307                            ? `This JIT channel setup supports receiving at most ${new Intl.NumberFormat().format(
 308                                jitMaximumReceiveSat
 309                              )} sats in a single payment`
 310                            : `You can receive at most ${new Intl.NumberFormat().format(
 311                                jitMaximumReceiveSat
 312                              )} sats with your current capacity`
 313                        );
 314                      } else {
 315                        e.currentTarget.setCustomValidity("");
 316                      }
 317                    }}
 318                    maxSat={jitMaximumReceiveSat}
 319                    autoFocus
 320                    contextRows={
 321                      hasChannelManagement && !lsps2Source && jitMaximumReceiveSat
 322                        ? [
 323                            {
 324                              label: "Receive limit",
 325                              amountSat: jitMaximumReceiveSat,
 326                            },
 327                          ]
 328                        : undefined
 329                    }
 330                  />
 331                  <div className="grid gap-2">
 332                    <Label htmlFor="description">Description</Label>
 333                    <Input
 334                      id="description"
 335                      type="text"
 336                      value={description}
 337                      placeholder="For e.g. who is sending this payment?"
 338                      onChange={(e) => {
 339                        setDescription(e.target.value);
 340                      }}
 341                    />
 342                  </div>
 343                  <ReceiveToSelect appId={toAppId} onChange={setToAppId} />
 344                  <LoadingButton
 345                    className={cn(
 346                      "w-full",
 347                      info?.albyAccountConnected &&
 348                        me?.lightning_address &&
 349                        "md:w-fit"
 350                    )}
 351                    loading={isLoading}
 352                    type="submit"
 353                    disabled={!amountSat}
 354                  >
 355                    Create Invoice
 356                  </LoadingButton>
 357                  {(!info?.albyAccountConnected || !me?.lightning_address) && (
 358                    <Accordion type="single" collapsible className="w-full">
 359                      <AccordionItem value="more-options">
 360                        <AccordionTrigger>
 361                          View other ways to receive
 362                        </AccordionTrigger>
 363                        <AccordionContent className="flex flex-col gap-2">
 364                          {!info?.albyAccountConnected && info.supportsBolt12 && (
 365                            <LinkButton
 366                              to="/wallet/receive/offer"
 367                              variant="outline"
 368                              className="w-full"
 369                            >
 370                              <ReceiptTextIcon className="h-4 w-4" />
 371                              Lightning Offer
 372                            </LinkButton>
 373                          )}
 374                          <LinkButton
 375                            to="/wallet/receive/onchain"
 376                            variant="outline"
 377                            className="w-full"
 378                          >
 379                            <LinkIcon className="h-4 w-4" />
 380                            Receive from On-chain / Other Cryptocurrency
 381                          </LinkButton>
 382                        </AccordionContent>
 383                      </AccordionItem>
 384                    </Accordion>
 385                  )}
 386                </form>
 387              )}
 388            </div>
 389            {!transaction && jitChannelRequestFailed && (
 390              <Alert variant="warning">
 391                <AlertTriangleIcon className="h-4 w-4" />
 392                <AlertDescription className="inline">
 393                  Failed to request a just-in-time channel invoice.{" "}
 394                  <Link className="underline" to="/channels/incoming">
 395                    Manually open a channel.
 396                  </Link>
 397                </AlertDescription>
 398              </Alert>
 399            )}
 400          </div>
 401          {!transaction &&
 402            (!info?.albyAccountConnected || !me?.lightning_address) && (
 403              <LightningAddressCard />
 404            )}
 405        </div>
 406      </div>
 407    );
 408  }
 409  
 410  function LightningAddressCard() {
 411    return (
 412      <Card className="w-full self-start">
 413        <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
 414          <CardTitle className="font-semibold text-lg">
 415            Get Your Free Lightning Address
 416          </CardTitle>
 417        </CardHeader>
 418        <CardContent>
 419          <div className="flex flex-col gap-3 text-muted-foreground">
 420            <p>
 421              Create free Alby Account and link it with your Alby Hub to get a
 422              convenient <span className="text-foreground">@getalby.com</span>{" "}
 423              lightning address and other perks:
 424            </p>
 425            <ul className="flex flex-col gap-1">
 426              <li>• Lightning address & Nostr identifier,</li>
 427              <li>• Personal tipping page,</li>
 428              <li>• Access to podcasting 2.0 apps,</li>
 429              <li>• Buy bitcoin directly to your wallet,</li>
 430              <li>• Useful email Alby Hub notifications.</li>
 431            </ul>
 432          </div>
 433        </CardContent>
 434        <CardFooter className="flex justify-end">
 435          <ExternalLinkButton
 436            to="https://getalby.com/auth/users/new"
 437            variant="secondary"
 438          >
 439            Get Alby Account
 440          </ExternalLinkButton>
 441        </CardFooter>
 442      </Card>
 443    );
 444  }
 445