@trustless-work/blocks 1.1.7 → 1.2.0

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
@@ -865,6 +865,90 @@ function copyTemplate(name, { uiBase, shouldInstall = false } = {}) {
865
865
  );
866
866
  }
867
867
 
868
+ try {
869
+ const isMultiDisputeRoot =
870
+ name === "escrows/multi-release/dispute-milestone";
871
+ const isMultiDisputeDialog =
872
+ name === "escrows/multi-release/dispute-milestone/dialog";
873
+ const isMultiDisputeForm =
874
+ name === "escrows/multi-release/dispute-milestone/form";
875
+
876
+ const srcSharedDir = path.join(
877
+ TEMPLATES_DIR,
878
+ "escrows",
879
+ "multi-release",
880
+ "dispute-milestone",
881
+ "shared"
882
+ );
883
+
884
+ function copyMultiDisputeSharedInto(targetDir) {
885
+ if (!fs.existsSync(srcSharedDir)) return;
886
+ const entries = fs.readdirSync(srcSharedDir, { withFileTypes: true });
887
+ for (const entry of entries) {
888
+ if (!/\.(tsx?|jsx?)$/i.test(entry.name)) continue;
889
+ const entrySrc = path.join(srcSharedDir, entry.name);
890
+ const entryDest = path.join(targetDir, entry.name);
891
+ writeTransformed(entrySrc, entryDest);
892
+ }
893
+ }
894
+
895
+ if (isMultiDisputeRoot) {
896
+ copyMultiDisputeSharedInto(path.join(destDir, "dialog"));
897
+ copyMultiDisputeSharedInto(path.join(destDir, "form"));
898
+ } else if (isMultiDisputeDialog) {
899
+ copyMultiDisputeSharedInto(destDir);
900
+ } else if (isMultiDisputeForm) {
901
+ copyMultiDisputeSharedInto(destDir);
902
+ }
903
+ } catch (e) {
904
+ console.warn(
905
+ "⚠️ Failed to materialize shared multi-release dispute-milestone files:",
906
+ e?.message || e
907
+ );
908
+ }
909
+
910
+ try {
911
+ const isMultiReleaseRoot =
912
+ name === "escrows/multi-release/release-milestone";
913
+ const isMultiReleaseDialog =
914
+ name === "escrows/multi-release/release-milestone/dialog";
915
+ const isMultiReleaseForm =
916
+ name === "escrows/multi-release/release-milestone/form";
917
+
918
+ const srcSharedDir = path.join(
919
+ TEMPLATES_DIR,
920
+ "escrows",
921
+ "multi-release",
922
+ "release-milestone",
923
+ "shared"
924
+ );
925
+
926
+ function copyMultiReleaseSharedInto(targetDir) {
927
+ if (!fs.existsSync(srcSharedDir)) return;
928
+ const entries = fs.readdirSync(srcSharedDir, { withFileTypes: true });
929
+ for (const entry of entries) {
930
+ if (!/\.(tsx?|jsx?)$/i.test(entry.name)) continue;
931
+ const entrySrc = path.join(srcSharedDir, entry.name);
932
+ const entryDest = path.join(targetDir, entry.name);
933
+ writeTransformed(entrySrc, entryDest);
934
+ }
935
+ }
936
+
937
+ if (isMultiReleaseRoot) {
938
+ copyMultiReleaseSharedInto(path.join(destDir, "dialog"));
939
+ copyMultiReleaseSharedInto(path.join(destDir, "form"));
940
+ } else if (isMultiReleaseDialog) {
941
+ copyMultiReleaseSharedInto(destDir);
942
+ } else if (isMultiReleaseForm) {
943
+ copyMultiReleaseSharedInto(destDir);
944
+ }
945
+ } catch (e) {
946
+ console.warn(
947
+ "⚠️ Failed to materialize shared multi-release release-milestone files:",
948
+ e?.message || e
949
+ );
950
+ }
951
+
868
952
  try {
869
953
  const isMultiUpdateRoot = name === "escrows/multi-release/update-escrow";
870
954
  const isMultiUpdateDialog =
@@ -1124,6 +1208,8 @@ function copyTemplate(name, { uiBase, shouldInstall = false } = {}) {
1124
1208
  "resolve-dispute",
1125
1209
  "update-escrow",
1126
1210
  "withdraw-remaining-funds",
1211
+ "dispute-milestone",
1212
+ "release-milestone",
1127
1213
  ];
1128
1214
 
1129
1215
  for (const mod of modules) {
@@ -1255,6 +1341,8 @@ function copyTemplate(name, { uiBase, shouldInstall = false } = {}) {
1255
1341
  "resolve-dispute",
1256
1342
  "update-escrow",
1257
1343
  "withdraw-remaining-funds",
1344
+ "dispute-milestone",
1345
+ "release-milestone",
1258
1346
  ];
1259
1347
 
1260
1348
  const baseTarget = path.join(destDir, "multi-release");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trustless-work/blocks",
3
- "version": "1.1.7",
3
+ "version": "1.2.0",
4
4
  "author": "Trustless Work",
5
5
  "keywords": [
6
6
  "react",
@@ -22,7 +22,9 @@ import * as React from "react";
22
22
 
23
23
  export function LoadEscrowDialog() {
24
24
  const [isOpen, setIsOpen] = React.useState(false);
25
- const { form, isSubmitting, onSubmit } = useLoadEscrow();
25
+ const { form, isSubmitting, onSubmit } = useLoadEscrow({
26
+ onSuccess: () => setIsOpen(false),
27
+ });
26
28
 
27
29
  return (
28
30
  <Dialog open={isOpen} onOpenChange={setIsOpen}>
@@ -12,7 +12,9 @@ import { GetEscrowsFromIndexerResponse } from "@trustless-work/escrow/types";
12
12
  import { useEscrowContext } from "@/components/tw-blocks/providers/EscrowProvider";
13
13
  import { formSchema } from "./schema";
14
14
 
15
- export const useLoadEscrow = () => {
15
+ export const useLoadEscrow = ({
16
+ onSuccess,
17
+ }: { onSuccess?: () => void } = {}) => {
16
18
  const { setSelectedEscrow } = useEscrowContext();
17
19
  const [isSubmitting, setIsSubmitting] = useState(false);
18
20
  const { getEscrowByContractIds } = useGetEscrowFromIndexerByContractIds();
@@ -46,6 +48,8 @@ export const useLoadEscrow = () => {
46
48
  toast.success(
47
49
  "Escrow data fetched successfully. Now you can use the selectedEscrow state"
48
50
  );
51
+
52
+ onSuccess?.();
49
53
  } catch (error) {
50
54
  toast.error(handleError(error as ErrorResponse).message);
51
55
  } finally {
@@ -0,0 +1,103 @@
1
+ import * as React from "react";
2
+ import {
3
+ Form,
4
+ FormField,
5
+ FormItem,
6
+ FormLabel,
7
+ FormControl,
8
+ FormMessage,
9
+ } from "__UI_BASE__/form";
10
+ import { Button } from "__UI_BASE__/button";
11
+ import {
12
+ Dialog,
13
+ DialogContent,
14
+ DialogHeader,
15
+ DialogTitle,
16
+ DialogTrigger,
17
+ } from "__UI_BASE__/dialog";
18
+ import { Loader2 } from "lucide-react";
19
+ import { useDisputeMilestone } from "./useDisputeMilestone";
20
+ import { useEscrowContext } from "@/components/tw-blocks/providers/EscrowProvider";
21
+ import {
22
+ Select,
23
+ SelectContent,
24
+ SelectItem,
25
+ SelectTrigger,
26
+ SelectValue,
27
+ } from "__UI_BASE__/select";
28
+
29
+ export const DisputeMilestoneDialog = () => {
30
+ const [isOpen, setIsOpen] = React.useState(false);
31
+ const { form, handleSubmit, isSubmitting } = useDisputeMilestone({
32
+ onSuccess: () => setIsOpen(false),
33
+ });
34
+ const { selectedEscrow } = useEscrowContext();
35
+
36
+ return (
37
+ <Dialog open={isOpen} onOpenChange={setIsOpen}>
38
+ <DialogTrigger asChild>
39
+ <Button type="button" className="cursor-pointer w-full">
40
+ Dispute Milestone
41
+ </Button>
42
+ </DialogTrigger>
43
+ <DialogContent className="!w-full sm:!max-w-md">
44
+ <DialogHeader>
45
+ <DialogTitle>Dispute Milestone</DialogTitle>
46
+ </DialogHeader>
47
+ <Form {...form}>
48
+ <form onSubmit={handleSubmit}>
49
+ <FormField
50
+ control={form.control}
51
+ name="milestoneIndex"
52
+ render={({ field }) => (
53
+ <FormItem>
54
+ <FormLabel className="flex items-center">
55
+ Milestone
56
+ <span className="text-destructive ml-1">*</span>
57
+ </FormLabel>
58
+ <FormControl>
59
+ <Select
60
+ value={field.value}
61
+ onValueChange={(e) => {
62
+ field.onChange(e);
63
+ }}
64
+ >
65
+ <SelectTrigger className="w-full">
66
+ <SelectValue placeholder="Select milestone" />
67
+ </SelectTrigger>
68
+ <SelectContent>
69
+ {(selectedEscrow?.milestones || []).map((m, idx) => (
70
+ <SelectItem key={`ms-${idx}`} value={String(idx)}>
71
+ {m?.description || `Milestone ${idx + 1}`}
72
+ </SelectItem>
73
+ ))}
74
+ </SelectContent>
75
+ </Select>
76
+ </FormControl>
77
+ <FormMessage />
78
+ </FormItem>
79
+ )}
80
+ />
81
+
82
+ <div className="mt-4 flex justify-start items-center">
83
+ <Button
84
+ type="submit"
85
+ disabled={isSubmitting || !selectedEscrow?.balance}
86
+ className="cursor-pointer"
87
+ >
88
+ {isSubmitting ? (
89
+ <div className="flex items-center">
90
+ <Loader2 className="h-5 w-5 animate-spin" />
91
+ <span className="ml-2">Disputing...</span>
92
+ </div>
93
+ ) : (
94
+ "Dispute Milestone"
95
+ )}
96
+ </Button>
97
+ </div>
98
+ </form>
99
+ </Form>
100
+ </DialogContent>
101
+ </Dialog>
102
+ );
103
+ };
@@ -0,0 +1,81 @@
1
+ import * as React from "react";
2
+ import {
3
+ Form,
4
+ FormField,
5
+ FormItem,
6
+ FormLabel,
7
+ FormControl,
8
+ FormMessage,
9
+ } from "__UI_BASE__/form";
10
+ import { Button } from "__UI_BASE__/button";
11
+ import { Loader2 } from "lucide-react";
12
+ import { useDisputeMilestone } from "./useDisputeMilestone";
13
+ import { useEscrowContext } from "@/components/tw-blocks/providers/EscrowProvider";
14
+ import {
15
+ Select,
16
+ SelectContent,
17
+ SelectItem,
18
+ SelectTrigger,
19
+ SelectValue,
20
+ } from "__UI_BASE__/select";
21
+
22
+ export const DisputeMilestoneForm = () => {
23
+ const { form, handleSubmit, isSubmitting } = useDisputeMilestone();
24
+ const { selectedEscrow } = useEscrowContext();
25
+
26
+ return (
27
+ <Form {...form}>
28
+ <form onSubmit={handleSubmit} className="flex flex-col space-y-6 w-full">
29
+ <FormField
30
+ control={form.control}
31
+ name="milestoneIndex"
32
+ render={({ field }) => (
33
+ <FormItem>
34
+ <FormLabel className="flex items-center">
35
+ Milestone
36
+ <span className="text-destructive ml-1">*</span>
37
+ </FormLabel>
38
+ <FormControl>
39
+ <Select
40
+ value={field.value}
41
+ onValueChange={(e) => {
42
+ field.onChange(e);
43
+ }}
44
+ >
45
+ <SelectTrigger className="w-full">
46
+ <SelectValue placeholder="Select milestone" />
47
+ </SelectTrigger>
48
+ <SelectContent>
49
+ {(selectedEscrow?.milestones || []).map((m, idx) => (
50
+ <SelectItem key={`ms-${idx}`} value={String(idx)}>
51
+ {m?.description || `Milestone ${idx + 1}`}
52
+ </SelectItem>
53
+ ))}
54
+ </SelectContent>
55
+ </Select>
56
+ </FormControl>
57
+ <FormMessage />
58
+ </FormItem>
59
+ )}
60
+ />
61
+
62
+ <div className="mt-4">
63
+ <Button
64
+ type="submit"
65
+ disabled={isSubmitting || !selectedEscrow?.balance}
66
+ className="cursor-pointer"
67
+ >
68
+ {isSubmitting ? (
69
+ <div className="flex items-center">
70
+ <Loader2 className="h-5 w-5 animate-spin" />
71
+ <span className="ml-2">Disputing...</span>
72
+ </div>
73
+ ) : (
74
+ "Dispute Milestone"
75
+ )}
76
+ </Button>
77
+ </div>
78
+ </form>
79
+ </Form>
80
+ );
81
+ };
@@ -0,0 +1,9 @@
1
+ import { z } from "zod";
2
+
3
+ export const disputeMilestoneSchema = z.object({
4
+ milestoneIndex: z
5
+ .string({ required_error: "Milestone is required" })
6
+ .min(1, { message: "Milestone is required" }),
7
+ });
8
+
9
+ export type DisputeMilestoneValues = z.infer<typeof disputeMilestoneSchema>;
@@ -0,0 +1,99 @@
1
+ import * as React from "react";
2
+ import { useForm } from "react-hook-form";
3
+ import { zodResolver } from "@hookform/resolvers/zod";
4
+ import {
5
+ disputeMilestoneSchema,
6
+ type DisputeMilestoneValues,
7
+ } from "./schema";
8
+ import { toast } from "sonner";
9
+ import {
10
+ MultiReleaseStartDisputePayload,
11
+ MultiReleaseMilestone,
12
+ } from "@trustless-work/escrow";
13
+ import { useEscrowContext } from "@/components/tw-blocks/providers/EscrowProvider";
14
+ import { useEscrowsMutations } from "@/components/tw-blocks/tanstack/useEscrowsMutations";
15
+ import {
16
+ ErrorResponse,
17
+ handleError,
18
+ } from "@/components/tw-blocks/handle-errors/handle";
19
+ import { useWalletContext } from "@/components/tw-blocks/wallet-kit/WalletProvider";
20
+
21
+ export function useDisputeMilestone({
22
+ onSuccess,
23
+ }: { onSuccess?: () => void } = {}) {
24
+ const { startDispute } = useEscrowsMutations();
25
+ const { selectedEscrow, updateEscrow } = useEscrowContext();
26
+ const { walletAddress } = useWalletContext();
27
+
28
+ const form = useForm<DisputeMilestoneValues>({
29
+ resolver: zodResolver(disputeMilestoneSchema),
30
+ defaultValues: {
31
+ milestoneIndex: "0",
32
+ },
33
+ mode: "onChange",
34
+ });
35
+
36
+ const [isSubmitting, setIsSubmitting] = React.useState(false);
37
+
38
+ const handleSubmit = form.handleSubmit(async (payload) => {
39
+ try {
40
+ setIsSubmitting(true);
41
+
42
+ /**
43
+ * Create the payload for the dispute escrow mutation
44
+ *
45
+ * @returns The payload for the dispute escrow mutation
46
+ */
47
+ const finalPayload: MultiReleaseStartDisputePayload = {
48
+ contractId: selectedEscrow?.contractId || "",
49
+ signer: walletAddress || "",
50
+ milestoneIndex: String(payload.milestoneIndex),
51
+ };
52
+
53
+ /**
54
+ * Call the dispute escrow mutation
55
+ *
56
+ * @param payload - The payload for the dispute escrow mutation
57
+ * @param type - The type of the escrow
58
+ * @param address - The address of the escrow
59
+ */
60
+ await startDispute.mutateAsync({
61
+ payload: finalPayload,
62
+ type: "multi-release",
63
+ address: walletAddress || "",
64
+ });
65
+
66
+ toast.success("Milestone disputed successfully");
67
+
68
+ updateEscrow({
69
+ ...selectedEscrow,
70
+ milestones: selectedEscrow?.milestones.map((milestone, index) => {
71
+ if (index === Number(payload.milestoneIndex)) {
72
+ return {
73
+ ...milestone,
74
+ flags: {
75
+ ...(milestone as MultiReleaseMilestone).flags,
76
+ disputed: true,
77
+ },
78
+ };
79
+ }
80
+ return milestone;
81
+ }),
82
+ });
83
+
84
+ onSuccess?.();
85
+ } catch (error) {
86
+ toast.error(handleError(error as ErrorResponse).message);
87
+ } finally {
88
+ setIsSubmitting(false);
89
+ form.reset();
90
+ }
91
+ });
92
+
93
+ return {
94
+ form,
95
+ handleSubmit,
96
+ isSubmitting,
97
+ };
98
+ }
99
+
@@ -17,7 +17,9 @@ import {
17
17
  import { useEscrowContext } from "@/components/tw-blocks/providers/EscrowProvider";
18
18
  import { trustlineOptions } from "@/components/tw-blocks/wallet-kit/trustlines";
19
19
 
20
- export function useInitializeEscrow({ onSuccess }: { onSuccess?: () => void } = {}) {
20
+ export function useInitializeEscrow({
21
+ onSuccess,
22
+ }: { onSuccess?: () => void } = {}) {
21
23
  const [isSubmitting, setIsSubmitting] = React.useState(false);
22
24
 
23
25
  const { getMultiReleaseFormSchema } = useInitializeEscrowSchema();
@@ -122,6 +124,15 @@ export function useInitializeEscrow({ onSuccess }: { onSuccess?: () => void } =
122
124
  try {
123
125
  setIsSubmitting(true);
124
126
 
127
+ const trustline = trustlineOptions.find(
128
+ (t) => t.value === payload.trustline.address
129
+ );
130
+
131
+ if (!trustline) {
132
+ toast.error("Invalid trustline address");
133
+ return;
134
+ }
135
+
125
136
  /**
126
137
  * Create the final payload for the initialize escrow mutation
127
138
  *
@@ -142,6 +153,10 @@ export function useInitializeEscrow({ onSuccess }: { onSuccess?: () => void } =
142
153
  ? Number(milestone.amount)
143
154
  : milestone.amount,
144
155
  })),
156
+ trustline: {
157
+ address: trustline.value,
158
+ symbol: trustline.label,
159
+ },
145
160
  };
146
161
 
147
162
  /**
@@ -0,0 +1,103 @@
1
+ import * as React from "react";
2
+ import {
3
+ Form,
4
+ FormField,
5
+ FormItem,
6
+ FormLabel,
7
+ FormControl,
8
+ FormMessage,
9
+ } from "__UI_BASE__/form";
10
+ import { Button } from "__UI_BASE__/button";
11
+ import {
12
+ Dialog,
13
+ DialogContent,
14
+ DialogHeader,
15
+ DialogTitle,
16
+ DialogTrigger,
17
+ } from "__UI_BASE__/dialog";
18
+ import { Loader2 } from "lucide-react";
19
+ import { useReleaseMilestone } from "./useReleaseMilestone";
20
+ import { useEscrowContext } from "@/components/tw-blocks/providers/EscrowProvider";
21
+ import {
22
+ Select,
23
+ SelectContent,
24
+ SelectItem,
25
+ SelectTrigger,
26
+ SelectValue,
27
+ } from "__UI_BASE__/select";
28
+
29
+ export const ReleaseMilestoneDialog = () => {
30
+ const [isOpen, setIsOpen] = React.useState(false);
31
+ const { form, handleSubmit, isSubmitting } = useReleaseMilestone({
32
+ onSuccess: () => setIsOpen(false),
33
+ });
34
+ const { selectedEscrow } = useEscrowContext();
35
+
36
+ return (
37
+ <Dialog open={isOpen} onOpenChange={setIsOpen}>
38
+ <DialogTrigger asChild>
39
+ <Button type="button" className="cursor-pointer w-full">
40
+ Release Milestone
41
+ </Button>
42
+ </DialogTrigger>
43
+ <DialogContent className="!w-full sm:!max-w-md">
44
+ <DialogHeader>
45
+ <DialogTitle>Release Milestone</DialogTitle>
46
+ </DialogHeader>
47
+ <Form {...form}>
48
+ <form onSubmit={handleSubmit}>
49
+ <FormField
50
+ control={form.control}
51
+ name="milestoneIndex"
52
+ render={({ field }) => (
53
+ <FormItem>
54
+ <FormLabel className="flex items-center">
55
+ Milestone
56
+ <span className="text-destructive ml-1">*</span>
57
+ </FormLabel>
58
+ <FormControl>
59
+ <Select
60
+ value={field.value}
61
+ onValueChange={(e) => {
62
+ field.onChange(e);
63
+ }}
64
+ >
65
+ <SelectTrigger className="w-full">
66
+ <SelectValue placeholder="Select milestone" />
67
+ </SelectTrigger>
68
+ <SelectContent>
69
+ {(selectedEscrow?.milestones || []).map((m, idx) => (
70
+ <SelectItem key={`ms-${idx}`} value={String(idx)}>
71
+ {m?.description || `Milestone ${idx + 1}`}
72
+ </SelectItem>
73
+ ))}
74
+ </SelectContent>
75
+ </Select>
76
+ </FormControl>
77
+ <FormMessage />
78
+ </FormItem>
79
+ )}
80
+ />
81
+
82
+ <div className="mt-4 flex justify-start items-center">
83
+ <Button
84
+ type="submit"
85
+ disabled={isSubmitting}
86
+ className="cursor-pointer"
87
+ >
88
+ {isSubmitting ? (
89
+ <div className="flex items-center">
90
+ <Loader2 className="h-5 w-5 animate-spin" />
91
+ <span className="ml-2">Releasing...</span>
92
+ </div>
93
+ ) : (
94
+ "Release Milestone"
95
+ )}
96
+ </Button>
97
+ </div>
98
+ </form>
99
+ </Form>
100
+ </DialogContent>
101
+ </Dialog>
102
+ );
103
+ };
@@ -0,0 +1,81 @@
1
+ import * as React from "react";
2
+ import {
3
+ Form,
4
+ FormField,
5
+ FormItem,
6
+ FormLabel,
7
+ FormControl,
8
+ FormMessage,
9
+ } from "__UI_BASE__/form";
10
+ import { Button } from "__UI_BASE__/button";
11
+ import { Loader2 } from "lucide-react";
12
+ import { useReleaseMilestone } from "./useReleaseMilestone";
13
+ import { useEscrowContext } from "@/components/tw-blocks/providers/EscrowProvider";
14
+ import {
15
+ Select,
16
+ SelectContent,
17
+ SelectItem,
18
+ SelectTrigger,
19
+ SelectValue,
20
+ } from "__UI_BASE__/select";
21
+
22
+ export const ReleaseMilestoneForm = () => {
23
+ const { form, handleSubmit, isSubmitting } = useReleaseMilestone();
24
+ const { selectedEscrow } = useEscrowContext();
25
+
26
+ return (
27
+ <Form {...form}>
28
+ <form onSubmit={handleSubmit} className="flex flex-col space-y-6 w-full">
29
+ <FormField
30
+ control={form.control}
31
+ name="milestoneIndex"
32
+ render={({ field }) => (
33
+ <FormItem>
34
+ <FormLabel className="flex items-center">
35
+ Milestone
36
+ <span className="text-destructive ml-1">*</span>
37
+ </FormLabel>
38
+ <FormControl>
39
+ <Select
40
+ value={field.value}
41
+ onValueChange={(e) => {
42
+ field.onChange(e);
43
+ }}
44
+ >
45
+ <SelectTrigger className="w-full">
46
+ <SelectValue placeholder="Select milestone" />
47
+ </SelectTrigger>
48
+ <SelectContent>
49
+ {(selectedEscrow?.milestones || []).map((m, idx) => (
50
+ <SelectItem key={`ms-${idx}`} value={String(idx)}>
51
+ {m?.description || `Milestone ${idx + 1}`}
52
+ </SelectItem>
53
+ ))}
54
+ </SelectContent>
55
+ </Select>
56
+ </FormControl>
57
+ <FormMessage />
58
+ </FormItem>
59
+ )}
60
+ />
61
+
62
+ <div className="mt-4">
63
+ <Button
64
+ type="submit"
65
+ disabled={isSubmitting}
66
+ className="cursor-pointer"
67
+ >
68
+ {isSubmitting ? (
69
+ <div className="flex items-center">
70
+ <Loader2 className="h-5 w-5 animate-spin" />
71
+ <span className="ml-2">Releasing...</span>
72
+ </div>
73
+ ) : (
74
+ "Release Milestone"
75
+ )}
76
+ </Button>
77
+ </div>
78
+ </form>
79
+ </Form>
80
+ );
81
+ };
@@ -0,0 +1,10 @@
1
+ import { z } from "zod";
2
+
3
+ export const releaseMilestoneSchema = z.object({
4
+ milestoneIndex: z
5
+ .string({ required_error: "Milestone is required" })
6
+ .min(1, { message: "Milestone is required" }),
7
+ });
8
+
9
+ export type ReleaseMilestoneValues = z.infer<typeof releaseMilestoneSchema>;
10
+
@@ -0,0 +1,121 @@
1
+ import * as React from "react";
2
+ import { useForm } from "react-hook-form";
3
+ import { zodResolver } from "@hookform/resolvers/zod";
4
+ import {
5
+ releaseMilestoneSchema,
6
+ type ReleaseMilestoneValues,
7
+ } from "./schema";
8
+ import { toast } from "sonner";
9
+ import {
10
+ MultiReleaseReleaseFundsPayload,
11
+ MultiReleaseMilestone,
12
+ } from "@trustless-work/escrow";
13
+ import { useEscrowContext } from "@/components/tw-blocks/providers/EscrowProvider";
14
+ import { useEscrowsMutations } from "@/components/tw-blocks/tanstack/useEscrowsMutations";
15
+ import {
16
+ ErrorResponse,
17
+ handleError,
18
+ } from "@/components/tw-blocks/handle-errors/handle";
19
+ import { useWalletContext } from "@/components/tw-blocks/wallet-kit/WalletProvider";
20
+ import { useEscrowDialogs } from "@/components/tw-blocks/providers/EscrowDialogsProvider";
21
+ import { useEscrowAmountContext } from "@/components/tw-blocks/providers/EscrowAmountProvider";
22
+
23
+ export function useReleaseMilestone({
24
+ onSuccess,
25
+ }: { onSuccess?: () => void } = {}) {
26
+ const { releaseFunds } = useEscrowsMutations();
27
+ const { selectedEscrow, updateEscrow } = useEscrowContext();
28
+ const dialogStates = useEscrowDialogs();
29
+ const { setAmounts, setLastReleasedMilestoneIndex } =
30
+ useEscrowAmountContext();
31
+ const { walletAddress } = useWalletContext();
32
+
33
+ const form = useForm<ReleaseMilestoneValues>({
34
+ resolver: zodResolver(releaseMilestoneSchema),
35
+ defaultValues: {
36
+ milestoneIndex: "0",
37
+ },
38
+ mode: "onChange",
39
+ });
40
+
41
+ const [isSubmitting, setIsSubmitting] = React.useState(false);
42
+
43
+ const handleSubmit = form.handleSubmit(async (payload) => {
44
+ try {
45
+ setIsSubmitting(true);
46
+
47
+ /**
48
+ * Create the payload for the release escrow mutation
49
+ *
50
+ * @returns The payload for the release escrow mutation
51
+ */
52
+ const finalPayload: MultiReleaseReleaseFundsPayload = {
53
+ contractId: selectedEscrow?.contractId || "",
54
+ releaseSigner: walletAddress || "",
55
+ milestoneIndex: String(payload.milestoneIndex),
56
+ };
57
+
58
+ /**
59
+ * Call the release escrow mutation
60
+ *
61
+ * @param payload - The payload for the release escrow mutation
62
+ * @param type - The type of the escrow
63
+ * @param address - The address of the escrow
64
+ */
65
+ await releaseFunds.mutateAsync({
66
+ payload: finalPayload,
67
+ type: "multi-release",
68
+ address: walletAddress || "",
69
+ });
70
+
71
+ toast.success("Milestone released successfully");
72
+
73
+ // Ensure amounts are up to date for the success dialog
74
+ if (selectedEscrow) {
75
+ const milestone = selectedEscrow.milestones?.[Number(payload.milestoneIndex)];
76
+ const releasedAmount = Number(
77
+ (milestone as MultiReleaseMilestone | undefined)?.amount || 0
78
+ );
79
+ const platformFee = Number(selectedEscrow.platformFee || 0);
80
+ setAmounts(releasedAmount, platformFee);
81
+ }
82
+
83
+ updateEscrow({
84
+ ...selectedEscrow,
85
+ milestones: selectedEscrow?.milestones.map((milestone, index) => {
86
+ if (index === Number(payload.milestoneIndex)) {
87
+ return {
88
+ ...milestone,
89
+ flags: {
90
+ ...(milestone as MultiReleaseMilestone).flags,
91
+ released: true,
92
+ },
93
+ };
94
+ }
95
+ return milestone;
96
+ }),
97
+ balance: (selectedEscrow?.balance || 0) - (selectedEscrow?.amount || 0),
98
+ });
99
+
100
+ // Remember which milestone was released for the success dialog
101
+ setLastReleasedMilestoneIndex(Number(payload.milestoneIndex));
102
+
103
+ // Open success dialog
104
+ dialogStates.successRelease.setIsOpen(true);
105
+
106
+ onSuccess?.();
107
+ } catch (error) {
108
+ toast.error(handleError(error as ErrorResponse).message);
109
+ } finally {
110
+ setIsSubmitting(false);
111
+ form.reset();
112
+ }
113
+ });
114
+
115
+ return {
116
+ form,
117
+ handleSubmit,
118
+ isSubmitting,
119
+ };
120
+ }
121
+
@@ -17,7 +17,9 @@ import {
17
17
  import { useEscrowContext } from "@/components/tw-blocks/providers/EscrowProvider";
18
18
  import { trustlineOptions } from "@/components/tw-blocks/wallet-kit/trustlines";
19
19
 
20
- export function useInitializeEscrow({ onSuccess }: { onSuccess?: () => void } = {}) {
20
+ export function useInitializeEscrow({
21
+ onSuccess,
22
+ }: { onSuccess?: () => void } = {}) {
21
23
  const [isSubmitting, setIsSubmitting] = React.useState(false);
22
24
 
23
25
  const { getSingleReleaseFormSchema } = useInitializeEscrowSchema();
@@ -108,6 +110,15 @@ export function useInitializeEscrow({ onSuccess }: { onSuccess?: () => void } =
108
110
  try {
109
111
  setIsSubmitting(true);
110
112
 
113
+ const trustline = trustlineOptions.find(
114
+ (t) => t.value === payload.trustline.address
115
+ );
116
+
117
+ if (!trustline) {
118
+ toast.error("Invalid trustline address");
119
+ return;
120
+ }
121
+
111
122
  /**
112
123
  * Create the final payload for the initialize escrow mutation
113
124
  *
@@ -126,6 +137,10 @@ export function useInitializeEscrow({ onSuccess }: { onSuccess?: () => void } =
126
137
  : payload.platformFee,
127
138
  signer: walletAddress || "",
128
139
  milestones: payload.milestones,
140
+ trustline: {
141
+ address: trustline.value,
142
+ symbol: trustline.label,
143
+ },
129
144
  };
130
145
 
131
146
  /**