@0xsquid/deposit-widget 0.1.0-beta.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.
Files changed (122) hide show
  1. package/dist/cjs/index.cjs +4062 -0
  2. package/dist/cjs/index.cjs.map +1 -0
  3. package/dist/index.d.ts +52 -0
  4. package/dist/index.esm.js +4042 -0
  5. package/dist/index.esm.js.map +1 -0
  6. package/dist/types/DepositWidget.d.ts +7 -0
  7. package/dist/types/components/ViewTransition.d.ts +7 -0
  8. package/dist/types/components/shared/buttons/button.d.ts +3 -0
  9. package/dist/types/components/shared/icons/types.d.ts +3 -0
  10. package/dist/types/components/shared/icons/user-round.d.ts +2 -0
  11. package/dist/types/components/shared/navigation/base-navbar.d.ts +3 -0
  12. package/dist/types/components/shared/navigation/sub-navbar.d.ts +6 -0
  13. package/dist/types/components/token-badge-icon.d.ts +6 -0
  14. package/dist/types/components/token-list-item.d.ts +13 -0
  15. package/dist/types/components/view-container.d.ts +1 -0
  16. package/dist/types/constants.d.ts +1 -0
  17. package/dist/types/hooks/ui/useMainCTAButtonState.d.ts +8 -0
  18. package/dist/types/hooks/use-auto-select-token.d.ts +1 -0
  19. package/dist/types/hooks/use-deposit-route.d.ts +7 -0
  20. package/dist/types/hooks/use-token-selection.d.ts +5 -0
  21. package/dist/types/hooks/use-transaction-history.d.ts +6 -0
  22. package/dist/types/index.d.ts +3 -0
  23. package/dist/types/services/assets-service.d.ts +6 -0
  24. package/dist/types/services/wallet-history/format.d.ts +10 -0
  25. package/dist/types/services/wallet-history/format.test.d.ts +1 -0
  26. package/dist/types/services/wallet-history/get-main-explorer-url.d.ts +23 -0
  27. package/dist/types/services/wallet-history/get-wallet-history.d.ts +2 -0
  28. package/dist/types/services/wallet-history/types.d.ts +66 -0
  29. package/dist/types/services/wallet-history/validation.d.ts +2 -0
  30. package/dist/types/store/use-deposit-store.d.ts +9 -0
  31. package/dist/types/store/use-input-mode.d.ts +5 -0
  32. package/dist/types/store/useRouter.d.ts +12 -0
  33. package/dist/types/types.d.ts +43 -0
  34. package/dist/types/utils/format-date.d.ts +2 -0
  35. package/dist/types/utils/format-date.test.d.ts +1 -0
  36. package/dist/types/utils/transaction.d.ts +10 -0
  37. package/dist/types/views/connect-wallet/connect-wallet-view.d.ts +6 -0
  38. package/dist/types/views/connect-wallet/wallet-list-item.d.ts +9 -0
  39. package/dist/types/views/main/amount-input.d.ts +10 -0
  40. package/dist/types/views/main/connect-prompt.d.ts +1 -0
  41. package/dist/types/views/main/deposit-amount-input.d.ts +1 -0
  42. package/dist/types/views/main/deposit-form.d.ts +1 -0
  43. package/dist/types/views/main/main-cta-button.d.ts +1 -0
  44. package/dist/types/views/main/main-view.d.ts +1 -0
  45. package/dist/types/views/main/navbar/actions.d.ts +1 -0
  46. package/dist/types/views/main/navbar/icon.d.ts +1 -0
  47. package/dist/types/views/main/navbar/navbar.d.ts +1 -0
  48. package/dist/types/views/main/navbar/title.d.ts +10 -0
  49. package/dist/types/views/main/recipient/account.d.ts +14 -0
  50. package/dist/types/views/main/recipient/recipient.d.ts +1 -0
  51. package/dist/types/views/main/token-selector.d.ts +1 -0
  52. package/dist/types/views/qr-code.d.ts +1 -0
  53. package/dist/types/views/render-view.d.ts +3 -0
  54. package/dist/types/views/select-chain/chain-type-meta.d.ts +7 -0
  55. package/dist/types/views/select-chain/select-chain-view.d.ts +6 -0
  56. package/dist/types/views/select-token.d.ts +1 -0
  57. package/dist/types/views/transaction-history/activity-list-item.d.ts +4 -0
  58. package/dist/types/views/transaction-history/transaction-history-view.d.ts +1 -0
  59. package/dist/types/views/transaction-progress/helpers.d.ts +14 -0
  60. package/dist/types/views/transaction-progress/transaction-progress-view.d.ts +1 -0
  61. package/dist/types/views/transaction-progress/use-transaction-progress.d.ts +17 -0
  62. package/package.json +99 -0
  63. package/src/DepositWidget.tsx +158 -0
  64. package/src/compiled-tailwind.css +6100 -0
  65. package/src/components/ViewTransition.tsx +81 -0
  66. package/src/components/shared/buttons/button.tsx +17 -0
  67. package/src/components/shared/icons/types.ts +3 -0
  68. package/src/components/shared/icons/user-round.tsx +21 -0
  69. package/src/components/shared/navigation/base-navbar.tsx +15 -0
  70. package/src/components/shared/navigation/sub-navbar.tsx +46 -0
  71. package/src/components/token-badge-icon.tsx +31 -0
  72. package/src/components/token-list-item.tsx +84 -0
  73. package/src/components/view-container.tsx +16 -0
  74. package/src/constants.ts +1 -0
  75. package/src/css.d.ts +4 -0
  76. package/src/fonts/DMSans-Variable.woff2 +0 -0
  77. package/src/hooks/ui/useMainCTAButtonState.ts +143 -0
  78. package/src/hooks/use-auto-select-token.ts +65 -0
  79. package/src/hooks/use-deposit-route.ts +58 -0
  80. package/src/hooks/use-token-selection.ts +17 -0
  81. package/src/hooks/use-transaction-history.ts +198 -0
  82. package/src/index.ts +3 -0
  83. package/src/services/assets-service.ts +21 -0
  84. package/src/services/wallet-history/format.test.ts +63 -0
  85. package/src/services/wallet-history/format.ts +128 -0
  86. package/src/services/wallet-history/get-main-explorer-url.ts +74 -0
  87. package/src/services/wallet-history/get-wallet-history.ts +24 -0
  88. package/src/services/wallet-history/types.ts +66 -0
  89. package/src/services/wallet-history/validation.ts +60 -0
  90. package/src/store/use-deposit-store.ts +20 -0
  91. package/src/store/use-input-mode.ts +10 -0
  92. package/src/store/useRouter.ts +49 -0
  93. package/src/tailwind.css +16 -0
  94. package/src/types.ts +39 -0
  95. package/src/utils/format-date.test.ts +32 -0
  96. package/src/utils/format-date.ts +25 -0
  97. package/src/utils/transaction.ts +39 -0
  98. package/src/views/connect-wallet/connect-wallet-view.tsx +147 -0
  99. package/src/views/connect-wallet/wallet-list-item.tsx +69 -0
  100. package/src/views/main/amount-input.tsx +272 -0
  101. package/src/views/main/connect-prompt.tsx +47 -0
  102. package/src/views/main/deposit-amount-input.tsx +42 -0
  103. package/src/views/main/deposit-form.tsx +13 -0
  104. package/src/views/main/main-cta-button.tsx +14 -0
  105. package/src/views/main/main-view.tsx +24 -0
  106. package/src/views/main/navbar/actions.tsx +25 -0
  107. package/src/views/main/navbar/icon.tsx +11 -0
  108. package/src/views/main/navbar/navbar.tsx +16 -0
  109. package/src/views/main/navbar/title.tsx +64 -0
  110. package/src/views/main/recipient/account.tsx +81 -0
  111. package/src/views/main/recipient/recipient.tsx +64 -0
  112. package/src/views/main/token-selector.tsx +77 -0
  113. package/src/views/qr-code.tsx +14 -0
  114. package/src/views/render-view.tsx +28 -0
  115. package/src/views/select-chain/chain-type-meta.ts +37 -0
  116. package/src/views/select-chain/select-chain-view.tsx +97 -0
  117. package/src/views/select-token.tsx +227 -0
  118. package/src/views/transaction-history/activity-list-item.tsx +87 -0
  119. package/src/views/transaction-history/transaction-history-view.tsx +58 -0
  120. package/src/views/transaction-progress/helpers.tsx +93 -0
  121. package/src/views/transaction-progress/transaction-progress-view.tsx +217 -0
  122. package/src/views/transaction-progress/use-transaction-progress.ts +112 -0
