@trustless-work/blocks 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/index.js CHANGED
@@ -1580,7 +1580,7 @@ if (args[0] === "init") {
1580
1580
  }
1581
1581
 
1582
1582
  const addShadcn = await promptYesNo(
1583
- "Add shadcn components (button, input, form, card, sonner, checkbox, dialog, textarea, sonner, select, table, calendar, popover, separator, calendar-05, badge, sheet, tabs, avatar, tooltip)?",
1583
+ "Add shadcn components (button, input, form, card, sonner, checkbox, dialog, textarea, sonner, select, table, calendar, popover, separator, calendar-05, badge, sheet, tabs, avatar, tooltip, progress, chart)?",
1584
1584
  true
1585
1585
  );
1586
1586
  if (addShadcn) {
@@ -1608,6 +1608,8 @@ if (args[0] === "init") {
1608
1608
  "tabs",
1609
1609
  "avatar",
1610
1610
  "tooltip",
1611
+ "progress",
1612
+ "chart",
1611
1613
  ]);
1612
1614
  });
1613
1615
  } else {
@@ -1620,7 +1622,7 @@ if (args[0] === "init") {
1620
1622
  }
1621
1623
  const meta = JSON.parse(fs.readFileSync(GLOBAL_DEPS_FILE, "utf8"));
1622
1624
  const installLibs = await promptYesNo(
1623
- "Install (react-hook-form, @tanstack/react-query, @tanstack/react-query-devtools, @trustless-work/escrow, @hookform/resolvers, axios, @creit.tech/stellar-wallets-kit, react-day-picker & zod) dependencies now?",
1625
+ "Install (react-hook-form, @tanstack/react-query, @tanstack/react-query-devtools, @trustless-work/escrow, @hookform/resolvers, axios, @creit.tech/stellar-wallets-kit, react-day-picker, recharts & zod) dependencies now?",
1624
1626
  true
1625
1627
  );
1626
1628
  if (installLibs) {
@@ -1699,6 +1701,20 @@ if (args[0] === "init") {
1699
1701
  console.log("- " + oscHyperlink("X", "https://x.com/TrustlessWork"));
1700
1702
  } else if (args[0] === "add" && args[1]) {
1701
1703
  const flags = parseFlags(args.slice(2));
1704
+ // Normalize common aliases (singular/plural, shorthand)
1705
+ const normalizeTemplateName = (name) => {
1706
+ let n = String(name).trim();
1707
+ // singular to plural base
1708
+ n = n.replace(/^escrow\b/, "escrows");
1709
+ n = n.replace(/^indicator\b/, "indicators");
1710
+ // allow nested segments singulars
1711
+ n = n.replace(/(^|\/)escrow(\/|$)/g, "$1escrows$2");
1712
+ n = n.replace(/(^|\/)indicator(\/|$)/g, "$1indicators$2");
1713
+ // friendly shape variants
1714
+ n = n.replace(/(^|\/)circle(\/|$)/g, "$1circular$2");
1715
+ return n;
1716
+ };
1717
+ args[1] = normalizeTemplateName(args[1]);
1702
1718
  const cfgPath = path.join(PROJECT_ROOT, ".twblocks.json");
1703
1719
  if (!fs.existsSync(cfgPath)) {
1704
1720
  console.error(
@@ -1791,6 +1807,14 @@ if (args[0] === "init") {
1791
1807
 
1792
1808
  --- Escrows ---
1793
1809
  trustless-work add escrows
1810
+
1811
+ --- Dashboard ---
1812
+ trustless-work add dashboard
1813
+
1814
+ --- Indicators ---
1815
+ trustless-work add escrows/indicators/balance-progress
1816
+ trustless-work add escrows/indicators/balance-progress/bar
1817
+ trustless-work add escrows/indicators/balance-progress/donut
1794
1818
 
1795
1819
  --- Escrows by role ---
1796
1820
  trustless-work add escrows/escrows-by-role
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trustless-work/blocks",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "author": "Trustless Work",
5
5
  "keywords": [
6
6
  "react",
@@ -0,0 +1,268 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import {
5
+ Card,
6
+ CardContent,
7
+ CardDescription,
8
+ CardHeader,
9
+ CardTitle,
10
+ } from "__UI_BASE__/card";
11
+ import { Separator } from "__UI_BASE__/separator";
12
+ import { useDashboard } from "./useDashboard";
13
+ import { formatCurrency } from "../helpers/format.helper";
14
+ import { Activity, Layers3, PiggyBank } from "lucide-react";
15
+ import {
16
+ AreaChart,
17
+ Area,
18
+ XAxis,
19
+ CartesianGrid,
20
+ BarChart,
21
+ Bar,
22
+ PieChart,
23
+ Pie,
24
+ Cell,
25
+ } from "recharts";
26
+ import {
27
+ ChartContainer,
28
+ ChartTooltip,
29
+ ChartTooltipContent,
30
+ type ChartConfig,
31
+ } from "__UI_BASE__/chart";
32
+
33
+ const chartConfigBar: ChartConfig = {
34
+ desktop: {
35
+ label: "Amount",
36
+ color: "var(--chart-1)",
37
+ },
38
+ };
39
+
40
+ const chartConfigDonut: ChartConfig = {
41
+ visitors: { label: "Count" },
42
+ single: { label: "Single", color: "var(--chart-1)" },
43
+ multi: { label: "Multi", color: "var(--chart-2)" },
44
+ };
45
+
46
+ const chartConfigArea: ChartConfig = {
47
+ desktop: {
48
+ label: "Created",
49
+ color: "var(--chart-1)",
50
+ },
51
+ };
52
+
53
+ export function Dashboard() {
54
+ const {
55
+ isLoading,
56
+ totalEscrows,
57
+ totalAmount,
58
+ totalBalance,
59
+ typeSlices,
60
+ amountsByDate,
61
+ createdByDate,
62
+ } = useDashboard();
63
+
64
+ const barData = React.useMemo(
65
+ () => amountsByDate.map((d) => ({ month: d.date, desktop: d.amount })),
66
+ [amountsByDate]
67
+ );
68
+
69
+ const donutData = React.useMemo(
70
+ () =>
71
+ typeSlices.map((s) => ({
72
+ browser: s.type === "single" ? "single" : "multi",
73
+ visitors: s.value,
74
+ fill:
75
+ s.type === "single" ? "var(--color-single)" : "var(--color-multi)",
76
+ })),
77
+ [typeSlices]
78
+ );
79
+
80
+ const areaData = React.useMemo(
81
+ () => createdByDate.map((d) => ({ month: d.date, desktop: d.count })),
82
+ [createdByDate]
83
+ );
84
+
85
+ return (
86
+ <div className="grid gap-6">
87
+ {/* KPI Cards */}
88
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
89
+ <Card>
90
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
91
+ <CardTitle className="text-sm font-medium">Escrows</CardTitle>
92
+ <Layers3 className="h-4 w-4 text-muted-foreground" />
93
+ </CardHeader>
94
+ <CardContent>
95
+ <div className="text-2xl font-bold">
96
+ {isLoading ? "-" : totalEscrows}
97
+ </div>
98
+ <p className="text-xs text-muted-foreground">
99
+ Total number of escrows
100
+ </p>
101
+ </CardContent>
102
+ </Card>
103
+
104
+ <Card>
105
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
106
+ <CardTitle className="text-sm font-medium">Total Amount</CardTitle>
107
+ <Activity className="h-4 w-4 text-muted-foreground" />
108
+ </CardHeader>
109
+ <CardContent>
110
+ <div className="text-2xl font-bold">
111
+ {isLoading ? "-" : formatCurrency(totalAmount, "USDC")}
112
+ </div>
113
+ <p className="text-xs text-muted-foreground">
114
+ Sum of amounts (SR + MR)
115
+ </p>
116
+ </CardContent>
117
+ </Card>
118
+
119
+ <Card>
120
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
121
+ <CardTitle className="text-sm font-medium">Total Balance</CardTitle>
122
+ <PiggyBank className="h-4 w-4 text-muted-foreground" />
123
+ </CardHeader>
124
+ <CardContent>
125
+ <div className="text-2xl font-bold">
126
+ {isLoading ? "-" : formatCurrency(totalBalance, "USDC")}
127
+ </div>
128
+ <p className="text-xs text-muted-foreground">
129
+ Total balance across all escrows
130
+ </p>
131
+ </CardContent>
132
+ </Card>
133
+ </div>
134
+
135
+ <Separator />
136
+
137
+ {/* Charts */}
138
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
139
+ {/* Bar chart: Amounts by date (shadcn pattern) */}
140
+ <Card>
141
+ <CardHeader>
142
+ <CardTitle>Escrow Amounts</CardTitle>
143
+ <CardDescription>Amounts by date</CardDescription>
144
+ </CardHeader>
145
+ <CardContent>
146
+ <ChartContainer
147
+ className="w-full h-56 sm:h-64 lg:h-72"
148
+ config={chartConfigBar}
149
+ >
150
+ <BarChart accessibilityLayer data={barData}>
151
+ <CartesianGrid vertical={false} />
152
+ <XAxis
153
+ dataKey="month"
154
+ tickLine={false}
155
+ tickMargin={10}
156
+ axisLine={false}
157
+ tickFormatter={(value) =>
158
+ new Date(String(value)).toLocaleDateString("en-US", {
159
+ month: "short",
160
+ day: "numeric",
161
+ })
162
+ }
163
+ />
164
+ <ChartTooltip
165
+ cursor={false}
166
+ content={<ChartTooltipContent hideLabel />}
167
+ />
168
+ <Bar dataKey="desktop" fill="var(--color-desktop)" radius={8} />
169
+ </BarChart>
170
+ </ChartContainer>
171
+ </CardContent>
172
+ </Card>
173
+
174
+ {/* Donut chart: Escrow types (shadcn pattern) */}
175
+ <Card className="flex flex-col">
176
+ <CardHeader className="items-center pb-0">
177
+ <CardTitle>Escrow Types</CardTitle>
178
+ <CardDescription>Escrow types</CardDescription>
179
+ </CardHeader>
180
+ <CardContent className="flex-1 pb-0">
181
+ <ChartContainer
182
+ config={chartConfigDonut}
183
+ className="w-full h-56 sm:h-64 lg:h-72"
184
+ >
185
+ <PieChart>
186
+ <ChartTooltip
187
+ cursor={false}
188
+ content={<ChartTooltipContent hideLabel />}
189
+ />
190
+ <Pie
191
+ data={donutData}
192
+ dataKey="visitors"
193
+ nameKey="browser"
194
+ innerRadius={60}
195
+ >
196
+ {donutData.map((slice, idx) => (
197
+ <Cell key={`cell-${idx}`} fill={slice.fill} />
198
+ ))}
199
+ </Pie>
200
+ </PieChart>
201
+ </ChartContainer>
202
+ <div className="mt-4 flex items-center justify-center gap-6">
203
+ <div className="flex items-center gap-2">
204
+ <span
205
+ className="h-2 w-2 rounded-full"
206
+ style={{ background: "var(--chart-1)" }}
207
+ />
208
+ <span className="text-xs text-muted-foreground">Single</span>
209
+ </div>
210
+ <div className="flex items-center gap-2">
211
+ <span
212
+ className="h-2 w-2 rounded-full"
213
+ style={{ background: "var(--chart-2)" }}
214
+ />
215
+ <span className="text-xs text-muted-foreground">Multi</span>
216
+ </div>
217
+ </div>
218
+ </CardContent>
219
+ </Card>
220
+
221
+ {/* Area chart: Created escrows (shadcn pattern) */}
222
+ <Card className="lg:col-span-2">
223
+ <CardHeader>
224
+ <CardTitle>Escrow Created</CardTitle>
225
+ <CardDescription>Created escrows by date</CardDescription>
226
+ </CardHeader>
227
+ <CardContent>
228
+ <ChartContainer
229
+ className="w-full h-56 sm:h-64 lg:h-72"
230
+ config={chartConfigArea}
231
+ >
232
+ <AreaChart
233
+ accessibilityLayer
234
+ data={areaData}
235
+ margin={{ left: 12, right: 12 }}
236
+ >
237
+ <CartesianGrid vertical={false} />
238
+ <XAxis
239
+ dataKey="month"
240
+ tickLine={false}
241
+ axisLine={false}
242
+ tickMargin={8}
243
+ tickFormatter={(value) =>
244
+ new Date(String(value)).toLocaleDateString("en-US", {
245
+ month: "short",
246
+ day: "numeric",
247
+ })
248
+ }
249
+ />
250
+ <ChartTooltip
251
+ cursor={false}
252
+ content={<ChartTooltipContent indicator="line" />}
253
+ />
254
+ <Area
255
+ dataKey="desktop"
256
+ type="natural"
257
+ fill="var(--color-desktop)"
258
+ fillOpacity={0.4}
259
+ stroke="var(--color-desktop)"
260
+ />
261
+ </AreaChart>
262
+ </ChartContainer>
263
+ </CardContent>
264
+ </Card>
265
+ </div>
266
+ </div>
267
+ );
268
+ }
@@ -0,0 +1,84 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { Tooltip as RechartsTooltip } from "recharts";
5
+
6
+ export type ChartConfig = Record<string, { label: string; color?: string }>;
7
+
8
+ interface ChartContainerProps {
9
+ config: ChartConfig;
10
+ className?: string;
11
+ children: React.ReactNode;
12
+ }
13
+
14
+ export function ChartContainer({
15
+ config,
16
+ className,
17
+ children,
18
+ }: ChartContainerProps) {
19
+ const style: React.CSSProperties = {};
20
+ for (const [key, value] of Object.entries(config)) {
21
+ const varName = `--color-${key}` as const;
22
+ if (value.color) (style as any)[varName] = value.color;
23
+ }
24
+ return (
25
+ <div className={className} style={style}>
26
+ {children}
27
+ </div>
28
+ );
29
+ }
30
+
31
+ type RechartsPayloadItem = {
32
+ value?: number | string;
33
+ dataKey?: string;
34
+ color?: string;
35
+ name?: string;
36
+ };
37
+
38
+ type RechartsTooltipContentProps = {
39
+ active?: boolean;
40
+ label?: string | number;
41
+ payload?: RechartsPayloadItem[];
42
+ };
43
+
44
+ export type ChartTooltipContentProps = {
45
+ hideLabel?: boolean;
46
+ indicator?: "line" | "dot";
47
+ } & RechartsTooltipContentProps;
48
+
49
+ export function ChartTooltip(
50
+ props: React.ComponentProps<typeof RechartsTooltip>
51
+ ) {
52
+ return <RechartsTooltip {...props} />;
53
+ }
54
+
55
+ export function ChartTooltipContent({
56
+ active,
57
+ label,
58
+ payload,
59
+ hideLabel,
60
+ }: ChartTooltipContentProps) {
61
+ if (!active || !payload || payload.length === 0) return null;
62
+ return (
63
+ <div className="rounded-md border bg-background p-2 text-sm shadow-sm">
64
+ {!hideLabel ? <div className="mb-1 font-medium">{label}</div> : null}
65
+ <div className="space-y-1">
66
+ {payload.map((item, idx) => (
67
+ <div key={idx} className="flex items-center gap-2">
68
+ <span
69
+ className="inline-block h-2 w-2 rounded-full"
70
+ style={{ backgroundColor: item.color }}
71
+ aria-hidden
72
+ />
73
+ <span className="text-muted-foreground">
74
+ {item.name ?? String(item.dataKey)}
75
+ </span>
76
+ <span className="font-medium ml-auto">
77
+ {item.value as React.ReactNode}
78
+ </span>
79
+ </div>
80
+ ))}
81
+ </div>
82
+ </div>
83
+ );
84
+ }
@@ -0,0 +1,119 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { useWalletContext } from "../wallet-kit/WalletProvider";
5
+ import { useEscrowsBySignerQuery } from "../tanstack/useEscrowsBySignerQuery";
6
+ import type { GetEscrowsFromIndexerResponse as Escrow } from "@trustless-work/escrow/types";
7
+
8
+ type AmountsByDatePoint = { date: string; amount: number };
9
+ type CreatedByDatePoint = { date: string; count: number };
10
+ type DonutSlice = { type: "single" | "multi"; value: number; fill: string };
11
+
12
+ function getCreatedDateKey(createdAt: Escrow["createdAt"]): string {
13
+ // createdAt is a Firestore-like timestamp: { _seconds, _nanoseconds }
14
+ const seconds = (createdAt as unknown as { _seconds?: number })?._seconds;
15
+ const d = seconds ? new Date(seconds * 1000) : new Date();
16
+ // YYYY-MM-DD
17
+ return d.toISOString().slice(0, 10);
18
+ }
19
+
20
+ function getSingleReleaseAmount(escrow: Escrow): number {
21
+ // Single release stores total in .amount
22
+ const raw = (escrow as unknown as { amount?: number | string }).amount;
23
+ const n = Number(raw ?? 0);
24
+ return Number.isFinite(n) ? n : 0;
25
+ }
26
+
27
+ function getMultiReleaseAmount(escrow: Escrow): number {
28
+ // Multi release accumulates across milestones
29
+ const milestones = (
30
+ escrow as unknown as {
31
+ milestones?: Array<{ amount?: number | string }>;
32
+ }
33
+ ).milestones;
34
+ if (!Array.isArray(milestones)) return 0;
35
+ return milestones.reduce((acc: number, m) => {
36
+ const n = Number(m?.amount ?? 0);
37
+ return acc + (Number.isFinite(n) ? n : 0);
38
+ }, 0);
39
+ }
40
+
41
+ function getEscrowAmount(escrow: Escrow): number {
42
+ if (escrow.type === "single-release") return getSingleReleaseAmount(escrow);
43
+ if (escrow.type === "multi-release") return getMultiReleaseAmount(escrow);
44
+ return 0;
45
+ }
46
+
47
+ export function useDashboard() {
48
+ const { walletAddress } = useWalletContext();
49
+
50
+ const {
51
+ data = [],
52
+ isLoading,
53
+ isFetching,
54
+ isError,
55
+ refetch,
56
+ } = useEscrowsBySignerQuery({
57
+ signer: walletAddress ?? "",
58
+ enabled: !!walletAddress,
59
+ });
60
+
61
+ const totalEscrows = React.useMemo<number>(() => data.length, [data.length]);
62
+
63
+ const totalAmount = React.useMemo<number>(() => {
64
+ return data.reduce((acc: number, e) => acc + getEscrowAmount(e), 0);
65
+ }, [data]);
66
+
67
+ const totalBalance = React.useMemo<number>(() => {
68
+ return data.reduce((acc: number, e) => acc + Number(e?.balance ?? 0), 0);
69
+ }, [data]);
70
+
71
+ const typeSlices = React.useMemo<DonutSlice[]>(() => {
72
+ let single = 0;
73
+ let multi = 0;
74
+ for (const e of data) {
75
+ if (e.type === "single-release") single += 1;
76
+ else if (e.type === "multi-release") multi += 1;
77
+ }
78
+ return [
79
+ { type: "single", value: single, fill: "var(--color-single)" },
80
+ { type: "multi", value: multi, fill: "var(--color-multi)" },
81
+ ];
82
+ }, [data]);
83
+
84
+ const amountsByDate = React.useMemo<AmountsByDatePoint[]>(() => {
85
+ const map = new Map<string, number>();
86
+ for (const e of data) {
87
+ const key = getCreatedDateKey(e.createdAt);
88
+ const current = map.get(key) ?? 0;
89
+ map.set(key, current + getEscrowAmount(e));
90
+ }
91
+ return Array.from(map.entries())
92
+ .map(([date, amount]) => ({ date, amount }))
93
+ .sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
94
+ }, [data]);
95
+
96
+ const createdByDate = React.useMemo<CreatedByDatePoint[]>(() => {
97
+ const map = new Map<string, number>();
98
+ for (const e of data) {
99
+ const key = getCreatedDateKey(e.createdAt);
100
+ map.set(key, (map.get(key) ?? 0) + 1);
101
+ }
102
+ return Array.from(map.entries())
103
+ .map(([date, count]) => ({ date, count }))
104
+ .sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
105
+ }, [data]);
106
+
107
+ return {
108
+ isLoading,
109
+ isFetching,
110
+ isError,
111
+ refetch,
112
+ totalEscrows,
113
+ totalAmount,
114
+ totalBalance,
115
+ typeSlices,
116
+ amountsByDate,
117
+ createdByDate,
118
+ } as const;
119
+ }
@@ -4,7 +4,7 @@
4
4
  "react-dom": "^18.2.0",
5
5
  "react-hook-form": "^7.53.0",
6
6
  "zod": "^3.23.8",
7
- "@trustless-work/escrow": "^2.0.9",
7
+ "@trustless-work/escrow": "^3.0.0",
8
8
  "@tanstack/react-query": "^5.75.0",
9
9
  "@tanstack/react-query-devtools": "^5.75.0",
10
10
  "tailwindcss": "^3.3.3",
@@ -14,7 +14,8 @@
14
14
  "@creit.tech/stellar-wallets-kit": "^1.8.0",
15
15
  "axios": "^1.7.9",
16
16
  "@tanstack/react-table": "^8.21.3",
17
- "react-day-picker": "^9.5.0"
17
+ "react-day-picker": "^9.5.0",
18
+ "recharts": "^2.13.3"
18
19
  },
19
20
  "devDependencies": {
20
21
  "postcss": "^8",
@@ -0,0 +1,55 @@
1
+ import * as React from "react";
2
+ import { Progress } from "__UI_BASE__/progress";
3
+ import { useGetMultipleEscrowBalancesQuery } from "@/components/tw-blocks/tanstack/useGetMultipleEscrowBalances";
4
+ import { formatCurrency } from "@/components/tw-blocks/helpers/format.helper";
5
+
6
+ type BalanceProgressBarProps = {
7
+ contractId: string;
8
+ target: number;
9
+ currency: string;
10
+ };
11
+
12
+ export const BalanceProgressBar = ({
13
+ contractId,
14
+ target,
15
+ currency,
16
+ }: BalanceProgressBarProps) => {
17
+ const isContractProvided = Boolean(
18
+ contractId && contractId.trim().length > 0
19
+ );
20
+
21
+ const { data, isLoading, isError } = useGetMultipleEscrowBalancesQuery({
22
+ addresses: isContractProvided ? [contractId] : [],
23
+ enabled: isContractProvided,
24
+ });
25
+
26
+ const currentBalanceRaw = Number(data?.[0]?.balance ?? 0);
27
+ const safeTarget = Number.isFinite(target) && target > 0 ? target : 0;
28
+ const progressValue =
29
+ safeTarget > 0
30
+ ? Math.min(100, Math.max(0, (currentBalanceRaw / safeTarget) * 100))
31
+ : 0;
32
+
33
+ return (
34
+ <div className="w-full">
35
+ <div className="flex items-center justify-between text-sm text-muted-foreground mb-2">
36
+ <p>
37
+ <span className="font-bold mr-1">Balance:</span>
38
+ {isLoading
39
+ ? "Loading…"
40
+ : isError
41
+ ? "-"
42
+ : formatCurrency(currentBalanceRaw, currency)}
43
+ </p>
44
+ <p>
45
+ <span className="font-bold mr-1">Target:</span>{" "}
46
+ {formatCurrency(safeTarget, currency)}
47
+ </p>
48
+ </div>
49
+ <Progress
50
+ value={isLoading || isError ? 0 : progressValue}
51
+ className="w-full"
52
+ />
53
+ </div>
54
+ );
55
+ };
@@ -0,0 +1,99 @@
1
+ // @ts-nocheck
2
+ import * as React from "react";
3
+ import { useGetMultipleEscrowBalancesQuery } from "@/components/tw-blocks/tanstack/useGetMultipleEscrowBalances";
4
+ import { formatCurrency } from "@/components/tw-blocks/helpers/format.helper";
5
+
6
+ type BalanceProgressDonutProps = {
7
+ contractId: string;
8
+ target: number;
9
+ currency: string;
10
+ };
11
+
12
+ export const BalanceProgressDonut = ({
13
+ contractId,
14
+ target,
15
+ currency,
16
+ }: BalanceProgressDonutProps) => {
17
+ const isContractProvided = Boolean(
18
+ contractId && contractId.trim().length > 0
19
+ );
20
+
21
+ const { data, isLoading, isError } = useGetMultipleEscrowBalancesQuery({
22
+ addresses: isContractProvided ? [contractId] : [],
23
+ enabled: isContractProvided,
24
+ });
25
+
26
+ const currentBalanceRaw = Number(data?.[0]?.balance ?? 0);
27
+ const safeTarget = Number.isFinite(target) && target > 0 ? target : 0;
28
+ const progressValue =
29
+ safeTarget > 0
30
+ ? Math.min(100, Math.max(0, (currentBalanceRaw / safeTarget) * 100))
31
+ : 0;
32
+
33
+ return (
34
+ <div className="w-full">
35
+ <div className="flex items-center justify-between text-sm text-muted-foreground mb-2">
36
+ <p>
37
+ <span className="font-bold mr-1">Balance:</span>
38
+ {isLoading
39
+ ? "Loading…"
40
+ : isError
41
+ ? "-"
42
+ : formatCurrency(currentBalanceRaw, currency)}
43
+ </p>
44
+ <p>
45
+ <span className="font-bold mr-1">Target:</span>{" "}
46
+ {formatCurrency(safeTarget, currency)}
47
+ </p>
48
+ </div>
49
+ {(() => {
50
+ const size = 160; // px
51
+ const stroke = 12; // px
52
+ const radius = (size - stroke) / 2;
53
+ const circumference = 2 * Math.PI * radius;
54
+ const pct = isLoading || isError ? 0 : progressValue;
55
+ const dashOffset = circumference * (1 - pct / 100);
56
+
57
+ return (
58
+ <div className="flex justify-center">
59
+ <div className="relative" style={{ width: size, height: size }}>
60
+ <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
61
+ {/* Track */}
62
+ <circle
63
+ cx={size / 2}
64
+ cy={size / 2}
65
+ r={radius}
66
+ strokeWidth={stroke}
67
+ stroke="currentColor"
68
+ className="text-muted-foreground/20"
69
+ fill="none"
70
+ strokeLinecap="round"
71
+ />
72
+ {/* Progress */}
73
+ <g transform={`rotate(-90 ${size / 2} ${size / 2})`}>
74
+ <circle
75
+ cx={size / 2}
76
+ cy={size / 2}
77
+ r={radius}
78
+ strokeWidth={stroke}
79
+ stroke="currentColor"
80
+ className="text-primary"
81
+ fill="none"
82
+ strokeDasharray={`${circumference} ${circumference}`}
83
+ strokeDashoffset={dashOffset}
84
+ strokeLinecap="round"
85
+ />
86
+ </g>
87
+ </svg>
88
+
89
+ <div className="absolute inset-0 flex flex-col items-center justify-center">
90
+ <span className="text-2xl font-bold">{Math.round(pct)}%</span>
91
+ <span className="text-muted-foreground text-sm">Progress</span>
92
+ </div>
93
+ </div>
94
+ </div>
95
+ );
96
+ })()}
97
+ </div>
98
+ );
99
+ };
@@ -0,0 +1,41 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import {
3
+ GetEscrowBalancesResponse,
4
+ GetBalanceParams,
5
+ } from "@trustless-work/escrow/types";
6
+ import { useGetMultipleEscrowBalances } from "@trustless-work/escrow/hooks";
7
+
8
+ /**
9
+ * Use the query to get the escrows balances
10
+ *
11
+ * @param params - The parameters for the query
12
+ * @returns The query result
13
+ */
14
+ export const useGetMultipleEscrowBalancesQuery = ({
15
+ addresses,
16
+ enabled = true,
17
+ }: GetBalanceParams & { enabled?: boolean }) => {
18
+ const { getMultipleBalances } = useGetMultipleEscrowBalances();
19
+
20
+ // Get the escrows by signer
21
+ return useQuery({
22
+ queryKey: ["escrows", addresses],
23
+ queryFn: async (): Promise<GetEscrowBalancesResponse[]> => {
24
+ /**
25
+ * Call the query to get the escrows from the Trustless Work Indexer
26
+ *
27
+ * @param params - The parameters for the query
28
+ * @returns The query result
29
+ */
30
+ const balances = await getMultipleBalances({ addresses });
31
+
32
+ if (!balances) {
33
+ throw new Error("Escrows not found");
34
+ }
35
+
36
+ return balances;
37
+ },
38
+ enabled: enabled,
39
+ staleTime: 1000 * 60 * 5, // 5 min
40
+ });
41
+ };