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