AppUsage.tsx raw
1 import {
2 CircleMinusIcon,
3 CirclePlusIcon,
4 CopyIcon,
5 Trash2Icon,
6 } from "lucide-react";
7 import React from "react";
8 import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount";
9 import FormattedFiatAmount from "src/components/FormattedFiatAmount";
10 import { IsolatedAppDrawDownDialog } from "src/components/IsolatedAppDrawDownDialog";
11 import { IsolatedAppTopupDialog } from "src/components/IsolatedAppTopupDialog";
12 import { Button } from "src/components/ui/button";
13 import {
14 Card,
15 CardContent,
16 CardDescription,
17 CardHeader,
18 CardTitle,
19 } from "src/components/ui/card";
20 import { InputWithAdornment } from "src/components/ui/custom/input-with-adornment";
21 import { LoadingButton } from "src/components/ui/custom/loading-button";
22 import { Progress } from "src/components/ui/progress";
23 import { UpgradeDialog } from "src/components/UpgradeDialog";
24 import { SUBWALLET_APPSTORE_APP_ID } from "src/constants";
25 import { useAlbyMe } from "src/hooks/useAlbyMe";
26 import { useCreateLightningAddress } from "src/hooks/useCreateLightningAddress";
27 import { useDeleteLightningAddress } from "src/hooks/useDeleteLightningAddress";
28 import { useTransactions } from "src/hooks/useTransactions";
29 import { copyToClipboard } from "src/lib/clipboard";
30 import { cn, getBudgetRenewalLabel } from "src/lib/utils";
31 import { App, Transaction } from "src/types";
32
33 export function AppUsage({ app }: { app: App }) {
34 const [page, setPage] = React.useState(1);
35 const { data: transactionsResponse } = useTransactions(
36 app.id,
37 false,
38 100,
39 page
40 );
41 const [allTransactions, setAllTransactions] = React.useState<Transaction[]>(
42 []
43 );
44 React.useEffect(() => {
45 if (transactionsResponse?.transactions.length) {
46 setAllTransactions((current) =>
47 [...current, ...transactionsResponse.transactions].filter(
48 (v, i, a) => a.findIndex((t) => t.paymentHash === v.paymentHash) === i // remove duplicates
49 )
50 );
51 setPage((current) => current + 1);
52 }
53 }, [transactionsResponse?.transactions]);
54
55 const totalSpentSat = allTransactions
56 .filter((tx) => tx.type === "outgoing" && tx.state === "settled")
57 .map((tx) => Math.floor((tx.amountMsat + tx.feesPaidMsat) / 1000))
58 .reduce((a, b) => a + b, 0);
59
60 const totalReceivedSat = allTransactions
61 .filter((tx) => tx.type === "incoming")
62 .map((tx) => tx.amountSat)
63 .reduce((a, b) => a + b, 0);
64
65 const { data: albyMe } = useAlbyMe();
66 const [intendedLightningAddress, setIntendedLightningAddress] =
67 React.useState("");
68 const { createLightningAddress, creatingLightningAddress } =
69 useCreateLightningAddress(app.id);
70 const {
71 deleteLightningAddress: deleteSubwalletLightningAddress,
72 deletingLightningAddress,
73 } = useDeleteLightningAddress(app.id);
74
75 return (
76 <>
77 {app.isolated && (
78 <div
79 className={cn(
80 "grid grid-cols-1 gap-3",
81 app.metadata?.app_store_app_id === SUBWALLET_APPSTORE_APP_ID &&
82 "lg:grid-cols-2"
83 )}
84 >
85 <Card className="justify-between">
86 <CardHeader>
87 <CardTitle>Isolated Balance</CardTitle>
88 </CardHeader>
89 <CardContent>
90 <div className="flex justify-between items-end">
91 <div>
92 <p className="font-medium text-2xl">
93 <FormattedBitcoinAmount amountMsat={app.balanceMsat} />
94 </p>
95 <FormattedFiatAmount
96 amountSat={Math.floor(app.balanceMsat / 1000)}
97 />
98 </div>
99 <div className="flex gap-2 items-center">
100 {app.balanceMsat > 0 && (
101 <IsolatedAppDrawDownDialog appId={app.id}>
102 <Button size="sm" variant="outline">
103 <CircleMinusIcon />
104 Decrease
105 </Button>
106 </IsolatedAppDrawDownDialog>
107 )}
108 <IsolatedAppTopupDialog appId={app.id}>
109 <Button size="sm" variant="outline">
110 <CirclePlusIcon />
111 Increase
112 </Button>
113 </IsolatedAppTopupDialog>
114 </div>
115 </div>
116 </CardContent>
117 </Card>
118 {app.metadata?.app_store_app_id === SUBWALLET_APPSTORE_APP_ID && (
119 <Card className="justify-between">
120 <CardHeader>
121 <CardTitle>Lightning Address</CardTitle>
122
123 {!app.metadata.lud16 && (
124 <CardDescription>
125 <p className="text-muted-foreground">
126 Create a lightning address for this particular app
127 connection
128 </p>
129 </CardDescription>
130 )}
131 </CardHeader>
132 <CardContent>
133 {!app.metadata.lud16 && (
134 <div className="flex items-center gap-2">
135 <InputWithAdornment
136 type="text"
137 value={intendedLightningAddress}
138 onChange={(e) =>
139 setIntendedLightningAddress(e.target.value)
140 }
141 required
142 autoComplete="off"
143 endAdornment={
144 <span className="mr-1 text-muted-foreground text-xs">
145 @getalby.com
146 </span>
147 }
148 className="flex-1"
149 />
150 {!albyMe?.subscription.plan_code ? (
151 <UpgradeDialog>
152 <Button className="shrink-0" variant="secondary">
153 Create
154 </Button>
155 </UpgradeDialog>
156 ) : (
157 <LoadingButton
158 className="shrink-0"
159 variant="secondary"
160 loading={creatingLightningAddress}
161 onClick={() =>
162 createLightningAddress(intendedLightningAddress)
163 }
164 >
165 Create
166 </LoadingButton>
167 )}
168 </div>
169 )}
170 {app.metadata.lud16 && (
171 <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
172 <p className="font-semibold break-all min-w-0 flex-1">
173 {app.metadata.lud16}
174 </p>
175 <div className="flex items-center gap-2 shrink-0">
176 <Button
177 size="sm"
178 onClick={() =>
179 copyToClipboard(app.metadata?.lud16 || "")
180 }
181 variant="outline"
182 >
183 <CopyIcon />
184 Copy
185 </Button>
186 <LoadingButton
187 size="sm"
188 variant="outline"
189 loading={deletingLightningAddress}
190 onClick={deleteSubwalletLightningAddress}
191 >
192 <Trash2Icon />
193 Remove
194 </LoadingButton>
195 </div>
196 </div>
197 )}
198 </CardContent>
199 </Card>
200 )}
201 </div>
202 )}
203
204 <div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
205 <Card>
206 <CardHeader>
207 <CardTitle>Total Spent</CardTitle>
208 </CardHeader>
209 <CardContent>
210 <p className="font-medium text-2xl">
211 <FormattedBitcoinAmount amountMsat={totalSpentSat * 1000} />
212 </p>
213 <FormattedFiatAmount amountSat={totalSpentSat} />
214 </CardContent>
215 </Card>
216 <Card>
217 <CardHeader>
218 <CardTitle>Total Received</CardTitle>
219 </CardHeader>
220 <CardContent>
221 <p className="font-medium text-2xl">
222 <FormattedBitcoinAmount amountMsat={totalReceivedSat * 1000} />
223 </p>
224 <FormattedFiatAmount amountSat={totalReceivedSat} />
225 </CardContent>
226 </Card>
227 </div>
228
229 {app.maxAmountSat > 0 && (
230 <Card>
231 <CardHeader>
232 <CardTitle>Budget</CardTitle>
233 </CardHeader>
234 <CardContent>
235 <div className="flex flex-row justify-between mb-2">
236 <div>
237 <p className="text-xs text-secondary-foreground font-medium">
238 Left in budget
239 </p>
240 <p className="text-xl font-medium">
241 <FormattedBitcoinAmount
242 amountMsat={app.maxAmountMsat - app.budgetUsageMsat}
243 />
244 </p>
245 <FormattedFiatAmount
246 amountSat={app.maxAmountSat - app.budgetUsageSat}
247 />
248 </div>
249 <div>
250 <p className="text-xs text-secondary-foreground font-medium">
251 Budget renewal
252 </p>
253 <p className="text-xl font-medium">
254 <FormattedBitcoinAmount amountMsat={app.maxAmountMsat} />
255 {app.budgetRenewal !== "never" && (
256 <> / {getBudgetRenewalLabel(app.budgetRenewal)}</>
257 )}
258 </p>
259 <FormattedFiatAmount amountSat={app.maxAmountSat} />
260 </div>
261 </div>
262 <Progress
263 className="h-4"
264 value={100 - (app.budgetUsageSat * 100) / app.maxAmountSat}
265 />
266 </CardContent>
267 </Card>
268 )}
269 </>
270 );
271 }
272