import React from "react"; import AppCard from "src/components/connections/AppCard"; import { Card, CardDescription, CardHeader, CardTitle, } from "src/components/ui/card"; import { useApps } from "src/hooks/useApps"; import { useCurrencies } from "src/hooks/useCurrencies"; import { createApp } from "src/requests/createApp"; import { CreateAppRequest, UpdateAppRequest } from "src/types"; import { handleRequestError } from "src/utils/handleRequestError"; import { getFormattedFiatValue, getSatoshiValue, LightningAddress, } from "@getalby/lightning-tools"; import { ExternalLinkIcon, PlusCircleIcon } from "lucide-react"; import { toast } from "sonner"; import alby from "src/assets/suggested-apps/alby.png"; import bff from "src/assets/zapplanner/bff.png"; import bitcoinbrink from "src/assets/zapplanner/bitcoinbrink.png"; import hrf from "src/assets/zapplanner/hrf.png"; import opensats from "src/assets/zapplanner/opensats.png"; import { AppStoreDetailHeader } from "src/components/connections/AppStoreDetailHeader"; import { appStoreApps } from "src/components/connections/SuggestedAppData"; import ExternalLink from "src/components/ExternalLink"; import ResponsiveButton from "src/components/ResponsiveButton"; import { Button } from "src/components/ui/button"; import { ExternalLinkButton } from "src/components/ui/custom/external-link-button"; import { LoadingButton } from "src/components/ui/custom/loading-button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "src/components/ui/dialog"; import { Input } from "src/components/ui/input"; import { Label } from "src/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "src/components/ui/select"; import { Textarea } from "src/components/ui/textarea"; import { SUPPORT_ALBY_LIGHTNING_ADDRESS } from "src/constants"; import { request } from "src/utils/request"; type Recipient = { name: string; description: string; lightningAddress: string; logo?: string; }; const recipients: Recipient[] = [ { name: "Alby", logo: alby, description: "Support the open-source development of Hub, Go, Lightning Browser Extension, developer tools and open protocols.", lightningAddress: SUPPORT_ALBY_LIGHTNING_ADDRESS, }, { name: "HRF", description: "We collaborate with transformative activists to develop innovative solutions that bring the world together in the fight against tyranny.", lightningAddress: "hrf@btcpay.hrf.org", logo: hrf, }, { name: "OpenSats", description: "Help us to provide sustainable funding for free and open-source contributors working on freedom tech and projects that help bitcoin flourish.", lightningAddress: "opensats@vlt.ge", logo: opensats, }, { name: "Brink", description: "Brink exists to strengthen the Bitcoin protocol and network through fundamental research, development, funding, mentoring.", lightningAddress: "bitcoinbrink@zbd.gg", logo: bitcoinbrink, }, { name: "Bitcoin For Fairness", description: "Bitcoin for Fairness is an initiative raising knowledge and understanding of Bitcoin with a focus on civil and human rights.", lightningAddress: "bffbtc@getalby.com", logo: bff, }, ]; export function ZapPlanner() { const { data: appsData, mutate: reloadApps } = useApps(undefined, undefined, { appStoreAppId: "zapplanner", }); const zapplannerApps = appsData?.apps; const [open, setOpen] = React.useState(false); const [isSubmitting, setSubmitting] = React.useState(false); const [recipientName, setRecipientName] = React.useState(""); const [recipientLightningAddress, setRecipientLightningAddress] = React.useState(""); const [amount, setAmount] = React.useState(""); const [comment, setComment] = React.useState(""); const [senderName, setSenderName] = React.useState(""); const [frequencyValue, setFrequencyValue] = React.useState("1"); const [frequencyUnit, setFrequencyUnit] = React.useState("months"); const [currency, setCurrency] = React.useState("USD"); const { currencies, isLoading: isCurrenciesLoading } = useCurrencies(true); const [convertedAmount, setConvertedAmount] = React.useState(""); const [satoshiAmount, setSatoshiAmount] = React.useState( undefined ); React.useEffect(() => { // reset form on close if (!open) { setRecipientName(""); setRecipientLightningAddress(""); setComment(""); setAmount("5"); setSenderName(""); setFrequencyValue("1"); setFrequencyUnit("months"); setCurrency("USD"); setConvertedAmount(""); setSatoshiAmount(undefined); } }, [open]); React.useEffect(() => { if (isCurrenciesLoading) { return; } // If amount is empty, clear conversion output if (!amount) { setConvertedAmount(""); setSatoshiAmount(undefined); return; } // Automatically convert between sats and USD if the amount changes const convertCurrency = async () => { try { // any fiat (not BTC) → sats if (currency !== "SATS") { const sats = await getSatoshiValue({ amount: parseFloat(amount), currency: currency, }); setSatoshiAmount(sats); setConvertedAmount(`~${sats.toLocaleString()} sats`); } else { // Convert satoshis to USD const sats = parseInt(amount, 10); setSatoshiAmount(sats); const fiatValue = await getFormattedFiatValue({ satoshi: sats, currency: "USD", locale: "en-US", }); setConvertedAmount(`~${fiatValue}`); } } catch (error) { console.error("Conversion error:", error); setConvertedAmount("--"); } }; convertCurrency(); }, [amount, currency, open, isCurrenciesLoading]); const appStoreApp = appStoreApps.find((app) => app.id === "zapplanner"); if (!appStoreApp) { return null; } const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setSubmitting(true); try { if (!amount || parseFloat(amount) !== parseInt(amount)) { throw new Error("Amount must be a whole number"); } if (!satoshiAmount) { throw new Error("Invalid amount"); } // parse and validate the raw frequency const rawFreq = parseInt(frequencyValue, 10); if (isNaN(rawFreq) || rawFreq < 1) { throw new Error("Invalid frequency"); } if (frequencyUnit === "months" && rawFreq !== 1) { throw new Error("Only once per month is supported, use weeks instead"); } // validate lightning address const ln = new LightningAddress(recipientLightningAddress); await ln.fetch(); if (!ln.lnurlpData) { throw new Error("invalid recipient lightning address"); } // Determine how many payments in one month let periodsPerMonth: number; switch (frequencyUnit) { case "days": periodsPerMonth = 31 / rawFreq; break; case "weeks": periodsPerMonth = 31 / 7 / rawFreq; break; case "months": // only once per month is supported periodsPerMonth = 1; break; default: throw new Error("Unsupported frequency unit"); } periodsPerMonth = Math.ceil(periodsPerMonth); // Compute raw monthly spend const rawSpend = satoshiAmount * periodsPerMonth; // with fee reserve of max(1% or 10 sats) + 30% to avoid nwc_budget_warning (see transactions service) const maxAmountSat = Math.ceil((rawSpend * 1.01 + 10) * 1.3); const isolated = false; const budgetRenewal = "monthly"; const createAppRequest: CreateAppRequest = { name: `ZapPlanner - ${recipientName}`, scopes: ["pay_invoice"], budgetRenewal, maxAmountSat, isolated, metadata: { app_store_app_id: "zapplanner", recipient_lightning_address: recipientLightningAddress, }, }; const createAppResponse = await createApp(createAppRequest); const cronExpression = frequencyUnit === "months" ? "0 0 1 * *" : undefined; // at the start of each month const sleepDuration = frequencyUnit !== "months" ? `${frequencyValue} ${frequencyUnit}` : undefined; const subscriptionBody: Record = { recipientLightningAddress, message: comment || "ZapPlanner payment from Alby Hub", payerData: JSON.stringify({ ...(senderName ? { name: senderName } : {}), }), nostrWalletConnectUrl: createAppResponse.pairingUri, sleepDuration, cronExpression, currency, amount: parseInt(amount), }; // TODO: proxy through hub backend and remove CSRF exceptions for zapplanner.albylabs.com const createSubscriptionResponse = await fetch( "https://zapplanner.albylabs.com/api/subscriptions", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(subscriptionBody), } ); if (!createSubscriptionResponse.ok) { throw new Error( "Failed to create subscription: " + createSubscriptionResponse.status ); } const { subscriptionId } = await createSubscriptionResponse.json(); if (!subscriptionId) { throw new Error("no subscription ID in create subscription response"); } // add the ZapPlanner subscription ID to the app metadata // Only send metadata since that's the only thing changing const updateAppRequest: UpdateAppRequest = { metadata: { ...createAppRequest.metadata, zapplanner_subscription_id: subscriptionId, }, }; await request(`/api/apps/${createAppResponse.pairingPublicKey}`, { method: "PATCH", headers: { "Content-Type": "application/json", }, body: JSON.stringify(updateAppRequest), }); toast("Created subscription", { description: cronExpression ? "Payment will be made at the start of each month" : "The first payment is scheduled immediately.", }); reloadApps(); setOpen(false); } catch (error) { handleRequestError("Failed to create app", error); } finally { setSubmitting(false); } }; return (
New Recurring Payment For advanced options go to{" "} zapplanner.albylabs.com
setRecipientName(e.target.value)} className="col-span-3 w-70" />
setRecipientLightningAddress(e.target.value) } className="col-span-3 w-70" />
setAmount(e.target.value)} className="col-span-3 w-70" /> {convertedAmount && ( {convertedAmount} )}
setFrequencyValue(e.target.value)} className="col-span-3 w-70" />
Repeat payment every