@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 +26 -2
- package/package.json +1 -1
- package/templates/dashboard/Dashboard.tsx +268 -0
- package/templates/dashboard/chart.tsx +84 -0
- package/templates/dashboard/useDashboard.ts +119 -0
- package/templates/deps.json +3 -2
- package/templates/escrows/indicators/balance-progress/bar/BalanceProgress.tsx +55 -0
- package/templates/escrows/indicators/balance-progress/donut/BalanceProgress.tsx +99 -0
- package/templates/tanstack/useGetMultipleEscrowBalances.ts +41 -0
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
|
@@ -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
|
+
}
|
package/templates/deps.json
CHANGED
|
@@ -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": "^
|
|
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
|
+
};
|