@trustless-work/blocks 1.0.0 → 1.0.2

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.
Files changed (36) hide show
  1. package/bin/index.js +78 -1
  2. package/package.json +1 -1
  3. package/templates/deps.json +1 -1
  4. package/templates/escrows/details/Actions.tsx +21 -1
  5. package/templates/escrows/indicators/balance-progress/bar/BalanceProgress.tsx +55 -0
  6. package/templates/escrows/indicators/balance-progress/donut/BalanceProgress.tsx +99 -0
  7. package/templates/escrows/multi-release/initialize-escrow/dialog/InitializeEscrow.tsx +1 -0
  8. package/templates/escrows/multi-release/initialize-escrow/form/InitializeEscrow.tsx +1 -0
  9. package/templates/escrows/multi-release/initialize-escrow/shared/schema.ts +0 -1
  10. package/templates/escrows/multi-release/initialize-escrow/shared/useInitializeEscrow.ts +0 -2
  11. package/templates/escrows/multi-release/resolve-dispute/button/ResolveDispute.tsx +10 -20
  12. package/templates/escrows/multi-release/resolve-dispute/dialog/ResolveDispute.tsx +117 -60
  13. package/templates/escrows/multi-release/resolve-dispute/form/ResolveDispute.tsx +111 -55
  14. package/templates/escrows/multi-release/resolve-dispute/shared/schema.ts +68 -71
  15. package/templates/escrows/multi-release/resolve-dispute/shared/useResolveDispute.ts +107 -21
  16. package/templates/escrows/multi-release/update-escrow/shared/schema.ts +0 -1
  17. package/templates/escrows/multi-release/update-escrow/shared/useUpdateEscrow.ts +0 -4
  18. package/templates/escrows/multi-release/withdraw-remaining-funds/button/WithdrawRemainingFunds.tsx +85 -0
  19. package/templates/escrows/multi-release/withdraw-remaining-funds/dialog/WithdrawRemainingFunds.tsx +176 -0
  20. package/templates/escrows/multi-release/withdraw-remaining-funds/form/WithdrawRemainingFunds.tsx +153 -0
  21. package/templates/escrows/multi-release/withdraw-remaining-funds/shared/schema.ts +81 -0
  22. package/templates/escrows/multi-release/withdraw-remaining-funds/shared/useWithdrawRemainingFunds.ts +160 -0
  23. package/templates/escrows/single-multi-release/approve-milestone/button/ApproveMilestone.tsx +0 -1
  24. package/templates/escrows/single-multi-release/approve-milestone/shared/useApproveMilestone.ts +0 -1
  25. package/templates/escrows/single-release/initialize-escrow/shared/schema.ts +0 -1
  26. package/templates/escrows/single-release/initialize-escrow/shared/useInitializeEscrow.ts +0 -2
  27. package/templates/escrows/single-release/resolve-dispute/button/ResolveDispute.tsx +15 -31
  28. package/templates/escrows/single-release/resolve-dispute/dialog/ResolveDispute.tsx +116 -60
  29. package/templates/escrows/single-release/resolve-dispute/form/ResolveDispute.tsx +98 -43
  30. package/templates/escrows/single-release/resolve-dispute/shared/schema.ts +65 -68
  31. package/templates/escrows/single-release/resolve-dispute/shared/useResolveDispute.ts +100 -22
  32. package/templates/escrows/single-release/update-escrow/shared/schema.ts +0 -1
  33. package/templates/escrows/single-release/update-escrow/shared/useUpdateEscrow.ts +0 -4
  34. package/templates/tanstack/useEscrowsMutations.ts +53 -0
  35. package/templates/tanstack/useGetMultipleEscrowBalances.ts +41 -0
  36. package/templates/wallet-kit/trustlines.ts +0 -4
@@ -11,7 +11,7 @@ import { Input } from "__UI_BASE__/input";
11
11
  import { Button } from "__UI_BASE__/button";
12
12
  import { useResolveDispute } from "./useResolveDispute";
13
13
  import { useEscrowContext } from "@/components/tw-blocks/providers/EscrowProvider";
