@ensofinance/checkout-widget 0.1.1 → 0.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ensofinance/checkout-widget",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "homepage": "https://www.enso.build/",
6
6
  "repository": {
@@ -60,6 +60,7 @@
60
60
  "globals": "^15.14.0",
61
61
  "orval": "^7.10.0",
62
62
  "prettier": "^3.4.2",
63
+ "source-map-explorer": "^2.5.3",
63
64
  "typescript": "~5.8.2",
64
65
  "typescript-eslint": "^8.18.2",
65
66
  "vite": "^6.0.5",
@@ -0,0 +1,233 @@
1
+ import { Box, Icon, Text } from "@chakra-ui/react";
2
+ import { ArrowDownUpIcon } from "lucide-react";
3
+ import { Input, IconButton, Tab } from "@/components/ui";
4
+ import { formatNumber, formatUSD } from "@/util";
5
+ import { precisionizeNumber, sanitizeDecimalInput } from "@/util/common";
6
+
7
+ export type InputMode = "usd" | "token";
8
+
9
+ export type AmountInputValue = {
10
+ tokenAmount: string;
11
+ usdAmount: string;
12
+ mode: InputMode;
13
+ };
14
+
15
+ type PercentOption = {
16
+ label: string;
17
+ value: number;
18
+ };
19
+
20
+ type AmountInputProps = {
21
+ value: AmountInputValue;
22
+ onChange: (value: AmountInputValue) => void;
23
+ tokenSymbol?: string;
24
+ tokenPriceUsd?: number;
25
+ tokenBalance?: number;
26
+ roundingPrecision?: number;
27
+ usdPrecision?: number;
28
+ percentOptions?: PercentOption[];
29
+ onPercentSelect?: (
30
+ percent: number,
31
+ ) => { tokenAmount: string; usdAmount: string } | null | undefined;
32
+ showPercentTabs?: boolean;
33
+ };
34
+
35
+ const defaultPercentOptions: PercentOption[] = [
36
+ { label: "25%", value: 25 },
37
+ { label: "50%", value: 50 },
38
+ { label: "75%", value: 75 },
39
+ { label: "Max", value: 100 },
40
+ ];
41
+
42
+ export const AmountInput = ({
43
+ value,
44
+ onChange,
45
+ tokenSymbol,
46
+ tokenPriceUsd,
47
+ tokenBalance,
48
+ roundingPrecision = 6,
49
+ usdPrecision = 2,
50
+ percentOptions = defaultPercentOptions,
51
+ onPercentSelect,
52
+ showPercentTabs = true,
53
+ }: AmountInputProps) => {
54
+ const updateValue = (next: Partial<AmountInputValue>) => {
55
+ onChange({ ...value, ...next });
56
+ };
57
+
58
+ const handleInputChange = (rawValue: string) => {
59
+ if (value.mode === "usd") {
60
+ const cleanUsd = sanitizeDecimalInput(
61
+ rawValue.replace("$", ""),
62
+ usdPrecision,
63
+ );
64
+
65
+ if (!cleanUsd || cleanUsd === ".") {
66
+ updateValue({ usdAmount: cleanUsd, tokenAmount: "" });
67
+ return;
68
+ }
69
+
70
+ if (!tokenPriceUsd || tokenPriceUsd <= 0) {
71
+ updateValue({ usdAmount: cleanUsd, tokenAmount: "" });
72
+ return;
73
+ }
74
+
75
+ const tokenAmountFromUsd = precisionizeNumber(
76
+ parseFloat(cleanUsd) / tokenPriceUsd,
77
+ roundingPrecision,
78
+ );
79
+ updateValue({
80
+ usdAmount: cleanUsd,
81
+ tokenAmount: tokenAmountFromUsd,
82
+ });
83
+ return;
84
+ }
85
+
86
+ const cleanTokenAmount = sanitizeDecimalInput(
87
+ rawValue,
88
+ roundingPrecision,
89
+ );
90
+
91
+ if (!cleanTokenAmount || cleanTokenAmount === ".") {
92
+ updateValue({ tokenAmount: cleanTokenAmount, usdAmount: "" });
93
+ return;
94
+ }
95
+
96
+ if (!tokenPriceUsd || tokenPriceUsd <= 0) {
97
+ updateValue({ tokenAmount: cleanTokenAmount });
98
+ return;
99
+ }
100
+
101
+ updateValue({
102
+ tokenAmount: cleanTokenAmount,
103
+ usdAmount: (
104
+ parseFloat(cleanTokenAmount) * tokenPriceUsd
105
+ ).toFixed(usdPrecision),
106
+ });
107
+ };
108
+
109
+ const handleToggleMode = () => {
110
+ updateValue({
111
+ mode: value.mode === "usd" ? "token" : "usd",
112
+ });
113
+ };
114
+
115
+ const handlePercentSelect = (percent: number) => {
116
+ if (onPercentSelect) {
117
+ const nextValues = onPercentSelect(percent);
118
+ if (!nextValues) return;
119
+
120
+ updateValue(nextValues);
121
+ return;
122
+ }
123
+
124
+ if (!tokenBalance || !tokenPriceUsd || tokenPriceUsd <= 0) return;
125
+
126
+ const tokenAmount = precisionizeNumber(
127
+ (tokenBalance * percent) / 100,
128
+ roundingPrecision,
129
+ );
130
+
131
+ updateValue({
132
+ tokenAmount,
133
+ usdAmount: (
134
+ parseFloat(tokenAmount || "0") * tokenPriceUsd
135
+ ).toFixed(usdPrecision),
136
+ });
137
+ };
138
+
139
+ const placeholder = value.mode === "usd" ? "$10.00" : "0.00";
140
+ const displayValue =
141
+ value.mode === "usd"
142
+ ? value.usdAmount
143
+ ? `$${value.usdAmount}`
144
+ : ""
145
+ : value.tokenAmount;
146
+
147
+ const hasTokenValue = !!value.tokenAmount && value.tokenAmount !== ".";
148
+ const hasUsdValue = !!value.usdAmount && value.usdAmount !== ".";
149
+
150
+ const formattedTokenValue = hasTokenValue
151
+ ? formatNumber(value.tokenAmount)
152
+ : "";
153
+ const tokenSuffix = tokenSymbol ? ` ${tokenSymbol}` : "";
154
+ const equivalentValue =
155
+ value.mode === "usd"
156
+ ? hasTokenValue
157
+ ? `${formattedTokenValue}${tokenSuffix}`
158
+ : "—"
159
+ : hasUsdValue
160
+ ? formatUSD(value.usdAmount)
161
+ : "—";
162
+
163
+ return (
164
+ <Box display={"flex"} flexDirection={"column"} gap={"8px"}>
165
+ <Box
166
+ display={"flex"}
167
+ flexDirection={"column"}
168
+ gap={"8px"}
169
+ alignItems={"center"}
170
+ padding={"25.5px"}
171
+ >
172
+ {/* Main Input */}
173
+ <Input
174
+ inputMode="decimal"
175
+ marginY={"8px"}
176
+ variant={"text"}
177
+ placeholder={placeholder}
178
+ value={displayValue}
179
+ onChange={(event) => handleInputChange(event.target.value)}
180
+ />
181
+
182
+ {/* Toggle Button and Equivalent Display */}
183
+ <Box
184
+ display={"flex"}
185
+ gap={"3"}
186
+ alignItems={"center"}
187
+ onClick={handleToggleMode}
188
+ _hover={{ background: "bg.subtle" }}
189
+ cursor={"pointer"}
190
+ borderRadius={"lg"}
191
+ px={"3"}
192
+ >
193
+ <IconButton
194
+ minWidth={"24px"}
195
+ minHeight={"24px"}
196
+ maxWidth={"24px"}
197
+ background={"transparent"}
198
+ >
199
+ <Icon
200
+ as={ArrowDownUpIcon}
201
+ color="gray"
202
+ width={"16px"}
203
+ height={"16px"}
204
+ />
205
+ </IconButton>
206
+
207
+ {/* Small equivalent value display */}
208
+ <Text fontSize="sm" color="fg.muted">
209
+ {equivalentValue}
210
+ </Text>
211
+ </Box>
212
+ </Box>
213
+
214
+ {showPercentTabs && percentOptions.length > 0 && (
215
+ <Box
216
+ display={"flex"}
217
+ gap={"4px"}
218
+ justifyContent={"center"}
219
+ paddingBottom={"35px"}
220
+ >
221
+ {percentOptions.map((option) => (
222
+ <Tab
223
+ key={option.label}
224
+ onClick={() => handlePercentSelect(option.value)}
225
+ >
226
+ {option.label}
227
+ </Tab>
228
+ ))}
229
+ </Box>
230
+ )}
231
+ </Box>
232
+ );
233
+ };
@@ -9,7 +9,7 @@ import {
9
9
  Image,
10
10
  Table,
11
11
  } from "@chakra-ui/react";
12
- import { ChevronLeft, X, ArrowDownUpIcon } from "lucide-react";
12
+ import { ChevronLeft, X, TriangleAlert } from "lucide-react";
13
13
  import { useContext, useEffect, useMemo, useState, useCallback } from "react";
14
14
  import { useAccount, useSignMessage } from "wagmi";
15
15
  import { getUserOperationHash } from "viem/account-abstraction";
@@ -20,7 +20,8 @@ import {
20
20
  HeaderWrapper,
21
21
  ListWrapper,
22
22
  } from "../ui/styled";
23
- import { IconButton, Button, Tab, Input } from "../ui";
23
+ import { IconButton, Button, Input } from "../ui";
24
+ import { AmountInput, AmountInputValue } from "../AmountInput";
24
25
  import { CheckoutContext } from "../Checkout";
25
26
  import Modal from "../modal";
26
27
  import {
@@ -797,13 +798,17 @@ const ChooseAmountStep = ({
797
798
  setStep: (step: WithdrawalStep) => void;
798
799
  selectedToken: MatchedToken | null;
799
800
  }) => {
800
- const [amount, setAmount] = useState<string>("");
801
- const [inputMode, setInputMode] = useState<"usd" | "token">("usd");
802
- const [usdValue, setUsdValue] = useState<string>("");
801
+ const [amountInput, setAmountInput] = useState<AmountInputValue>({
802
+ tokenAmount: "",
803
+ usdAmount: "",
804
+ mode: "usd",
805
+ });
803
806
  const setAmountIn = useAppStore((s) => s.setAmountIn);
804
807
  const { tokenInData, selectedIntegration } = useAppDetails();
805
808
  const isStable = selectedToken?.symbol.toLowerCase().includes("USD");
806
809
  const roundingPrecision = isStable ? 2 : 6;
810
+ const amount = amountInput.tokenAmount;
811
+ const usdValue = amountInput.usdAmount;
807
812
 
808
813
  // Only apply CEX withdrawal limits if using a CEX holding (not smart account)
809
814
  const isWithdrawal = !isDelayedBalanceUsed(selectedIntegration.type);
@@ -816,8 +821,13 @@ const ChooseAmountStep = ({
816
821
  : selectedToken.marketValue.toFixed(2)
817
822
  : 0;
818
823
 
824
+ const tokenPriceUsd = useMemo(() => {
825
+ if (!selectedToken || !selectedToken.balance) return undefined;
826
+ return selectedToken.marketValue / selectedToken.balance;
827
+ }, [selectedToken]);
828
+
819
829
  // Handle percentage selection with limits (only for CEX withdrawals)
820
- const handlePercentageSelect = useCallback(
830
+ const getPercentAmounts = useCallback(
821
831
  (percent: number) => {
822
832
  if (!selectedToken) return;
823
833
 
@@ -851,8 +861,13 @@ const ChooseAmountStep = ({
851
861
  finalTokenAmount = (selectedToken.balance * percent) / 100;
852
862
  }
853
863
 
854
- setAmount(precisionizeNumber(finalTokenAmount, roundingPrecision));
855
- setUsdValue(finalUsdAmount.toFixed(2));
864
+ return {
865
+ tokenAmount: precisionizeNumber(
866
+ finalTokenAmount,
867
+ roundingPrecision,
868
+ ),
869
+ usdAmount: finalUsdAmount.toFixed(2),
870
+ };
856
871
  },
857
872
  [selectedToken, isWithdrawal, maxUsdAmount, roundingPrecision],
858
873
  );
@@ -860,72 +875,43 @@ const ChooseAmountStep = ({
860
875
  // Set max value on load
861
876
  useEffect(() => {
862
877
  if (selectedToken) {
863
- handlePercentageSelect(100);
878
+ const percentAmounts = getPercentAmounts(100);
879
+ if (!percentAmounts) return;
880
+
881
+ setAmountInput((prev) => ({
882
+ ...prev,
883
+ ...percentAmounts,
884
+ }));
864
885
  }
865
- }, [selectedToken, handlePercentageSelect]);
886
+ }, [selectedToken, getPercentAmounts]);
866
887
 
867
888
  useEffect(() => {
868
- if (tokenInData?.decimals)
869
- setAmountIn(
870
- Number(
871
- denormalizeValue(amount || "0", tokenInData?.decimals),
872
- ).toFixed(),
873
- );
874
- }, [amount, tokenInData?.decimals]);
875
-
876
- // Handle input change based on current mode
877
- const handleInputChange = (value: string) => {
878
- if (!selectedToken) return;
879
-
880
- if (inputMode === "usd") {
881
- const cleanUsd = value.replace("$", "") || "";
882
- setUsdValue(cleanUsd);
883
- // Calculate token amount from USD value
884
- const tokenPrice =
885
- selectedToken.marketValue / selectedToken.balance;
886
- const tokenAmount = parseFloat(cleanUsd || "0") / tokenPrice;
887
- setAmount(precisionizeNumber(tokenAmount, roundingPrecision));
888
- } else {
889
- setAmount(precisionizeNumber(value, roundingPrecision));
890
- // Calculate USD value from token amount
891
- const tokenPrice =
892
- selectedToken.marketValue / selectedToken.balance;
893
- const usdAmount = parseFloat(value) * tokenPrice;
894
- setUsdValue(usdAmount.toFixed(2));
895
- }
896
- };
889
+ if (!tokenInData?.decimals) return;
897
890
 
898
- // Toggle between USD and token input modes
899
- const handleToggleMode = () => {
900
- setInputMode(inputMode === "usd" ? "token" : "usd");
901
- };
891
+ const normalizedAmount = amount.endsWith(".")
892
+ ? amount.slice(0, -1)
893
+ : amount;
902
894
 
903
- // Get input placeholder and display value
904
- const getInputDisplay = () => {
905
- if (inputMode === "usd") {
906
- return {
907
- placeholder: "$10.00",
908
- displayValue: usdValue ? `$${usdValue}` : "",
909
- equivalentValue: amount
910
- ? `${formatNumber(parseFloat(amount))} ${selectedToken?.symbol}`
911
- : "—",
912
- };
913
- } else {
914
- return {
915
- placeholder: "0.00",
916
- displayValue: amount,
917
- equivalentValue: usdValue ? `$${usdValue}` : "—",
918
- };
895
+ if (!normalizedAmount || normalizedAmount === ".") {
896
+ setAmountIn("0");
897
+ return;
919
898
  }
920
- };
921
899
 
922
- const { placeholder, displayValue, equivalentValue } = getInputDisplay();
900
+ try {
901
+ setAmountIn(denormalizeValue(normalizedAmount, tokenInData.decimals));
902
+ } catch (error) {
903
+ setAmountIn("0");
904
+ }
905
+ }, [amount, tokenInData?.decimals, setAmountIn]);
906
+
907
+ const hasAmount = !!amount && amount !== ".";
908
+ const hasUsdValue = !!usdValue && usdValue !== ".";
923
909
  const notEnoughBalance = selectedToken
924
- ? parseFloat(amount) > selectedToken.balance
910
+ ? hasAmount && parseFloat(amount) > selectedToken.balance
925
911
  : true;
926
912
 
927
913
  // Limits validation logic - only for CEX withdrawals
928
- const currentUsdValue = parseFloat(usdValue);
914
+ const currentUsdValue = hasUsdValue ? parseFloat(usdValue) : 0;
929
915
  const minValueForToken =
930
916
  isWithdrawal && selectedToken
931
917
  ? EXCHANGE_MIN_LIMIT[
@@ -936,17 +922,19 @@ const ChooseAmountStep = ({
936
922
  const isBelowMinAmount =
937
923
  isWithdrawal &&
938
924
  selectedToken &&
925
+ hasAmount &&
939
926
  currentUsdValue > 0 &&
940
927
  minValueForToken &&
941
928
  +amount < minValueForToken;
942
929
  const isAboveMaxAmount =
943
930
  isWithdrawal &&
944
931
  selectedToken &&
932
+ hasAmount &&
945
933
  currentUsdValue > 0 &&
946
934
  currentUsdValue > +maxUsdAmount;
947
935
 
948
936
  const isAmountInvalid =
949
- isBelowMinAmount || isAboveMaxAmount || notEnoughBalance;
937
+ !hasAmount || isBelowMinAmount || isAboveMaxAmount || notEnoughBalance;
950
938
 
951
939
  if (!selectedToken) {
952
940
  return (
@@ -975,70 +963,14 @@ const ChooseAmountStep = ({
975
963
  gap={"8px"}
976
964
  width="100%"
977
965
  >
978
- <Box
979
- display={"flex"}
980
- flexDirection={"column"}
981
- gap={"8px"}
982
- alignItems={"center"}
983
- padding={"25.5px"}
984
- >
985
- {/* Main Input */}
986
- <Input
987
- inputMode="decimal"
988
- marginY={"8px"}
989
- variant={"text"}
990
- placeholder={placeholder}
991
- value={displayValue}
992
- onChange={(e) => handleInputChange(e.target.value)}
993
- />
994
-
995
- {/* Toggle Button and Equivalent Display */}
996
- <Box
997
- display={"flex"}
998
- gap={"3"}
999
- alignItems={"center"}
1000
- onClick={handleToggleMode}
1001
- _hover={{ background: "bg.subtle" }}
1002
- cursor={"pointer"}
1003
- borderRadius={"lg"}
1004
- px={"3"}
1005
- >
1006
- <IconButton
1007
- minWidth={"24px"}
1008
- minHeight={"24px"}
1009
- maxWidth={"24px"}
1010
- background={"transparent"}
1011
- >
1012
- <Icon
1013
- as={ArrowDownUpIcon}
1014
- color="gray"
1015
- width={"16px"}
1016
- height={"16px"}
1017
- />
1018
- </IconButton>
1019
-
1020
- {/* Small equivalent value display */}
1021
- <Text fontSize="sm" color="fg.muted">
1022
- {equivalentValue}
1023
- </Text>
1024
- </Box>
1025
- </Box>
1026
-
1027
- <Box
1028
- display={"flex"}
1029
- gap={"4px"}
1030
- justifyContent={"center"}
1031
- paddingBottom={"35px"}
1032
- >
1033
- {[25, 50, 75, 100].map((percent) => (
1034
- <Tab
1035
- key={percent}
1036
- onClick={() => handlePercentageSelect(percent)}
1037
- >
1038
- {percent === 100 ? "Max" : `${percent}%`}
1039
- </Tab>
1040
- ))}
1041
- </Box>
966
+ <AmountInput
967
+ value={amountInput}
968
+ onChange={setAmountInput}
969
+ tokenSymbol={selectedToken.symbol}
970
+ tokenPriceUsd={tokenPriceUsd}
971
+ roundingPrecision={roundingPrecision}
972
+ onPercentSelect={getPercentAmounts}
973
+ />
1042
974
  </Box>
1043
975
 
1044
976
  {
@@ -1049,10 +981,10 @@ const ChooseAmountStep = ({
1049
981
  h={3}
1050
982
  m={-1}
1051
983
  visibility={
1052
- isAmountInvalid || !amount ? "visible" : "hidden"
984
+ isAmountInvalid ? "visible" : "hidden"
1053
985
  }
1054
986
  >
1055
- {!amount
987
+ {!hasAmount
1056
988
  ? "Please enter an amount"
1057
989
  : isBelowMinAmount
1058
990
  ? `Minimum amount is ${formatNumber(minValueForToken)} ${selectedToken.symbol}`
@@ -1066,11 +998,11 @@ const ChooseAmountStep = ({
1066
998
 
1067
999
  <Button
1068
1000
  onClick={() =>
1069
- isAmountInvalid || !amount
1001
+ isAmountInvalid
1070
1002
  ? undefined
1071
1003
  : setStep(WithdrawalStep.SignUserOp)
1072
1004
  }
1073
- disabled={isAmountInvalid || !amount}
1005
+ disabled={isAmountInvalid}
1074
1006
  >
1075
1007
  Continue
1076
1008
  </Button>
@@ -1200,6 +1132,7 @@ const InitiateWithdrawalStep = ({
1200
1132
  const { tokenInData } = useAppDetails();
1201
1133
  const sessionId = useAppStore((state) => state.sessionId);
1202
1134
  const [isLoading, setIsLoading] = useState(true);
1135
+ const [error, setError] = useState<string | null>(null);
1203
1136
  const selectedIntegration = useAppStore((s) => s.selectedIntegration);
1204
1137
 
1205
1138
  const handleMeshAccessPayload = useHandleMeshAccessPayload();
@@ -1272,6 +1205,7 @@ const InitiateWithdrawalStep = ({
1272
1205
 
1273
1206
  console.log("accessTokens", accessTokens);
1274
1207
 
1208
+ let handledByEvent = false;
1275
1209
  const link = createLink({
1276
1210
  clientId: address,
1277
1211
  accessTokens,
@@ -1285,6 +1219,7 @@ const InitiateWithdrawalStep = ({
1285
1219
  },
1286
1220
  onExit: (error) => {
1287
1221
  console.log("Mesh link exited:", error);
1222
+ if (handledByEvent) return;
1288
1223
  setIsLoading(false);
1289
1224
  setStep(WithdrawalStep.ChooseExchangeAsset);
1290
1225
  },
@@ -1294,8 +1229,18 @@ const InitiateWithdrawalStep = ({
1294
1229
  console.log(
1295
1230
  "Transfer executed, closing mesh link and moving to TrackUserOp step",
1296
1231
  );
1232
+ handledByEvent = true;
1297
1233
  link.closeLink();
1298
1234
  setStep(WithdrawalStep.TrackUserOp);
1235
+ } else if (ev.type === "transferExecutionError") {
1236
+ const errorMessage =
1237
+ ev.payload?.errorMessage ||
1238
+ "Transfer failed. Please try again.";
1239
+ console.error("Mesh transfer error:", errorMessage);
1240
+ handledByEvent = true;
1241
+ link.closeLink();
1242
+ setError(errorMessage);
1243
+ setIsLoading(false);
1299
1244
  }
1300
1245
  },
1301
1246
  });
@@ -1310,6 +1255,28 @@ const InitiateWithdrawalStep = ({
1310
1255
  fetchLinkTokenAndOpen();
1311
1256
  }, [selectedToken, userOp]);
1312
1257
 
1258
+ if (error) {
1259
+ return (
1260
+ <BodyWrapper>
1261
+ <Flex direction="column" align="center" justify="center" flex={1} p={6} gap={4}>
1262
+ <Flex
1263
+ align="center"
1264
+ justify="center"
1265
+ w={12}
1266
+ h={12}
1267
+ borderRadius="full"
1268
+ bg="orange.100"
1269
+ >
1270
+ <Icon as={TriangleAlert} boxSize={6} color="orange.500" />
1271
+ </Flex>
1272
+ <Text fontSize="md" textAlign="center" color="gray.700">
1273
+ {error}
1274
+ </Text>
1275
+ </Flex>
1276
+ </BodyWrapper>
1277
+ );
1278
+ }
1279
+
1313
1280
  return (
1314
1281
  <BodyWrapper>
1315
1282
  <Center>