ZapPlanner.tsx raw
1 import React from "react";
2 import AppCard from "src/components/connections/AppCard";
3 import {
4 Card,
5 CardDescription,
6 CardHeader,
7 CardTitle,
8 } from "src/components/ui/card";
9 import { useApps } from "src/hooks/useApps";
10 import { useCurrencies } from "src/hooks/useCurrencies";
11 import { createApp } from "src/requests/createApp";
12 import { CreateAppRequest, UpdateAppRequest } from "src/types";
13 import { handleRequestError } from "src/utils/handleRequestError";
14
15 import {
16 getFormattedFiatValue,
17 getSatoshiValue,
18 LightningAddress,
19 } from "@getalby/lightning-tools";
20 import { ExternalLinkIcon, PlusCircleIcon } from "lucide-react";
21 import { toast } from "sonner";
22 import alby from "src/assets/suggested-apps/alby.png";
23 import bff from "src/assets/zapplanner/bff.png";
24 import bitcoinbrink from "src/assets/zapplanner/bitcoinbrink.png";
25 import hrf from "src/assets/zapplanner/hrf.png";
26 import opensats from "src/assets/zapplanner/opensats.png";
27 import { AppStoreDetailHeader } from "src/components/connections/AppStoreDetailHeader";
28 import { appStoreApps } from "src/components/connections/SuggestedAppData";
29 import ExternalLink from "src/components/ExternalLink";
30 import ResponsiveButton from "src/components/ResponsiveButton";
31 import { Button } from "src/components/ui/button";
32 import { ExternalLinkButton } from "src/components/ui/custom/external-link-button";
33 import { LoadingButton } from "src/components/ui/custom/loading-button";
34 import {
35 Dialog,
36 DialogContent,
37 DialogDescription,
38 DialogFooter,
39 DialogHeader,
40 DialogTitle,
41 DialogTrigger,
42 } from "src/components/ui/dialog";
43 import { Input } from "src/components/ui/input";
44 import { Label } from "src/components/ui/label";
45 import {
46 Select,
47 SelectContent,
48 SelectItem,
49 SelectTrigger,
50 SelectValue,
51 } from "src/components/ui/select";
52 import { Textarea } from "src/components/ui/textarea";
53 import { SUPPORT_ALBY_LIGHTNING_ADDRESS } from "src/constants";
54 import { request } from "src/utils/request";
55
56 type Recipient = {
57 name: string;
58 description: string;
59 lightningAddress: string;
60 logo?: string;
61 };
62
63 const recipients: Recipient[] = [
64 {
65 name: "Alby",
66 logo: alby,
67 description:
68 "Support the open-source development of Hub, Go, Lightning Browser Extension, developer tools and open protocols.",
69 lightningAddress: SUPPORT_ALBY_LIGHTNING_ADDRESS,
70 },
71 {
72 name: "HRF",
73 description:
74 "We collaborate with transformative activists to develop innovative solutions that bring the world together in the fight against tyranny.",
75 lightningAddress: "hrf@btcpay.hrf.org",
76 logo: hrf,
77 },
78 {
79 name: "OpenSats",
80 description:
81 "Help us to provide sustainable funding for free and open-source contributors working on freedom tech and projects that help bitcoin flourish.",
82 lightningAddress: "opensats@vlt.ge",
83 logo: opensats,
84 },
85 {
86 name: "Brink",
87 description:
88 "Brink exists to strengthen the Bitcoin protocol and network through fundamental research, development, funding, mentoring.",
89 lightningAddress: "bitcoinbrink@zbd.gg",
90 logo: bitcoinbrink,
91 },
92 {
93 name: "Bitcoin For Fairness",
94 description:
95 "Bitcoin for Fairness is an initiative raising knowledge and understanding of Bitcoin with a focus on civil and human rights.",
96 lightningAddress: "bffbtc@getalby.com",
97 logo: bff,
98 },
99 ];
100
101 export function ZapPlanner() {
102 const { data: appsData, mutate: reloadApps } = useApps(undefined, undefined, {
103 appStoreAppId: "zapplanner",
104 });
105 const zapplannerApps = appsData?.apps;
106
107 const [open, setOpen] = React.useState(false);
108 const [isSubmitting, setSubmitting] = React.useState(false);
109 const [recipientName, setRecipientName] = React.useState("");
110 const [recipientLightningAddress, setRecipientLightningAddress] =
111 React.useState("");
112 const [amount, setAmount] = React.useState("");
113 const [comment, setComment] = React.useState("");
114 const [senderName, setSenderName] = React.useState("");
115 const [frequencyValue, setFrequencyValue] = React.useState("1");
116 const [frequencyUnit, setFrequencyUnit] = React.useState("months");
117 const [currency, setCurrency] = React.useState<string>("USD");
118 const { currencies, isLoading: isCurrenciesLoading } = useCurrencies(true);
119
120 const [convertedAmount, setConvertedAmount] = React.useState<string>("");
121 const [satoshiAmount, setSatoshiAmount] = React.useState<number | undefined>(
122 undefined
123 );
124
125 React.useEffect(() => {
126 // reset form on close
127 if (!open) {
128 setRecipientName("");
129 setRecipientLightningAddress("");
130 setComment("");
131 setAmount("5");
132 setSenderName("");
133 setFrequencyValue("1");
134 setFrequencyUnit("months");
135 setCurrency("USD");
136 setConvertedAmount("");
137 setSatoshiAmount(undefined);
138 }
139 }, [open]);
140
141 React.useEffect(() => {
142 if (isCurrenciesLoading) {
143 return;
144 }
145
146 // If amount is empty, clear conversion output
147 if (!amount) {
148 setConvertedAmount("");
149 setSatoshiAmount(undefined);
150 return;
151 }
152
153 // Automatically convert between sats and USD if the amount changes
154 const convertCurrency = async () => {
155 try {
156 // any fiat (not BTC) → sats
157 if (currency !== "SATS") {
158 const sats = await getSatoshiValue({
159 amount: parseFloat(amount),
160 currency: currency,
161 });
162 setSatoshiAmount(sats);
163 setConvertedAmount(`~${sats.toLocaleString()} sats`);
164 } else {
165 // Convert satoshis to USD
166 const sats = parseInt(amount, 10);
167 setSatoshiAmount(sats);
168 const fiatValue = await getFormattedFiatValue({
169 satoshi: sats,
170 currency: "USD",
171 locale: "en-US",
172 });
173 setConvertedAmount(`~${fiatValue}`);
174 }
175 } catch (error) {
176 console.error("Conversion error:", error);
177 setConvertedAmount("--");
178 }
179 };
180
181 convertCurrency();
182 }, [amount, currency, open, isCurrenciesLoading]);
183
184 const appStoreApp = appStoreApps.find((app) => app.id === "zapplanner");
185 if (!appStoreApp) {
186 return null;
187 }
188
189 const handleSubmit = async (e: React.FormEvent) => {
190 e.preventDefault();
191 setSubmitting(true);
192
193 try {
194 if (!amount || parseFloat(amount) !== parseInt(amount)) {
195 throw new Error("Amount must be a whole number");
196 }
197 if (!satoshiAmount) {
198 throw new Error("Invalid amount");
199 }
200 // parse and validate the raw frequency
201 const rawFreq = parseInt(frequencyValue, 10);
202 if (isNaN(rawFreq) || rawFreq < 1) {
203 throw new Error("Invalid frequency");
204 }
205
206 if (frequencyUnit === "months" && rawFreq !== 1) {
207 throw new Error("Only once per month is supported, use weeks instead");
208 }
209
210 // validate lightning address
211 const ln = new LightningAddress(recipientLightningAddress);
212 await ln.fetch();
213 if (!ln.lnurlpData) {
214 throw new Error("invalid recipient lightning address");
215 }
216 // Determine how many payments in one month
217 let periodsPerMonth: number;
218 switch (frequencyUnit) {
219 case "days":
220 periodsPerMonth = 31 / rawFreq;
221 break;
222 case "weeks":
223 periodsPerMonth = 31 / 7 / rawFreq;
224 break;
225 case "months":
226 // only once per month is supported
227 periodsPerMonth = 1;
228 break;
229 default:
230 throw new Error("Unsupported frequency unit");
231 }
232 periodsPerMonth = Math.ceil(periodsPerMonth);
233 // Compute raw monthly spend
234 const rawSpend = satoshiAmount * periodsPerMonth;
235
236 // with fee reserve of max(1% or 10 sats) + 30% to avoid nwc_budget_warning (see transactions service)
237 const maxAmountSat = Math.ceil((rawSpend * 1.01 + 10) * 1.3);
238 const isolated = false;
239
240 const budgetRenewal = "monthly";
241
242 const createAppRequest: CreateAppRequest = {
243 name: `ZapPlanner - ${recipientName}`,
244 scopes: ["pay_invoice"],
245 budgetRenewal,
246 maxAmountSat,
247 isolated,
248 metadata: {
249 app_store_app_id: "zapplanner",
250 recipient_lightning_address: recipientLightningAddress,
251 },
252 };
253
254 const createAppResponse = await createApp(createAppRequest);
255
256 const cronExpression =
257 frequencyUnit === "months" ? "0 0 1 * *" : undefined; // at the start of each month
258 const sleepDuration =
259 frequencyUnit !== "months"
260 ? `${frequencyValue} ${frequencyUnit}`
261 : undefined;
262
263 const subscriptionBody: Record<string, unknown> = {
264 recipientLightningAddress,
265 message: comment || "ZapPlanner payment from Alby Hub",
266 payerData: JSON.stringify({
267 ...(senderName ? { name: senderName } : {}),
268 }),
269 nostrWalletConnectUrl: createAppResponse.pairingUri,
270 sleepDuration,
271 cronExpression,
272 currency,
273 amount: parseInt(amount),
274 };
275
276 // TODO: proxy through hub backend and remove CSRF exceptions for zapplanner.albylabs.com
277 const createSubscriptionResponse = await fetch(
278 "https://zapplanner.albylabs.com/api/subscriptions",
279 {
280 method: "POST",
281 headers: {
282 "Content-Type": "application/json",
283 },
284 body: JSON.stringify(subscriptionBody),
285 }
286 );
287 if (!createSubscriptionResponse.ok) {
288 throw new Error(
289 "Failed to create subscription: " + createSubscriptionResponse.status
290 );
291 }
292
293 const { subscriptionId } = await createSubscriptionResponse.json();
294 if (!subscriptionId) {
295 throw new Error("no subscription ID in create subscription response");
296 }
297
298 // add the ZapPlanner subscription ID to the app metadata
299 // Only send metadata since that's the only thing changing
300 const updateAppRequest: UpdateAppRequest = {
301 metadata: {
302 ...createAppRequest.metadata,
303 zapplanner_subscription_id: subscriptionId,
304 },
305 };
306
307 await request(`/api/apps/${createAppResponse.pairingPublicKey}`, {
308 method: "PATCH",
309 headers: {
310 "Content-Type": "application/json",
311 },
312 body: JSON.stringify(updateAppRequest),
313 });
314
315 toast("Created subscription", {
316 description: cronExpression
317 ? "Payment will be made at the start of each month"
318 : "The first payment is scheduled immediately.",
319 });
320
321 reloadApps();
322 setOpen(false);
323 } catch (error) {
324 handleRequestError("Failed to create app", error);
325 } finally {
326 setSubmitting(false);
327 }
328 };
329
330 return (
331 <div className="grid gap-5">
332 <AppStoreDetailHeader
333 appStoreApp={appStoreApp}
334 contentRight={
335 <>
336 <Dialog open={open} onOpenChange={setOpen}>
337 <ResponsiveButton
338 asChild
339 icon={PlusCircleIcon}
340 text="New Recurring Payment"
341 >
342 <DialogTrigger />
343 </ResponsiveButton>
344 <DialogContent className="sm:max-w-[600px]">
345 <form onSubmit={handleSubmit}>
346 <DialogHeader>
347 <DialogTitle>New Recurring Payment</DialogTitle>
348 <DialogDescription>
349 For advanced options go to{" "}
350 <ExternalLink
351 className="underline"
352 to="https://zapplanner.albylabs.com"
353 >
354 zapplanner.albylabs.com
355 </ExternalLink>
356 </DialogDescription>
357 </DialogHeader>
358
359 <div className="grid gap-4 py-4">
360 <div className="grid grid-cols-4 items-center gap-4">
361 <Label htmlFor="name" className="text-right">
362 Recipient Name
363 </Label>
364 <Input
365 id="name"
366 value={recipientName}
367 required
368 onChange={(e) => setRecipientName(e.target.value)}
369 className="col-span-3 w-70"
370 />
371 </div>
372 <div className="grid grid-cols-4 items-center gap-4">
373 <Label htmlFor="name" className="text-right">
374 Recipient Lightning Address
375 </Label>
376 <Input
377 id="receiver"
378 required
379 value={recipientLightningAddress}
380 onChange={(e) =>
381 setRecipientLightningAddress(e.target.value)
382 }
383 className="col-span-3 w-70"
384 />
385 </div>
386 <div className="grid grid-cols-4 items-center gap-4">
387 <Label htmlFor="amount" className="text-right">
388 Amount
389 </Label>
390 <div className="col-span-3 flex items-center gap-2">
391 <div className="relative flex-1">
392 <Input
393 id="amount"
394 type="number"
395 min="0"
396 step="any"
397 inputMode="decimal"
398 value={amount}
399 onChange={(e) => setAmount(e.target.value)}
400 className="col-span-3 w-70"
401 />
402
403 {convertedAmount && (
404 <span className="absolute inset-y-0 right-3 flex items-center text-sm text-gray-500 pointer-events-none">
405 {convertedAmount}
406 </span>
407 )}
408 </div>
409
410 <Select
411 value={currency}
412 onValueChange={setCurrency}
413 disabled={isCurrenciesLoading}
414 >
415 <SelectTrigger className="w-1/2">
416 <SelectValue
417 placeholder={
418 isCurrenciesLoading
419 ? "Loading currencies..."
420 : "Select a currency"
421 }
422 />
423 </SelectTrigger>
424 <SelectContent>
425 {currencies.map(([code]) => (
426 <SelectItem key={code} value={code}>
427 {code}
428 </SelectItem>
429 ))}
430 </SelectContent>
431 </Select>
432 </div>
433 </div>
434
435 <div className="grid grid-cols-4 items-start gap-4">
436 <Label htmlFor="frequency" className="text-right pt-2">
437 Frequency
438 </Label>
439 <div className="col-span-3 flex flex-col gap-1 w-full max-w-[450px]">
440 <div className="flex items-center gap-2 w-full">
441 <Input
442 id="frequency"
443 type="number"
444 min="1"
445 step="1"
446 inputMode="numeric"
447 value={frequencyValue}
448 onChange={(e) => setFrequencyValue(e.target.value)}
449 className="col-span-3 w-70"
450 />
451 <Select
452 value={frequencyUnit}
453 onValueChange={setFrequencyUnit}
454 >
455 <SelectTrigger className="w-1/2">
456 <SelectValue />
457 </SelectTrigger>
458 <SelectContent>
459 <SelectItem value="days">days</SelectItem>
460 <SelectItem value="weeks">weeks</SelectItem>
461 <SelectItem value="months">months</SelectItem>
462 </SelectContent>
463 </Select>
464 </div>
465
466 <span className="text-muted-foreground text-sm">
467 Repeat payment every
468 </span>
469 </div>
470 </div>
471
472 <div className="grid grid-cols-4 gap-4">
473 <Label htmlFor="comment" className="text-right pt-2">
474 Comment
475 </Label>
476 <Textarea
477 id="comment"
478 value={comment}
479 onChange={(e) => setComment(e.target.value)}
480 placeholder="Optional comment"
481 className="col-span-3 w-70"
482 />
483 </div>
484 <div className="grid grid-cols-4 items-center gap-4">
485 <Label htmlFor="comment" className="text-right">
486 Your Name
487 </Label>
488 <Input
489 id="sender-name"
490 value={senderName}
491 onChange={(e) => setSenderName(e.target.value)}
492 placeholder={`Let ${recipientName || "them"} know it was from you`}
493 className="col-span-3 w-70"
494 />
495 </div>
496 </div>
497 <DialogFooter>
498 <LoadingButton
499 type="submit"
500 disabled={!!isSubmitting}
501 loading={isSubmitting}
502 >
503 Create
504 </LoadingButton>
505 </DialogFooter>
506 </form>
507 </DialogContent>
508 </Dialog>
509 </>
510 }
511 />
512 <p className="text-muted-foreground">
513 ZapPlanner is a tool to securely schedule recurring payments. A new
514 special app connection with a strict budget is created for each
515 scheduled payment. This allows you to securely setup recurring payments
516 and be in full control.
517 </p>
518 <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
519 {recipients.map((recipient) => (
520 <Card key={recipient.lightningAddress}>
521 <CardHeader>
522 <div className="flex flex-row items-center gap-3">
523 <img src={recipient.logo} className="rounded-lg w-10 h-10" />
524 <div className="flex flex-row gap-3 grow justify-between items-center">
525 <CardTitle>{recipient.name}</CardTitle>
526 <Button
527 size="sm"
528 onClick={() => {
529 setRecipientName(recipient.name);
530 setRecipientLightningAddress(recipient.lightningAddress);
531 setOpen(true);
532 }}
533 >
534 Support
535 </Button>
536 </div>
537 </div>
538 <CardDescription>{recipient.description}</CardDescription>
539 </CardHeader>
540 </Card>
541 ))}
542 </div>
543
544 {!!zapplannerApps?.length && (
545 <>
546 <h2 className="font-semibold text-xl">Recurring Payments</h2>
547 <div className="grid grid-cols-1 lg:grid-cols-2 gap-3 items-stretch">
548 {zapplannerApps.map((app, index) => (
549 <AppCard
550 key={index}
551 app={app}
552 actions={
553 app.metadata?.zapplanner_subscription_id ? (
554 <ExternalLinkButton
555 to={`https://zapplanner.albylabs.com/subscriptions/${app.metadata.zapplanner_subscription_id}`}
556 size="sm"
557 >
558 View <ExternalLinkIcon className="size-4 ml-2" />
559 </ExternalLinkButton>
560 ) : undefined
561 }
562 />
563 ))}
564 </div>
565 </>
566 )}
567 </div>
568 );
569 }
570