14
- import { Loader2 } from "lucide-react";
14
+ import { Loader2, Trash2 } from "lucide-react";
15
15
  import {
16
16
  Select,
17
17
  SelectContent,
@@ -28,7 +28,22 @@ export const ResolveDisputeForm = ({
28
28
  showSelectMilestone?: boolean;
29
29
  milestoneIndex?: number | string;
30
30
  }) => {
31
- const { form, handleSubmit, isSubmitting, totalAmount } = useResolveDispute();
31
+ const {
32
+ form,
33
+ handleSubmit,
34
+ isSubmitting,
35
+ totalAmount,
36
+ distributions,
37
+ handleAddDistribution,
38
+ handleRemoveDistribution,
39
+ handleDistributionAddressChange,
40
+ handleDistributionAmountChange,
41
+ isAnyDistributionEmpty,
42
+ allowedAmount,
43
+ distributedSum,
44
+ isExactMatch,
45
+ difference,
46
+ } = useResolveDispute();
32
47
  const { selectedEscrow } = useEscrowContext();
33
48
 
34
49
  React.useEffect(() => {
@@ -79,52 +94,106 @@ export const ResolveDisputeForm = ({
79
94
  />
80
95
  )}
81
96
 
82
- <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
83
- <FormField
84
- control={form.control}
85
- name="approverFunds"
86
- render={({ field }) => (
87
- <FormItem>
88
- <FormLabel>Approver Funds</FormLabel>
89
- <FormControl>
90
- <Input
91
- type="text"
92
- inputMode="decimal"
93
- placeholder="Enter approver funds"
94
- value={field.value as unknown as string}
95
- onChange={(e) => field.onChange(e.target.value)}
96
- />
97
- </FormControl>
98
- <FormMessage />
99
- </FormItem>
100
- )}
101
- />
97
+ <FormLabel className="flex items-center my-4">
98
+ Distributions<span className="text-destructive ml-1">*</span>
99
+ </FormLabel>
102
100
 
103
- <FormField
104
- control={form.control}
105
- name="receiverFunds"
106
- render={({ field }) => (
107
- <FormItem>
108
- <FormLabel>Receiver Funds</FormLabel>
109
- <FormControl>
110
- <Input
111
- type="text"
112
- inputMode="decimal"
113
- placeholder="Enter receiver funds"
114
- value={field.value as unknown as string}
115
- onChange={(e) => field.onChange(e.target.value)}
116
- />
117
- </FormControl>
118
- <FormMessage />
119
- </FormItem>
120
- )}
121
- />
101
+ {distributions.map((d, idx) => (
102
+ <div
103
+ key={`dist-${idx}`}
104
+ className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-[1fr_minmax(140px,220px)_auto] gap-3 sm:gap-4 items-end mb-2"
105
+ >
106
+ <FormField
107
+ control={form.control}
108
+ name={`distributions.${idx}.address` as const}
109
+ render={() => (
110
+ <FormItem className="sm:col-span-2 lg:col-span-1">
111
+ <FormLabel>Address</FormLabel>
112
+ <FormControl>
113
+ <Input
114
+ type="text"
115
+ placeholder="Receiver address"
116
+ value={d.address}
117
+ onChange={(e) =>
118
+ handleDistributionAddressChange(idx, e.target.value)
119
+ }
120
+ />
121
+ </FormControl>
122
+ <FormMessage />
123
+ </FormItem>
124
+ )}
125
+ />
126
+
127
+ <FormField
128
+ control={form.control}
129
+ name={`distributions.${idx}.amount` as const}
130
+ render={() => (
131
+ <FormItem>
132
+ <FormLabel>Amount</FormLabel>
133
+ <FormControl>
134
+ <Input
135
+ type="text"
136
+ inputMode="decimal"
137
+ placeholder="0.00"
138
+ value={(d.amount as string) ?? ""}
139
+ onChange={(e) => handleDistributionAmountChange(idx, e)}
140
+ />
141
+ </FormControl>
142
+ <FormMessage />
143
+ </FormItem>
144
+ )}
145
+ />
146
+
147
+ <Button
148
+ type="button"
149
+ onClick={() => handleRemoveDistribution(idx)}
150
+ className="justify-self-end self-end p-2 bg-transparent text-destructive rounded-md border-none shadow-none hover:bg-transparent hover:shadow-none hover:text-destructive focus:ring-0 active:ring-0"
151
+ disabled={distributions.length <= 2}
152
+ >
153
+ <Trash2 className="h-5 w-5" />
154
+ </Button>
155
+ </div>
156
+ ))}
157
+
158
+ <div className="flex justify-between items-center">
159
+ <Button
160
+ type="button"
161
+ variant="outline"
162
+ onClick={handleAddDistribution}
163
+ disabled={isAnyDistributionEmpty}
164
+ className="cursor-pointer"
165
+ >
166
+ Add Item
167
+ </Button>
168
+
169
+ <div className="flex items-center gap-4">
170
+ <div className="text-xs text-muted-foreground">
171
+ <p>
172
+ <span className="font-bold">Total Amount: </span>
173
+ {distributedSum.toFixed(2)} / {allowedAmount.toFixed(2)}
174
+ </p>
175
+ {!isExactMatch && (
176
+ <p className="text-destructive">
177
+ <span className="font-bold">Difference: </span>
178
+ {difference.toFixed(2)}
179
+ </p>
180
+ )}
181
+ </div>
182
+
183
+ <p className="text-xs text-muted-foreground">
184
+ <span className="font-bold">Total Balance: </span>
185
+ {formatCurrency(
186
+ selectedEscrow?.balance || 0,
187
+ selectedEscrow?.trustline.name || ""
188
+ )}
189
+ </p>
190
+ </div>
122
191
  </div>
123
192
 
124
193
  <div className="mt-4">
125
194
  <Button
126
195
  type="submit"
127
- disabled={isSubmitting}
196
+ disabled={isSubmitting || isAnyDistributionEmpty || !isExactMatch}
128
197
  className="cursor-pointer"
129
198
  >
130
199
  {isSubmitting ? (
@@ -136,19 +205,6 @@ export const ResolveDisputeForm = ({
136
205
  "Resolve"
137
206
  )}
138
207
  </Button>
139
-
140
- <p className="text-xs text-muted-foreground">
141
- <span className="font-bold">Total Amount: </span>
142
- {formatCurrency(totalAmount, selectedEscrow?.trustline.name || "")}
143
- </p>
144
-
145
- <p className="text-xs text-muted-foreground">
146
- <span className="font-bold">Total Balance: </span>
147
- {formatCurrency(
148
- selectedEscrow?.balance || 0,
149
- selectedEscrow?.trustline.name || ""
150
- )}
151
- </p>
152
208
  </div>
153
209
  </form>
154
210
  </Form>
@@ -1,83 +1,80 @@
1
1
  import { z } from "zod";
2
+ import { isValidWallet } from "../../../../wallet-kit/validators";
2
3
 
3
4
  export const getFormSchema = () => {
4
- return z.object({
5
- approverFunds: z
6
- .union([z.string(), z.number()])
7
- .refine(
8
- (val) => {
9
- if (typeof val === "string") {
10
- if (val === "" || val === "." || val.endsWith(".")) {
11
- return true;
12
- }
13
- const numVal = Number(val);
14
- return !isNaN(numVal) && numVal >= 0;
5
+ const amountSchema = z
6
+ .union([z.string(), z.number()])
7
+ .refine(
8
+ (val) => {
9
+ if (typeof val === "string") {
10
+ if (val === "" || val === "." || val.endsWith(".")) {
11
+ return true; // Allow partial input
15
12
  }
16
- return val >= 0;
17
- },
18
- {
19
- message: "Approver funds must be 0 or greater.",
13
+ const numVal = Number(val);
14
+ return !isNaN(numVal) && numVal >= 0;
20
15
  }
21
- )
22
- .refine(
23
- (val) => {
24
- if (typeof val === "string") {
25
- if (val === "" || val === "." || val.endsWith(".")) {
26
- return true;
27
- }
28
- const numVal = Number(val);
29
- if (isNaN(numVal)) return false;
30
- const decimalPlaces = (numVal.toString().split(".")[1] || "")
31
- .length;
32
- return decimalPlaces <= 2;
16
+ return val >= 0;
17
+ },
18
+ { message: "Amount must be 0 or greater." }
19
+ )
20
+ .refine(
21
+ (val) => {
22
+ if (typeof val === "string") {
23
+ if (val === "" || val === "." || val.endsWith(".")) {
24
+ return true; // Allow partial input
33
25
  }
34
- const decimalPlaces = (val.toString().split(".")[1] || "").length;
26
+ const numVal = Number(val);
27
+ if (isNaN(numVal)) return false;
28
+ const decimalPlaces = (numVal.toString().split(".")[1] || "").length;
35
29
  return decimalPlaces <= 2;
36
- },
37
- {
38
- message: "Approver funds can have a maximum of 2 decimal places.",
39
30
  }
40
- ),
41
- receiverFunds: z
42
- .union([z.string(), z.number()])
43
- .refine(
44
- (val) => {
45
- if (typeof val === "string") {
46
- if (val === "" || val === "." || val.endsWith(".")) {
47
- return true;
48
- }
49
- const numVal = Number(val);
50
- return !isNaN(numVal) && numVal >= 0;
51
- }
52
- return val >= 0;
53
- },
54
- {
55
- message: "Receiver funds must be 0 or greater.",
56
- }
57
- )
58
- .refine(
59
- (val) => {
60
- if (typeof val === "string") {
61
- if (val === "" || val === "." || val.endsWith(".")) {
62
- return true;
63
- }
64
- const numVal = Number(val);
65
- if (isNaN(numVal)) return false;
66
- const decimalPlaces = (numVal.toString().split(".")[1] || "")
67
- .length;
68
- return decimalPlaces <= 2;
69
- }
70
- const decimalPlaces = (val.toString().split(".")[1] || "").length;
71
- return decimalPlaces <= 2;
72
- },
73
- {
74
- message: "Receiver funds can have a maximum of 2 decimal places.",
31
+ const decimalPlaces = (val.toString().split(".")[1] || "").length;
32
+ return decimalPlaces <= 2;
33
+ },
34
+ { message: "Amount can have a maximum of 2 decimal places." }
35
+ );
36
+
37
+ return z
38
+ .object({
39
+ distributions: z
40
+ .array(
41
+ z.object({
42
+ address: z
43
+ .string()
44
+ .min(1, { message: "Address is required." })
45
+ .refine((addr) => isValidWallet(addr), {
46
+ message: "Invalid Stellar address.",
47
+ }),
48
+ amount: amountSchema,
49
+ })
50
+ )
51
+ .min(2, { message: "At least two distributions are required." }),
52
+ milestoneIndex: z
53
+ .string({ required_error: "Milestone is required" })
54
+ .min(1, { message: "Milestone is required" }),
55
+ })
56
+ .superRefine((data, ctx) => {
57
+ const seen = new Map<string, number>();
58
+ data.distributions.forEach((item, idx) => {
59
+ const key = (item.address || "").trim().toUpperCase();
60
+ if (!key) return;
61
+ if (seen.has(key)) {
62
+ const firstIdx = seen.get(key)!;
63
+ ctx.addIssue({
64
+ code: z.ZodIssueCode.custom,
65
+ path: ["distributions", idx, "address"],
66
+ message: "Duplicate address. Each recipient must be unique.",
67
+ });
68
+ ctx.addIssue({
69
+ code: z.ZodIssueCode.custom,
70
+ path: ["distributions", firstIdx, "address"],
71
+ message: "Duplicate address. Each recipient must be unique.",
72
+ });
73
+ } else {
74
+ seen.set(key, idx);
75
75
  }
76
- ),
77
- milestoneIndex: z
78
- .string({ required_error: "Milestone is required" })
79
- .min(1, { message: "Milestone is required" }),
80
- });
76
+ });
77
+ });
81
78
  };
82
79
 
83
80
  export const resolveDisputeSchema = getFormSchema();
@@ -15,6 +15,8 @@ import {
15
15
  } from "@/components/tw-blocks/handle-errors/handle";
16
16
  import { useWalletContext } from "@/components/tw-blocks/wallet-kit/WalletProvider";
17
17
 
18
+ type DistributionInput = { address: string; amount: string | number };
19
+
18
20
  export function useResolveDispute() {
19
21
  const { resolveDispute } = useEscrowsMutations();
20
22
  const { selectedEscrow, updateEscrow } = useEscrowContext();
@@ -23,8 +25,10 @@ export function useResolveDispute() {
23
25
  const form = useForm<ResolveDisputeValues>({
24
26
  resolver: zodResolver(resolveDisputeSchema),
25
27
  defaultValues: {
26
- approverFunds: 0,
27
- receiverFunds: 0,
28
+ distributions: [
29
+ { address: "", amount: "" },
30
+ { address: "", amount: "" },
31
+ ],
28
32
  milestoneIndex: "0",
29
33
  },
30
34
  mode: "onChange",
@@ -39,33 +43,97 @@ export function useResolveDispute() {
39
43
  );
40
44
  }, [selectedEscrow]);
41
45
 
46
+ const milestoneIndexWatch = form.watch("milestoneIndex");
47
+
48
+ const allowedAmount = React.useMemo(() => {
49
+ if (selectedEscrow?.type !== "multi-release") return 0;
50
+ const idx = Number(milestoneIndexWatch);
51
+ const milestones = selectedEscrow.milestones as MultiReleaseMilestone[];
52
+ const m = milestones?.[idx];
53
+ return Number((m?.amount as unknown as number) || 0);
54
+ }, [selectedEscrow, milestoneIndexWatch]);
55
+
56
+ const distributions = form.watch("distributions") as DistributionInput[];
57
+
58
+ const distributedSum = React.useMemo(() => {
59
+ return (distributions || []).reduce((acc, d) => {
60
+ const n = Number(d?.amount ?? 0);
61
+ return acc + (isNaN(n) ? 0 : n);
62
+ }, 0);
63
+ }, [distributions]);
64
+
65
+ const isExactMatch = React.useMemo(() => {
66
+ return Number(allowedAmount) === Number(distributedSum);
67
+ }, [allowedAmount, distributedSum]);
68
+
69
+ const difference = React.useMemo(() => {
70
+ return Math.abs(Number(allowedAmount) - Number(distributedSum));
71
+ }, [allowedAmount, distributedSum]);
72
+
42
73
  const [isSubmitting, setIsSubmitting] = React.useState(false);
43
74
 
75
+ const handleDistributionAddressChange = (index: number, value: string) => {
76
+ const updated = [...distributions];
77
+ updated[index] = { ...updated[index], address: value };
78
+ form.setValue("distributions", updated);
79
+ };
80
+
81
+ const handleDistributionAmountChange = (
82
+ index: number,
83
+ e: React.ChangeEvent<HTMLInputElement>
84
+ ) => {
85
+ let rawValue = e.target.value;
86
+ rawValue = rawValue.replace(/[^0-9.]/g, "");
87
+ if (rawValue.split(".").length > 2) {
88
+ rawValue = rawValue.slice(0, -1);
89
+ }
90
+ if (rawValue.includes(".")) {
91
+ const parts = rawValue.split(".");
92
+ if (parts[1] && parts[1].length > 2) {
93
+ rawValue = parts[0] + "." + parts[1].slice(0, 2);
94
+ }
95
+ }
96
+ const updated = [...distributions];
97
+ updated[index] = { ...updated[index], amount: rawValue };
98
+ form.setValue("distributions", updated);
99
+ };
100
+
101
+ const handleAddDistribution = () => {
102
+ const updated = [...distributions, { address: "", amount: "" }];
103
+ form.setValue("distributions", updated);
104
+ };
105
+
106
+ const handleRemoveDistribution = (index: number) => {
107
+ if (distributions.length <= 2) return;
108
+ const updated = distributions.filter((_, i) => i !== index);
109
+ form.setValue("distributions", updated);
110
+ };
111
+
112
+ const isAnyDistributionEmpty = React.useMemo(() => {
113
+ if (!distributions.length) return true;
114
+ const last = distributions[distributions.length - 1];
115
+ return (last.address || "").trim() === "" || (last.amount ?? "") === "";
116
+ }, [distributions]);
117
+
44
118
  const handleSubmit = form.handleSubmit(async (payload) => {
45
119
  try {
46
120
  setIsSubmitting(true);
47
121
 
48
- /**
49
- * Create the final payload for the resolve dispute mutation
50
- *
51
- * @param payload - The payload from the form
52
- * @returns The final payload for the resolve dispute mutation
53
- */
122
+ if (!isExactMatch) {
123
+ toast.error("The total distributions must equal the milestone amount");
124
+ return;
125
+ }
126
+
54
127
  const finalPayload: MultiReleaseResolveDisputePayload = {
55
128
  contractId: selectedEscrow?.contractId || "",
56
129
  disputeResolver: walletAddress || "",
57
- approverFunds: Number(payload.approverFunds),
58
- receiverFunds: Number(payload.receiverFunds),
59
130
  milestoneIndex: String(payload.milestoneIndex),
131
+ distributions: payload.distributions.map((d) => ({
132
+ address: d.address,
133
+ amount: Number(d.amount || 0),
134
+ })) as [{ address: string; amount: number }],
60
135
  };
61
136
 
62
- /**
63
- * Call the resolve dispute mutation
64
- *
65
- * @param payload - The final payload for the resolve dispute mutation
66
- * @param type - The type of the escrow
67
- * @param address - The address of the escrow
68
- */
69
137
  await resolveDispute.mutateAsync({
70
138
  payload: finalPayload,
71
139
  type: "multi-release",
@@ -74,6 +142,11 @@ export function useResolveDispute() {
74
142
 
75
143
  toast.success("Dispute resolved successfully");
76
144
 
145
+ const sumDistributed = payload.distributions.reduce((acc, d) => {
146
+ const n = Number(d.amount || 0);
147
+ return acc + (isNaN(n) ? 0 : n);
148
+ }, 0);
149
+
77
150
  updateEscrow({
78
151
  ...selectedEscrow,
79
152
  milestones: selectedEscrow?.milestones.map((milestone, index) => {
@@ -89,9 +162,7 @@ export function useResolveDispute() {
89
162
  }
90
163
  return milestone;
91
164
  }),
92
- balance:
93
- selectedEscrow?.balance ||
94
- Number(payload.approverFunds) + Number(payload.receiverFunds),
165
+ balance: (selectedEscrow?.balance || 0) - sumDistributed || 0,
95
166
  });
96
167
  } catch (error) {
97
168
  toast.error(handleError(error as ErrorResponse).message);
@@ -101,5 +172,20 @@ export function useResolveDispute() {
101
172
  }
102
173
  });
103
174
 
104
- return { form, handleSubmit, isSubmitting, totalAmount };
175
+ return {
176
+ form,
177
+ handleSubmit,
178
+ isSubmitting,
179
+ totalAmount,
180
+ distributions,
181
+ handleAddDistribution,
182
+ handleRemoveDistribution,
183
+ handleDistributionAddressChange,
184
+ handleDistributionAmountChange,
185
+ isAnyDistributionEmpty,
186
+ allowedAmount,
187
+ distributedSum,
188
+ isExactMatch,
189
+ difference,
190
+ };
105
191
  }
@@ -8,7 +8,6 @@ export const useUpdateEscrowSchema = () => {
8
8
  address: z.string().min(1, {
9
9
  message: "Trustline address is required.",
10
10
  }),
11
- decimals: z.number().default(10000000),
12
11
  }),
13
12
  roles: z.object({
14
13
  approver: z
@@ -43,7 +43,6 @@ export function useUpdateEscrow() {
43
43
  : "",
44
44
  trustline: {
45
45
  address: selectedEscrow?.trustline?.address || "",
46
- decimals: 10000000,
47
46
  },
48
47
  roles: {
49
48
  approver: selectedEscrow?.roles?.approver || "",
@@ -84,7 +83,6 @@ export function useUpdateEscrow() {
84
83
  : "",
85
84
  trustline: {
86
85
  address: selectedEscrow?.trustline?.address || "",
87
- decimals: 10000000,
88
86
  },
89
87
  roles: {
90
88
  approver: selectedEscrow?.roles?.approver || "",
@@ -190,7 +188,6 @@ export function useUpdateEscrow() {
190
188
  : undefined,
191
189
  trustline: {
192
190
  address: payload.trustline.address,
193
- decimals: 10000000,
194
191
  },
195
192
  roles: payload.roles,
196
193
  milestones: payload.milestones.map((milestone) => ({
@@ -227,7 +224,6 @@ export function useUpdateEscrow() {
227
224
  (selectedEscrow.trustline?.address as string) ||
228
225
  "",
229
226
  address: finalPayload.escrow.trustline.address,
230
- decimals: finalPayload.escrow.trustline.decimals,
231
227
  },
232
228
  };
233
229
 
@@ -0,0 +1,85 @@
1
+ import * as React from "react";
2
+ import { Button } from "__UI_BASE__/button";
3
+ import { useEscrowsMutations } from "@/components/tw-blocks/tanstack/useEscrowsMutations";
4
+ import { useWalletContext } from "@/components/tw-blocks/wallet-kit/WalletProvider";
5
+ import { WithdrawRemainingFundsPayload } from "@trustless-work/escrow/types";
6
+ import { toast } from "sonner";
7
+ import {
8
+ ErrorResponse,
9
+ handleError,
10
+ } from "@/components/tw-blocks/handle-errors/handle";
11
+ import { useEscrowContext } from "@/components/tw-blocks/providers/EscrowProvider";
12
+ import { Loader2 } from "lucide-react";
13
+
14
+ type Distribution = { address: string; amount: number };
15
+
16
+ type WithdrawRemainingFundsButtonProps = {
17
+ distributions: Distribution[];
18
+ };
19
+
20
+ export const WithdrawRemainingFundsButton = ({
21
+ distributions,
22
+ }: WithdrawRemainingFundsButtonProps) => {
23
+ const { withdrawRemainingFunds } = useEscrowsMutations();
24
+ const { selectedEscrow, updateEscrow } = useEscrowContext();
25
+ const { walletAddress } = useWalletContext();
26
+ const [isSubmitting, setIsSubmitting] = React.useState(false);
27
+
28
+ async function handleClick() {
29
+ try {
30
+ const hasInvalid = distributions.some(
31
+ (d) => !d.address || Number.isNaN(d.amount) || d.amount < 0
32
+ );
33
+ if (hasInvalid) {
34
+ toast.error("Invalid distributions");
35
+ return;
36
+ }
37
+
38
+ setIsSubmitting(true);
39
+
40
+ const payload: WithdrawRemainingFundsPayload = {
41
+ contractId: selectedEscrow?.contractId || "",
42
+ disputeResolver: walletAddress || "",
43
+ distributions: distributions as [{ address: string; amount: number }],
44
+ };
45
+
46
+ await withdrawRemainingFunds.mutateAsync({
47
+ payload,
48
+ type: "multi-release",
49
+ address: walletAddress || "",
50
+ });
51
+
52
+ toast.success("Withdraw successful");
53
+ const sumDistributed = distributions.reduce(
54
+ (acc, d) => acc + Number(d.amount || 0),
55
+ 0
56
+ );
57
+ updateEscrow({
58
+ ...selectedEscrow,
59
+ balance: (selectedEscrow?.balance || 0) - sumDistributed || 0,
60
+ });
61
+ } catch (error) {
62
+ toast.error(handleError(error as ErrorResponse).message);
63
+ } finally {
64
+ setIsSubmitting(false);
65
+ }
66
+ }
67
+
68
+ return (
69
+ <Button
70
+ type="button"
71
+ disabled={isSubmitting}
72
+ onClick={handleClick}
73
+ className="cursor-pointer w-full"
74
+ >
75
+ {isSubmitting ? (
76
+ <div className="flex items-center">
77
+ <Loader2 className="h-5 w-5 animate-spin" />
78
+ <span className="ml-2">Withdrawing...</span>
79
+ </div>
80
+ ) : (
81
+ "Withdraw Remaining"
82
+ )}
83
+ </Button>
84
+ );
85
+ };