SupportAlby.tsx raw
1 import {
2 CodeIcon,
3 HandCoinsIcon,
4 PlusCircleIcon,
5 RefreshCwIcon,
6 SparklesIcon,
7 } from "lucide-react";
8 import React from "react";
9 import { useNavigate } from "react-router";
10 import { toast } from "sonner";
11 import AppHeader from "src/components/AppHeader";
12 import ExternalLink from "src/components/ExternalLink";
13 import { Button } from "src/components/ui/button";
14 import {
15 Card,
16 CardContent,
17 CardDescription,
18 CardFooter,
19 CardHeader,
20 CardTitle,
21 } from "src/components/ui/card";
22 import { LoadingButton } from "src/components/ui/custom/loading-button";
23 import {
24 Dialog,
25 DialogContent,
26 DialogDescription,
27 DialogFooter,
28 DialogHeader,
29 DialogTitle,
30 DialogTrigger,
31 } from "src/components/ui/dialog";
32 import { Input } from "src/components/ui/input";
33 import { Label } from "src/components/ui/label";
34 import { UpgradeDialog } from "src/components/UpgradeDialog";
35 import {
36 SUPPORT_ALBY_CONNECTION_NAME,
37 SUPPORT_ALBY_LIGHTNING_ADDRESS,
38 } from "src/constants";
39 import { useInfo } from "src/hooks/useInfo";
40 import { createApp } from "src/requests/createApp";
41 import { CreateAppRequest, UpdateAppRequest } from "src/types";
42 import { formatBitcoinAmount } from "src/utils/bitcoinFormatting";
43 import { handleRequestError } from "src/utils/handleRequestError";
44 import { request } from "src/utils/request";
45
46 function SupportAlby() {
47 const navigate = useNavigate();
48 const { data: info } = useInfo();
49
50 const [amountSat, setAmountSat] = React.useState("");
51 const [senderName, setSenderName] = React.useState("");
52 const [isSubmitting, setSubmitting] = React.useState(false);
53 const [open, setOpen] = React.useState(false);
54
55 const handleSubmit = async (e: React.FormEvent) => {
56 e.preventDefault();
57
58 if (!info) {
59 return;
60 }
61
62 setSubmitting(true);
63 try {
64 const parsedAmountSat = Number(amountSat);
65 if (!Number.isInteger(parsedAmountSat) || parsedAmountSat < 1) {
66 throw new Error("Invalid amount");
67 }
68
69 if (parsedAmountSat < 1000) {
70 toast.error("Amount too low", {
71 description: `Minimum payment is ${formatBitcoinAmount(
72 1_000 * 1000,
73 info.bitcoinDisplayFormat
74 )}`,
75 });
76 return;
77 }
78
79 // TODO: extract below code as is duplicated with ZapPlanner
80 // with fee reserve of max(1% or 10 sats) + 30% to avoid nwc_budget_warning (see transactions service)
81 const maxAmountSat = Math.floor((parsedAmountSat * 1.01 + 10) * 1.3);
82 const isolated = false;
83
84 const createAppRequest: CreateAppRequest = {
85 name: SUPPORT_ALBY_CONNECTION_NAME,
86 scopes: ["pay_invoice"],
87 budgetRenewal: "monthly",
88 maxAmountSat,
89 isolated,
90 metadata: {
91 app_store_app_id: "zapplanner",
92 recipient_lightning_address: SUPPORT_ALBY_LIGHTNING_ADDRESS,
93 },
94 };
95
96 const createAppResponse = await createApp(createAppRequest);
97
98 // TODO: proxy through hub backend and remove CSRF exceptions for zapplanner.albylabs.com
99 const createSubscriptionResponse = await fetch(
100 "https://zapplanner.albylabs.com/api/subscriptions",
101 {
102 method: "POST",
103 headers: {
104 "Content-Type": "application/json",
105 },
106 body: JSON.stringify({
107 recipientLightningAddress: SUPPORT_ALBY_LIGHTNING_ADDRESS,
108 amount: parsedAmountSat,
109 message: "ZapPlanner payment from Alby Hub",
110 payerData: JSON.stringify({
111 ...(senderName ? { name: senderName } : {}),
112 }),
113 nostrWalletConnectUrl: createAppResponse.pairingUri,
114 cronExpression: "0 0 1 * *", // at the start of each month
115 }),
116 }
117 );
118 if (!createSubscriptionResponse.ok) {
119 throw new Error(
120 "Failed to create subscription: " + createSubscriptionResponse.status
121 );
122 }
123
124 const { subscriptionId } = await createSubscriptionResponse.json();
125 if (!subscriptionId) {
126 throw new Error("no subscription ID in create subscription response");
127 }
128
129 // add the ZapPlanner subscription ID to the app metadata
130 // Only send metadata since that's the only thing changing
131 const updateAppRequest: UpdateAppRequest = {
132 metadata: {
133 ...createAppRequest.metadata,
134 zapplanner_subscription_id: subscriptionId,
135 },
136 };
137
138 await request(`/api/apps/${createAppResponse.pairingPublicKey}`, {
139 method: "PATCH",
140 headers: {
141 "Content-Type": "application/json",
142 },
143 body: JSON.stringify(updateAppRequest),
144 });
145
146 toast("Thank you for becoming a supporter", {
147 description: "Payment will be made at the start of each month",
148 });
149
150 navigate("/");
151 } catch (error) {
152 handleRequestError("Failed to create app", error);
153 } finally {
154 setSubmitting(false);
155 }
156 };
157
158 return (
159 <>
160 <AppHeader
161 title="Support Alby Hub"
162 pageTitle="Support Alby"
163 description="We are committed to elevating the Bitcoin ecosystem by offering reliable, efficient, and user-friendly software solutions for seamless transactions. With your help, we can keep pushing boundaries and evolving Alby Hub into something extraordinary."
164 />
165 <h2 className="text-2xl font-semibold">Become a Supporter</h2>
166 <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
167 <Card className="flex flex-col">
168 <CardHeader className="grow">
169 <CardTitle>Upgrade to Pro</CardTitle>
170 <CardDescription>
171 Upgrade your Alby Account to Pro for a small fee and enjoy
172 additional perks that come with it!
173 </CardDescription>
174 </CardHeader>
175 <CardFooter className="flex justify-end">
176 <UpgradeDialog>
177 <Button>
178 <SparklesIcon />
179 Upgrade to Pro
180 </Button>
181 </UpgradeDialog>
182 </CardFooter>
183 </Card>
184 <Card className="flex flex-col">
185 <CardHeader className="grow">
186 <CardTitle>Donate to Alby Hub development</CardTitle>
187 <CardDescription>
188 Set up a recurring value4value payment to support the development
189 of Alby Hub, Alby Go, and the NWC ecosystem.
190 </CardDescription>
191 </CardHeader>
192 <CardContent className="flex-1 grow" />
193 <CardFooter className="flex justify-end">
194 <Dialog open={open} onOpenChange={setOpen}>
195 <div className="flex flex-col items-center justify-center gap-2">
196 <DialogTrigger asChild>
197 <Button>
198 <HandCoinsIcon />
199 Setup Donation
200 </Button>
201 </DialogTrigger>
202 </div>
203 <DialogContent>
204 <form onSubmit={handleSubmit}>
205 <DialogHeader>
206 <DialogTitle>Become a Supporter</DialogTitle>
207 <DialogDescription>
208 A new app connection will be established to facilitate
209 monthly payments to Alby. You can cancel it anytime
210 through the connections page.
211 </DialogDescription>
212 </DialogHeader>
213 <div className="flex flex-col gap-3 my-5">
214 <div className="grid grid-cols-4 gap-4">
215 <Label htmlFor="amount" className="text-right mt-2">
216 Amount <br></br>
217 <span className="font-normal text-muted-foreground">
218 (sats / month)
219 </span>
220 </Label>
221 <div className="col-span-3">
222 <Input
223 id="amount"
224 value={amountSat}
225 required
226 onChange={(e) => setAmountSat(e.target.value)}
227 />
228 <div className="grid grid-cols-3 gap-1 mt-1">
229 <Button
230 type="button"
231 variant="outline"
232 onClick={() => setAmountSat("3000")}
233 >
234 🙏 3000
235 </Button>
236 <Button
237 type="button"
238 variant="outline"
239 onClick={() => setAmountSat("6000")}
240 >
241 💪 6000
242 </Button>
243 <Button
244 type="button"
245 variant="outline"
246 onClick={() => setAmountSat("10000")}
247 >
248 ✨ 10000
249 </Button>
250 </div>
251 </div>
252 </div>
253 <div className="grid grid-cols-4 items-center gap-4">
254 <Label htmlFor="comment" className="text-right">
255 Name{" "}
256 <span className="font-normal text-muted-foreground">
257 (optional)
258 </span>
259 </Label>
260 <div className="col-span-3">
261 <Input
262 id="sender-name"
263 value={senderName}
264 onChange={(e) => setSenderName(e.target.value)}
265 placeholder={`Nickname, npub, @twitter, etc.`}
266 />
267 </div>
268 </div>
269 </div>
270
271 <DialogFooter>
272 <LoadingButton
273 type="submit"
274 disabled={!!isSubmitting}
275 loading={isSubmitting}
276 >
277 Complete Setup
278 </LoadingButton>
279 </DialogFooter>
280 </form>
281 </DialogContent>
282 </Dialog>
283 </CardFooter>
284 </Card>
285 </div>
286 <div className="mt-4">
287 <h2 className="text-2xl font-semibold mb-4">
288 Why Your Contribution Is Important
289 </h2>
290 <ul className="flex flex-col gap-5">
291 <li className="flex flex-col">
292 <div className="flex flex-row items-center">
293 <PlusCircleIcon className="size-4 mr-2" />
294 Unlock New Features
295 </div>
296 <div className="text-muted-foreground text-sm">
297 Your support empowers us to design and implement cutting-edge{" "}
298 <ExternalLink
299 className="underline"
300 to="https://github.com/getAlby/hub/issues"
301 >
302 features
303 </ExternalLink>{" "}
304 that enhance your experience and keep us at the forefront of
305 technology.
306 </div>
307 </li>
308 <li className="flex flex-col ">
309 <div className="flex flex-row items-center">
310 <RefreshCwIcon className="size-4 mr-2" />
311 Ensure Continuous Improvement
312 </div>
313 <div className="text-muted-foreground text-sm">
314 With your contributions, we can provide{" "}
315 <ExternalLink
316 className="underline"
317 to="https://github.com/getAlby/hub/releases"
318 >
319 regular updates
320 </ExternalLink>{" "}
321 and ongoing maintenance, ensuring everything runs smoothly and
322 efficiently for all users.
323 </div>
324 </li>
325 <li className="flex flex-col ">
326 <div className="flex flex-row items-center">
327 <CodeIcon className="size-4 mr-2" />
328 Support Open-Source Freedom
329 </div>
330 <div className="text-muted-foreground text-sm">
331 Your support helps us keep Alby Hub true to the principles of{" "}
332 <ExternalLink
333 className="underline"
334 to="https://github.com/getAlby/hub/blob/master/LICENSE"
335 >
336 free and open-source software
337 </ExternalLink>{" "}
338 and remains accessible for everyone to use, modify and improve.
339 </div>
340 </li>
341 </ul>
342 </div>
343 </>
344 );
345 }
346
347 export default SupportAlby;
348