@@ -0,0 +1,49 @@
1
+ import { create } from "zustand";
2
+ import type { WidgetView } from "../types";
3
+
4
+ const INITIAL_VIEW: WidgetView = { id: "main" };
5
+
6
+ export type NavigationDirection = "forward" | "backward";
7
+
8
+ interface WidgetNavigationState {
9
+ stack: WidgetView[];
10
+ direction: NavigationDirection | null;
11
+ push: (_view: WidgetView) => void;
12
+ pop: () => void;
13
+ popToRoot: () => void;
14
+ }
15
+
16
+ export const selectCurrentView = (state: WidgetNavigationState): WidgetView =>
17
+ state.stack[state.stack.length - 1] ?? INITIAL_VIEW;
18
+
19
+ export const useRouter = create<WidgetNavigationState>((set) => ({
20
+ stack: [INITIAL_VIEW],
21
+ direction: null,
22
+
23
+ push: (view: WidgetView): void =>
24
+ set((state) => {
25
+ if (selectCurrentView(state).id === view.id) return state;
26
+ return {
27
+ stack: [...state.stack, view],
28
+ direction: "forward",
29
+ };
30
+ }),
31
+
32
+ pop: (): void =>
33
+ set((state) => {
34
+ if (state.stack.length <= 1) return state;
35
+ return {
36
+ stack: state.stack.slice(0, -1),
37
+ direction: "backward",
38
+ };
39
+ }),
40
+
41
+ popToRoot: (): void =>
42
+ set((state) => {
43
+ if (state.stack.length <= 1) return state;
44
+ return {
45
+ stack: [INITIAL_VIEW],
46
+ direction: "backward",
47
+ };
48
+ }),
49
+ }));
@@ -0,0 +1,16 @@
1
+ @import "@0xsquid/ui/dist/custom-styles.css";
2
+
3
+ @font-face {
4
+ font-family: "DMSansVariable";
5
+ src:
6
+ local("DMSansVariable"),
7
+ url("./fonts/DMSans-Variable.woff2") format("woff2");
8
+ font-display: swap;
9
+ }
10
+
11
+ .squid-style-container--preflight {
12
+ @tailwind base;
13
+ }
14
+
15
+ @tailwind components;
16
+ @tailwind utilities;
package/src/types.ts ADDED
@@ -0,0 +1,39 @@
1
+ import type { Wallet } from "@0xsquid/react-hooks";
2
+ import type { ChainType } from "@0xsquid/squid-types";
3
+ import type { Theme, ThemeType } from "@0xsquid/ui";
4
+
5
+ // View definitions — discriminated union so params are typed per view
6
+ export type WidgetView =
7
+ | { id: "main" }
8
+ | { id: "connect-wallet"; chainType: ChainType | null }
9
+ | { id: "select-chain"; wallet: Wallet }
10
+ | { id: "qr-code" }
11
+ | { id: "select-token" }
12
+ | { id: "transaction-history" }
13
+ | { id: "transaction-progress" };
14
+
15
+ export type WidgetViewId = WidgetView["id"];
16
+
17
+ interface BaseDepositConfig {
18
+ destinationAddress: string;
19
+
20
+ destinationToken: {
21
+ address: string;
22
+ chainId: string;
23
+ };
24
+
25
+ integrator: {
26
+ id: string;
27
+ name: string;
28
+ logoUrl: string;
29
+ };
30
+
31
+ apiUrl?: string;
32
+
33
+ theme?: Theme;
34
+ themeType?: ThemeType;
35
+ }
36
+
37
+ export type DepositConfig =
38
+ | (BaseDepositConfig & { mode: "deposit" })
39
+ | (BaseDepositConfig & { mode: "payment"; amount: string });
@@ -0,0 +1,32 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { formatDayHeader, formatDateToHhMm } from "./format-date";
3
+
4
+ describe("formatDayHeader", () => {
5
+ beforeEach(() => {
6
+ vi.useFakeTimers();
7
+ vi.setSystemTime(new Date(2026, 3, 14));
8
+ });
9
+
10
+ afterEach(() => {
11
+ vi.useRealTimers();
12
+ });
13
+
14
+ it("omits the year when the date is in the current year", () => {
15
+ expect(formatDayHeader("2026-01-05")).toBe("January 5");
16
+ });
17
+
18
+ it("includes the year when the date is not in the current year", () => {
19
+ expect(formatDayHeader("2024-12-31")).toBe("December 31, 2024");
20
+ });
21
+
22
+ it("handles single-digit months and days", () => {
23
+ expect(formatDayHeader("2026-03-07")).toBe("March 7");
24
+ });
25
+ });
26
+
27
+ describe("formatDateToHhMm", () => {
28
+ it("formats a date to a 2-digit hour and minute string", () => {
29
+ const date = new Date(2026, 3, 14, 9, 5);
30
+ expect(formatDateToHhMm(date)).toMatch(/09:05/);
31
+ });
32
+ });
@@ -0,0 +1,25 @@
1
+ export function formatDateToHhMm(date: Date): string {
2
+ return date.toLocaleTimeString("en-US", {
3
+ hour: "2-digit",
4
+ minute: "2-digit",
5
+ });
6
+ }
7
+
8
+ export function formatDayHeader(yyyyMmDd: string): string {
9
+ const [year = 0, month = 0, day] = yyyyMmDd.split("-").map(Number);
10
+ const date = new Date(year, month - 1, day);
11
+ const currentYear = new Date().getFullYear();
12
+
13
+ if (year === currentYear) {
14
+ return new Intl.DateTimeFormat("en-US", {
15
+ month: "long",
16
+ day: "numeric",
17
+ }).format(date);
18
+ }
19
+
20
+ return new Intl.DateTimeFormat("en-US", {
21
+ month: "long",
22
+ day: "numeric",
23
+ year: "numeric",
24
+ }).format(date);
25
+ }
@@ -0,0 +1,39 @@
1
+ import { parseToBigInt } from "@0xsquid/react-hooks";
2
+ import type { SwapTransactionHistory } from "@0xsquid/react-hooks";
3
+ import { SquidTransaction } from "../services/wallet-history/types";
4
+ import type { DepositConfig } from "../types";
5
+
6
+ export function isSameTx(
7
+ tx: SquidTransaction,
8
+ pending: SwapTransactionHistory,
9
+ ): boolean {
10
+ if (!tx.transactionId || !pending.transactionId) return false;
11
+ return tx.transactionId.toLowerCase() === pending.transactionId.toLowerCase();
12
+ }
13
+
14
+ export function isSameAddress(a: string, b: string): boolean {
15
+ return a.trim().toLowerCase() === b.trim().toLowerCase();
16
+ }
17
+
18
+ export function matchesDestinationToken(
19
+ toToken: { address: string; chainId: string },
20
+ destinationToken: DepositConfig["destinationToken"],
21
+ ): boolean {
22
+ return (
23
+ toToken.chainId === destinationToken.chainId &&
24
+ isSameAddress(toToken.address, destinationToken.address)
25
+ );
26
+ }
27
+
28
+ export function isPaymentTx(
29
+ tx: SquidTransaction,
30
+ paymentAmount: string,
31
+ destinationTokenDecimals: number,
32
+ ): boolean {
33
+ try {
34
+ const expected = parseToBigInt(paymentAmount, destinationTokenDecimals);
35
+ return tx.quote.route.estimate.toAmount === expected.toString();
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
@@ -0,0 +1,147 @@
1
+ import { useCallback, useMemo, useState } from "react";
2
+ import {
3
+ ConnectingWalletStatus,
4
+ useWallet,
5
+ useWallets,
6
+ sortWallets,
7
+ } from "@0xsquid/react-hooks";
8
+ import type { ConnectorID, Wallet } from "@0xsquid/react-hooks";
9
+ import type { ChainType } from "@0xsquid/squid-types";
10
+ import Fuse from "fuse.js";
11
+ import { SubNavBar } from "../../components/shared/navigation/sub-navbar";
12
+ import { useRouter } from "../../store/useRouter";
13
+ import { WalletListItem } from "./wallet-list-item";
14
+ import { ViewContainer } from "../../components/view-container";
15
+ import { CHAIN_TYPE_META } from "../select-chain/chain-type-meta";
16
+
17
+ const WALLET_PRIORITY_LIST: ConnectorID[] = [
18
+ "walletConnect",
19
+ "metamask",
20
+ "phantom",
21
+ "rabby",
22
+ "trustwallet",
23
+ ];
24
+
25
+ function walletSupportsChainType(wallet: Wallet, chainType: ChainType) {
26
+ if (wallet.isMultiChain) {
27
+ return wallet.supportedNetworks.some((n) => n.chainType === chainType);
28
+ }
29
+ return wallet.type === chainType;
30
+ }
31
+
32
+ interface ConnectWalletViewProps {
33
+ chainType: ChainType | null;
34
+ }
35
+
36
+ export function ConnectWalletView({ chainType }: ConnectWalletViewProps) {
37
+ const { wallets } = useWallets();
38
+ const { connectWallet, connectingWalletState } = useWallet();
39
+ const pop = useRouter((s) => s.pop);
40
+ const push = useRouter((s) => s.push);
41
+ const [search, setSearch] = useState("");
42
+
43
+ const requiredChainType = chainType;
44
+
45
+ const connectingConnectorId =
46
+ connectingWalletState.status === ConnectingWalletStatus.CONNECTING
47
+ ? connectingWalletState.wallet?.connectorId
48
+ : undefined;
49
+
50
+ const isMultiChainWallet = useCallback(
51
+ (wallet: Wallet) =>
52
+ !requiredChainType &&
53
+ wallet.isMultiChain &&
54
+ wallet.supportedNetworks.length > 1 &&
55
+ !!wallet.isInstalled?.(),
56
+ [requiredChainType],
57
+ );
58
+
59
+ const onWalletClicked = async (wallet: Wallet) => {
60
+ if (isMultiChainWallet(wallet)) {
61
+ push({ id: "select-chain", wallet });
62
+ return;
63
+ }
64
+
65
+ try {
66
+ const selectedChainTypes = requiredChainType
67
+ ? [requiredChainType]
68
+ : undefined;
69
+ const connected = await connectWallet({ wallet, selectedChainTypes });
70
+ if (connected) {
71
+ pop();
72
+ }
73
+ } catch (error) {
74
+ console.error("Failed to connect wallet:", error);
75
+ }
76
+ };
77
+
78
+ const sortedWallets = useMemo(
79
+ () =>
80
+ wallets.toSorted((a, b) => {
81
+ if (requiredChainType) {
82
+ const aSupports = walletSupportsChainType(a, requiredChainType);
83
+ const bSupports = walletSupportsChainType(b, requiredChainType);
84
+ if (aSupports !== bSupports) return aSupports ? -1 : 1;
85
+ }
86
+ return sortWallets(a, b, {
87
+ isMobile: false,
88
+ priorityList: WALLET_PRIORITY_LIST,
89
+ });
90
+ }),
91
+ [wallets, requiredChainType],
92
+ );
93
+
94
+ const fuse = useMemo(
95
+ () =>
96
+ new Fuse(sortedWallets, {
97
+ isCaseSensitive: false,
98
+ includeScore: false,
99
+ minMatchCharLength: 1,
100
+ threshold: 0.4,
101
+ keys: ["name"],
102
+ }),
103
+ [sortedWallets],
104
+ );
105
+
106
+ const walletsToDisplay = useMemo(() => {
107
+ if (search.length === 0) return sortedWallets;
108
+ return fuse.search(search).map((result) => result.item);
109
+ }, [fuse, search, sortedWallets]);
110
+
111
+ const chainTypeWallets = useMemo(() => {
112
+ if (!requiredChainType) return walletsToDisplay;
113
+ return walletsToDisplay.filter((w) =>
114
+ walletSupportsChainType(w, requiredChainType),
115
+ );
116
+ }, [walletsToDisplay, requiredChainType]);
117
+
118
+ return (
119
+ <>
120
+ <SubNavBar
121
+ input={{
122
+ placeholder: requiredChainType
123
+ ? `Select ${CHAIN_TYPE_META[requiredChainType].name} wallet`
124
+ : "Select wallet",
125
+ onChange: (e) => setSearch(e.target.value),
126
+ autoComplete: "off",
127
+ autoCorrect: "off",
128
+ spellCheck: false,
129
+ }}
130
+ />
131
+
132
+ <ViewContainer>
133
+ <ul className="tw-flex tw-flex-col tw-items-start tw-flex-1 tw-self-stretch">
134
+ {chainTypeWallets.map((wallet) => (
135
+ <WalletListItem
136
+ key={wallet.connectorId}
137
+ wallet={wallet}
138
+ isConnecting={wallet.connectorId === connectingConnectorId}
139
+ showAsMultiChain={isMultiChainWallet(wallet)}
140
+ onClick={() => onWalletClicked(wallet)}
141
+ />
142
+ ))}
143
+ </ul>
144
+ </ViewContainer>
145
+ </>
146
+ );
147
+ }
@@ -0,0 +1,69 @@
1
+ import type { Wallet } from "@0xsquid/react-hooks";
2
+ import { ChevronLargeRightIcon, Loader, cn } from "@0xsquid/ui";
3
+
4
+ interface WalletListItemProps extends React.ComponentProps<"button"> {
5
+ wallet: Wallet;
6
+ isConnecting?: boolean;
7
+ showAsMultiChain?: boolean;
8
+ parentProps?: React.ComponentProps<"li">;
9
+ }
10
+
11
+ export function WalletListItem({
12
+ wallet,
13
+ isConnecting,
14
+ showAsMultiChain,
15
+ parentProps,
16
+ ...props
17
+ }: WalletListItemProps) {
18
+ const isInstalled = wallet.isInstalled?.();
19
+
20
+ return (
21
+ <li className="tw-self-stretch" {...parentProps}>
22
+ <button
23
+ className="tw-group/wallet tw-flex tw-min-h-14 tw-h-14 tw-px-4 tw-items-center tw-gap-3 tw-self-stretch tw-w-full hover:tw-bg-material-light-thin"
24
+ disabled={isConnecting}
25
+ {...props}
26
+ >
27
+ {/* icon */}
28
+ <div className="tw-flex tw-size-squid-xl tw-justify-center tw-items-center tw-aspect-square tw-rounded-xl tw-shadow-icon-light dark:tw-shadow-icon-dark">
29
+ {wallet.icon && (
30
+ <img
31
+ src={wallet.icon}
32
+ alt={wallet.name}
33
+ className="tw-size-full tw-rounded-xl"
34
+ />
35
+ )}
36
+ </div>
37
+
38
+ {/* label */}
39
+ <span className="tw-flex tw-flex-col tw-justify-center tw-items-start tw-gap-2 tw-flex-1 tw-self-stretch tw-text-grey-200 tw-text-lg">
40
+ {wallet.name}
41
+ </span>
42
+
43
+ {/* detail */}
44
+ {isConnecting ? (
45
+ <Loader size="20" className="tw-text-grey-100" />
46
+ ) : (
47
+ <div className="tw-flex tw-items-center">
48
+ {isInstalled && (
49
+ <div
50
+ className={cn(
51
+ "tw-flex tw-py-squid-s tw-flex-col tw-justify-center tw-items-end tw-self-stretch tw-text-material-light-thick tw-text-base tw-transition-transform tw-duration-200",
52
+ showAsMultiChain && "group-hover/wallet:-tw-translate-x-1",
53
+ )}
54
+ >
55
+ Installed
56
+ </div>
57
+ )}
58
+ {showAsMultiChain && (
59
+ <ChevronLargeRightIcon
60
+ className="tw-text-material-light-thick tw-transition-all tw-duration-200 tw-w-0 tw-opacity-0 group-hover/wallet:tw-w-4 group-hover/wallet:tw-opacity-100"
61
+ size="0.75rem"
62
+ />
63
+ )}
64
+ </div>
65
+ )}
66
+ </button>
67
+ </li>
68
+ );
69
+ }
@@ -0,0 +1,272 @@
1
+ import {
2
+ AnimateNumber,
3
+ CaptionText,
4
+ ChevronTopSmallIcon,
5
+ cn,
6
+ NumericInputProps,
7
+ Tooltip,
8
+ useNumericInput,
9
+ UserInputType,
10
+ } from "@0xsquid/ui";
11
+ import { useEffect, useRef } from "react";
12
+ import { useInputMode } from "../../store/use-input-mode";
13
+
14
+ const placeholder = "0";
15
+ const BASE_FONT_PX = 48; // tw-text-5xl = 3rem
16
+ const WRAPPER_PAD_X = 20; // tw-px-squid-xs = 0.625rem × 2
17
+ const MIN_SCALE = 0.5;
18
+
19
+ interface AmountInputProps extends NumericInputProps {
20
+ allowMaxButton?: boolean;
21
+ readOnly?: boolean;
22
+ errorMessage?: string;
23
+ loading?: boolean;
24
+ forcedUsdDisplay?: string;
25
+ }
26
+
27
+ export function AmountInput({
28
+ balance = "0",
29
+ token,
30
+ onAmountChange,
31
+ forcedAmount,
32
+ maxUsdDecimals = 2,
33
+ formatIfVerySmall = {
34
+ token: "0.001",
35
+ usd: "0.01",
36
+ },
37
+ inputModeButton,
38
+ debounceInput = true,
39
+ allowMaxButton = true,
40
+ readOnly = false,
41
+ errorMessage,
42
+ loading = false,
43
+ forcedUsdDisplay,
44
+ }: AmountInputProps) {
45
+ const inputRef = useRef<HTMLInputElement>(null);
46
+ const inputValueSpyRef = useRef<HTMLSpanElement>(null);
47
+ const inputWidthSpyRef = useRef<HTMLSpanElement>(null);
48
+ const wrapperRef = useRef<HTMLLabelElement & HTMLDivElement>(null);
49
+ const {
50
+ userInputType,
51
+ inputValue,
52
+ handleInputChange,
53
+ handleSwitchInputMode,
54
+ isTokenAmountVerySmall,
55
+ isUsdAmountVerySmall,
56
+ amountFormatted,
57
+ onBalanceButtonClick,
58
+ } = useNumericInput({
59
+ onAmountChange,
60
+ token,
61
+ balance,
62
+ debounceInput,
63
+ forcedAmount,
64
+ formatIfVerySmall,
65
+ inputModeButton,
66
+ maxUsdDecimals,
67
+ direction: "from",
68
+ initialInputMode: UserInputType.USD,
69
+ });
70
+
71
+ const setInputMode = useInputMode((s) => s.setMode);
72
+ useEffect(() => {
73
+ setInputMode(userInputType);
74
+ }, [userInputType, setInputMode]);
75
+
76
+ const displayedNumber =
77
+ userInputType === UserInputType.USD && forcedUsdDisplay && !inputValue
78
+ ? forcedUsdDisplay
79
+ : inputValue || placeholder;
80
+ const fullDisplayedNumber =
81
+ userInputType === UserInputType.USD
82
+ ? `$${displayedNumber}`
83
+ : displayedNumber;
84
+
85
+ useEffect(() => {
86
+ const spy = inputValueSpyRef.current;
87
+ const wrapper = wrapperRef.current;
88
+ const row = wrapper?.parentElement;
89
+ if (!spy || !wrapper || !row) return;
90
+
91
+ const apply = () => {
92
+ const containerWidth = row.clientWidth - WRAPPER_PAD_X;
93
+ const spyWidth = spy.getBoundingClientRect().width;
94
+ if (containerWidth === 0 || spyWidth === 0) return;
95
+
96
+ // Scale against the full visible value, including the "$" prefix in USD
97
+ // mode, so read-only/payment values don't overflow by the prefix width.
98
+ const scale = Math.min(1, Math.max(MIN_SCALE, containerWidth / spyWidth));
99
+ wrapper.style.fontSize = `${BASE_FONT_PX * scale}px`;
100
+ if (inputRef.current) {
101
+ const inputWidthSpy = inputWidthSpyRef.current;
102
+ // Editable USD renders "$" beside the input, so the input itself must
103
+ // be sized from the number-only spy. Token mode can reuse full width.
104
+ const inputWidth =
105
+ userInputType === UserInputType.USD && inputWidthSpy
106
+ ? inputWidthSpy.getBoundingClientRect().width
107
+ : spyWidth;
108
+
109
+ inputRef.current.style.width = `${inputWidth * scale}px`;
110
+ }
111
+ };
112
+
113
+ const observer = new ResizeObserver(apply);
114
+ observer.observe(spy);
115
+ observer.observe(row);
116
+ apply();
117
+
118
+ return () => observer.disconnect();
119
+ }, [readOnly, displayedNumber, fullDisplayedNumber, userInputType]);
120
+ const wrapperClassName = cn(
121
+ "tw-text-grey-100 tw-flex tw-items-center tw-overflow-hidden tw-px-squid-xs",
122
+ readOnly
123
+ ? "tw-cursor-default"
124
+ : "tw-cursor-text focus-within:tw-bg-material-light-thin hover:tw-bg-material-light-thin group-focus-within/amount-input:tw-text-royal-500",
125
+ );
126
+
127
+ // em so the radius scales with the wrapper's font-size (which the
128
+ // auto-shrink effect mutates) — keeps the corner proportional to the
129
+ // shrunk height instead of looking pill-like at small font sizes.
130
+ const wrapperStyle = { borderRadius: "0.4em" };
131
+
132
+ const inputContent = (
133
+ <>
134
+ {/* Hidden full visible value, fixed at base font size, for wrapper scaling. */}
135
+ <span
136
+ ref={inputValueSpyRef}
137
+ className="tw-pointer-events-none tw-absolute tw-opacity-0 tw-text-5xl"
138
+ >
139
+ {fullDisplayedNumber}
140
+ </span>
141
+ {/* Hidden number-only value for editable USD input width. */}
142
+ <span
143
+ ref={inputWidthSpyRef}
144
+ className="tw-pointer-events-none tw-absolute tw-opacity-0 tw-text-5xl"
145
+ >
146
+ {displayedNumber}
147
+ </span>
148
+
149
+ {readOnly ? (
150
+ <span className="tw-relative tw-py-2 tw-flex tw-items-center">
151
+ {userInputType === UserInputType.USD && <span>$</span>}
152
+ <AnimateNumber
153
+ isLoading={loading}
154
+ number={displayedNumber}
155
+ showMask
156
+ />
157
+ {loading && (
158
+ <span className="tw-pointer-events-none tw-absolute tw-inset-0 tw-z-10 tw-overflow-hidden">
159
+ <span className="tw-absolute tw-inset-0 tw-animate-shimmer tw-bg-[length:200%_100%] tw-bg-shimmer-overlay-light dark:tw-bg-shimmer-overlay-dark" />
160
+ </span>
161
+ )}
162
+ </span>
163
+ ) : (
164
+ <>
165
+ {userInputType === UserInputType.USD && <span>$</span>}
166
+ <input
167
+ inputMode="decimal"
168
+ pattern="[0-9.,]*"
169
+ ref={inputRef}
170
+ value={inputValue}
171
+ onChange={handleInputChange}
172
+ className="tw-bg-transparent tw-placeholder-grey-100 tw-outline-none focus:tw-outline-none focus:tw-placeholder-royal-500"
173
+ placeholder={placeholder}
174
+ />
175
+ </>
176
+ )}
177
+ </>
178
+ );
179
+
180
+ return (
181
+ <section className="tw-flex tw-flex-1 tw-flex-col tw-items-center tw-justify-center tw-self-stretch tw-gap-1 tw-relative tw-overflow-hidden">
182
+ <div className="tw-flex tw-w-full tw-items-center tw-justify-center tw-self-stretch tw-text-5xl tw-group/amount-input">
183
+ {readOnly ? (
184
+ <div
185
+ ref={wrapperRef}
186
+ className={wrapperClassName}
187
+ style={wrapperStyle}
188
+ >
189
+ {inputContent}
190
+ </div>
191
+ ) : (
192
+ <label
193
+ ref={wrapperRef}
194
+ className={wrapperClassName}
195
+ style={wrapperStyle}
196
+ >
197
+ {inputContent}
198
+ </label>
199
+ )}
200
+ </div>
201
+
202
+ <div className="tw-relative tw-flex tw-justify-center tw-self-stretch">
203
+ <footer className="tw-flex tw-h-squid-m tw-items-center tw-justify-center tw-text-sm tw-text-material-light-thick tw-w-fit">
204
+ <Tooltip
205
+ {...(userInputType === UserInputType.TOKEN
206
+ ? inputModeButton?.tokenModeTooltip
207
+ : inputModeButton?.usdModeTooltip)}
208
+ tooltipWidth="max"
209
+ childrenClassName="tw-rounded-squid-s"
210
+ containerClassName="tw-rounded-squid-s tw-w-fit"
211
+ >
212
+ <button
213
+ onClick={() => {
214
+ handleSwitchInputMode();
215
+ inputRef.current?.focus();
216
+ }}
217
+ className="tw-flex tw-flex-1 tw-items-center tw-gap-squid-xxs tw-rounded-squid-s tw-px-3 tw-py-0.5 hover:tw-bg-material-light-thin"
218
+ >
219
+ {userInputType === UserInputType.TOKEN ? (
220
+ <span className="tw-flex tw-items-center tw-justify-center">
221
+ <CaptionText>{isUsdAmountVerySmall ? "<" : ""}$</CaptionText>
222
+ <CaptionText>
223
+ {forcedUsdDisplay && !inputValue
224
+ ? forcedUsdDisplay
225
+ : isUsdAmountVerySmall
226
+ ? formatIfVerySmall.token
227
+ : amountFormatted}
228
+ </CaptionText>
229
+ </span>
230
+ ) : (
231
+ <>
232
+ <CaptionText>
233
+ {isTokenAmountVerySmall ? "<" : ""}
234
+ {isTokenAmountVerySmall
235
+ ? formatIfVerySmall.token
236
+ : amountFormatted}
237
+ </CaptionText>{" "}
238
+ <CaptionText>{token.symbol}</CaptionText>
239
+ </>
240
+ )}
241
+
242
+ <span className="tw-flex tw-items-center tw-justify-center tw-rounded-full tw-size-3 tw-bg-material-light-thin tw-aspect-square">
243
+ <ChevronTopSmallIcon />
244
+ </span>
245
+ </button>
246
+ </Tooltip>
247
+
248
+ {allowMaxButton && Number(balance) > 0 && (
249
+ <button
250
+ onClick={() => {
251
+ onBalanceButtonClick();
252
+ inputRef.current?.focus();
253
+ }}
254
+ className="tw-rounded-squid-s tw-px-3 tw-py-0.5 hover:tw-bg-material-light-thin"
255
+ >
256
+ <CaptionText>MAX</CaptionText>
257
+ </button>
258
+ )}
259
+ </footer>
260
+
261
+ {/* Anchored to the footer via top-full so it sits just below the
262
+ input + balance row regardless of the section's flex-1 height;
263
+ absolute so toggling it never reflows the input. */}
264
+ {errorMessage && (
265
+ <CaptionText className="tw-text-status-negative tw-text-center tw-absolute tw-top-full tw-mt-2 tw-left-0 tw-right-0 tw-px-4 [text-wrap:pretty]">
266
+ {errorMessage}
267
+ </CaptionText>
268
+ )}
269
+ </div>
270
+ </section>
271
+ );
272
+ }