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