diff --git a/campaign-launcher/client/eslint.config.js b/campaign-launcher/client/eslint.config.js index 65e707dc3..fd570195c 100644 --- a/campaign-launcher/client/eslint.config.js +++ b/campaign-launcher/client/eslint.config.js @@ -3,9 +3,10 @@ import tseslint from 'typescript-eslint'; import reactPlugin from 'eslint-plugin-react'; import reactHooksPlugin from 'eslint-plugin-react-hooks'; import prettierPlugin from 'eslint-plugin-prettier'; -import importPlugin from 'eslint-plugin-import'; +import importXPlugin from 'eslint-plugin-import-x'; import globals from 'globals'; import { defineConfig } from 'eslint/config'; +import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'; export default defineConfig( { @@ -35,17 +36,18 @@ export default defineConfig( react: reactPlugin, 'react-hooks': reactHooksPlugin, prettier: prettierPlugin, - import: importPlugin, + import: importXPlugin, }, settings: { react: { version: 'detect', }, - 'import/resolver': { - typescript: { + 'import-x/resolver-next': [ + createTypeScriptImportResolver({ alwaysTryTypes: true, - }, - }, + project: './tsconfig.json', + }), + ], }, rules: { 'react/prop-types': 'off', diff --git a/campaign-launcher/client/package.json b/campaign-launcher/client/package.json index 9f0a1943e..6e80cea87 100644 --- a/campaign-launcher/client/package.json +++ b/campaign-launcher/client/package.json @@ -54,7 +54,7 @@ "@vitejs/plugin-react-refresh": "^1.3.6", "eslint": "^10.2.0", "eslint-import-resolver-typescript": "^4.4.4", - "eslint-plugin-import": "^2.32.0", + "eslint-plugin-import-x": "^4.16.2", "eslint-plugin-prettier": "^5.5.5", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", diff --git a/campaign-launcher/client/public/tableImage.png b/campaign-launcher/client/public/tableImage.png deleted file mode 100644 index 82b7d8c5e..000000000 Binary files a/campaign-launcher/client/public/tableImage.png and /dev/null differ diff --git a/campaign-launcher/client/src/App.tsx b/campaign-launcher/client/src/App.tsx index 664df396c..c74913313 100644 --- a/campaign-launcher/client/src/App.tsx +++ b/campaign-launcher/client/src/App.tsx @@ -6,19 +6,19 @@ import { BrowserRouter, Routes, Route } from 'react-router'; import Layout from '@/components/Layout'; import ProtectedRoute from '@/components/ProtectedRoute'; -import StakeProtectedRoute from '@/components/StakeProtectedRoute'; +import WalletProtectedRoute from '@/components/WalletProtectedRoute'; import { ROUTES } from '@/constants'; import Campaign from '@/pages/Campaign'; import Dashboard from '@/pages/Dashboard'; import LaunchCampaignPage from '@/pages/LaunchCampaign'; import ManageApiKeysPage from '@/pages/ManageApiKeys'; import ActiveAccountProvider from '@/providers/ActiveAccountProvider'; +import { AuthedUserDataProvider } from '@/providers/AuthedUserData'; import ExchangesProvider from '@/providers/ExchangesProvider'; import { NetworkProvider } from '@/providers/NetworkProvider'; import { NotificationProvider } from '@/providers/NotificationProvider'; import QueryClientProvider from '@/providers/QueryClientProvider'; import SignerProvider from '@/providers/SignerProvider'; -import StakeProvider from '@/providers/StakeProvider'; import ThemeProvider from '@/providers/ThemeProvider'; import WagmiProvider from '@/providers/WagmiProvider'; import { Web3AuthProvider } from '@/providers/Web3AuthProvider'; @@ -34,8 +34,8 @@ const App: FC = () => { - - + + { + - + } /> - - + + diff --git a/campaign-launcher/client/src/assets/coinbase.png b/campaign-launcher/client/src/assets/coinbase.png new file mode 100644 index 000000000..092bea92b Binary files /dev/null and b/campaign-launcher/client/src/assets/coinbase.png differ diff --git a/campaign-launcher/client/src/assets/coinbase.svg b/campaign-launcher/client/src/assets/coinbase.svg deleted file mode 100644 index 8e98d89e5..000000000 --- a/campaign-launcher/client/src/assets/coinbase.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/campaign-launcher/client/src/assets/metamask.png b/campaign-launcher/client/src/assets/metamask.png new file mode 100644 index 000000000..ace902fef Binary files /dev/null and b/campaign-launcher/client/src/assets/metamask.png differ diff --git a/campaign-launcher/client/src/assets/metamask.svg b/campaign-launcher/client/src/assets/metamask.svg deleted file mode 100644 index f893c1592..000000000 --- a/campaign-launcher/client/src/assets/metamask.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/campaign-launcher/client/src/assets/walletConnect.png b/campaign-launcher/client/src/assets/walletConnect.png new file mode 100644 index 000000000..63d1bbe51 Binary files /dev/null and b/campaign-launcher/client/src/assets/walletConnect.png differ diff --git a/campaign-launcher/client/src/assets/walletconnect.svg b/campaign-launcher/client/src/assets/walletconnect.svg deleted file mode 100644 index 3ef58a415..000000000 --- a/campaign-launcher/client/src/assets/walletconnect.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/campaign-launcher/client/src/components/Account/index.tsx b/campaign-launcher/client/src/components/Account/index.tsx index 55a0fda7d..8d8e156ab 100644 --- a/campaign-launcher/client/src/components/Account/index.tsx +++ b/campaign-launcher/client/src/components/Account/index.tsx @@ -83,16 +83,15 @@ const Account: FC = () => { return ( <> + )} + {(isPending || isSuccess) && ( + + )} + {isError && ( + + )} + + + + ); +}; + +export default AddApiKeyDialog; diff --git a/campaign-launcher/client/src/components/ApiKeysTable/index.tsx b/campaign-launcher/client/src/components/ApiKeysTable/index.tsx index a9fd5b74f..f0a7cdad5 100644 --- a/campaign-launcher/client/src/components/ApiKeysTable/index.tsx +++ b/campaign-launcher/client/src/components/ApiKeysTable/index.tsx @@ -6,17 +6,18 @@ import { Box, IconButton, List, ListItem, Typography } from '@mui/material'; import { DataGrid, type GridColDef } from '@mui/x-data-grid'; import CustomTooltip from '@/components/CustomTooltip'; +import DeleteApiKeyDialog from '@/components/DeleteApiKeyDialog'; +import EditApiKeyDialog from '@/components/EditApiKeyDialog'; import InfoTooltipInner from '@/components/InfoTooltipInner'; -import DeleteApiKeyModal from '@/components/modals/DeleteApiKeyModal'; -import EditApiKeyModal from '@/components/modals/EditApiKeyModal'; import { useRevalidateExchangeApiKey } from '@/hooks/recording-oracle/exchangeApiKeys'; import { useIsMobile } from '@/hooks/useBreakpoints'; -import { DeleteIcon, EditIcon } from '@/icons'; +import { DeleteIcon, EditIcon, NoKeysIcon } from '@/icons'; import type { ExchangeApiKeyData } from '@/types'; import { formatAddress } from '@/utils'; type ApiKeysTableProps = { data: ExchangeApiKeyData[] | undefined; + isLoading: boolean; }; const MissingPermissionsTooltip: FC<{ missingPermissions: string[] }> = ({ @@ -48,7 +49,7 @@ const MissingPermissionsTooltip: FC<{ missingPermissions: string[] }> = ({ ); }; -const ApiKeysTable: FC = ({ data }) => { +const ApiKeysTable: FC = ({ data, isLoading }) => { const [editingItem, setEditingItem] = useState(''); const [deletingItem, setDeletingItem] = useState(''); @@ -69,14 +70,6 @@ const ApiKeysTable: FC = ({ data }) => { setDeletingItem(exchangeName); }; - const handleCloseEditModal = () => { - setEditingItem(''); - }; - - const handleCloseDeleteModal = () => { - setDeletingItem(''); - }; - const rows = data?.map( ({ exchange_name, api_key, extras, is_valid, missing_permissions }) => ({ id: exchange_name, @@ -102,8 +95,10 @@ const ApiKeysTable: FC = ({ data }) => { return ( {exchangeName} @@ -125,7 +120,7 @@ const ApiKeysTable: FC = ({ data }) => { const isBitmart = params.row.exchangeName === 'bitmart'; return ( - + {isMobile ? formatAddress(params.row.apiKey) : params.row.apiKey} {isBitmart && !!params.row.extras?.api_key_memo && ( @@ -156,7 +151,7 @@ const ApiKeysTable: FC = ({ data }) => { { field: 'actions', headerName: '', - width: isMobile ? 110 : 96, + width: isMobile ? 110 : 126, renderCell: (params) => { const { exchangeName, isValid } = params.row; return ( @@ -201,11 +196,15 @@ const ApiKeysTable: FC = ({ data }) => { ]; return ( - + = ({ data }) => { }, }, }} + loading={isLoading} + slots={{ + loadingOverlay: undefined, + noRowsOverlay: !isLoading + ? () => ( + + + + No key is set at the moment + + + ) + : undefined, + }} sx={{ border: 'none', bgcolor: 'inherit', + borderRadius: '0px', '& .MuiDataGrid-main': { - p: isMobile ? 0 : 4, - borderRadius: '16px', + p: isMobile ? 0 : 0, width: '100%', - height: '450px', - border: '1px solid rgba(255, 255, 255, 0.1)', - background: - 'linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.05) 100%), #100735', + height: '400px', + background: 'background.default', }, '& .MuiDataGrid-cell': { + display: 'flex', + alignItems: 'center', borderTop: 'none', - p: isMobile ? 1.5 : 2, + py: 1.5, + px: 2, overflow: 'visible !important', '&[data-field="actions"]': { - px: isMobile ? 1 : 0, + px: isMobile ? 1 : 2, + justifyContent: 'flex-end', + }, + '&[data-field="apiKey"]': { + px: 0, }, }, '& .MuiDataGrid-cellEmpty': { @@ -251,13 +279,17 @@ const ApiKeysTable: FC = ({ data }) => { bgcolor: 'transparent', }, '& .MuiDataGrid-columnHeader': { - backgroundColor: 'rgba(255, 255, 255, 0.12) !important', - fontSize: '12px', - fontWeight: 400, - p: isMobile ? 1.5 : 2, + backgroundColor: '#251d47', + fontSize: '14px', + lineHeight: '14px', + fontWeight: 500, + py: 1.5, + px: 2, overflow: 'visible !important', - textTransform: 'uppercase', borderBottom: 'none !important', + '&[data-field="apiKey"]': { + px: 0, + }, }, '& .MuiDataGrid-row': { borderTop: '1px solid rgba(255, 255, 255, 0.04)', @@ -279,14 +311,14 @@ const ApiKeysTable: FC = ({ data }) => { }, }} /> - setEditingItem('')} exchangeName={editingItem} /> - setDeletingItem('')} exchangeName={deletingItem} /> diff --git a/campaign-launcher/client/src/components/BaseDrawer/index.tsx b/campaign-launcher/client/src/components/BaseDrawer/index.tsx new file mode 100644 index 000000000..86d56ab62 --- /dev/null +++ b/campaign-launcher/client/src/components/BaseDrawer/index.tsx @@ -0,0 +1,71 @@ +import { useCallback, type FC, type PropsWithChildren } from 'react'; + +import { Drawer, IconButton, type SxProps, type Theme } from '@mui/material'; + +import { CloseIcon } from '@/icons'; + +type Props = { + open: boolean; + onClose: () => void; + isLoading?: boolean; + sx?: SxProps; + closeButtonSx?: SxProps; +}; + +const BaseDrawer: FC> = ({ + open, + onClose, + isLoading = false, + sx, + closeButtonSx, + children, +}) => { + const handleClose = useCallback(() => { + if (isLoading) return; + onClose(); + }, [isLoading, onClose]); + + return ( + + + + + {children} + + ); +}; + +export default BaseDrawer; diff --git a/campaign-launcher/client/src/components/CampaignAddress/index.tsx b/campaign-launcher/client/src/components/CampaignAddress/index.tsx index 77c1fc70a..077c35afc 100644 --- a/campaign-launcher/client/src/components/CampaignAddress/index.tsx +++ b/campaign-launcher/client/src/components/CampaignAddress/index.tsx @@ -1,46 +1,44 @@ import { type FC, type MouseEvent, useEffect, useRef, useState } from 'react'; import type { ChainId } from '@human-protocol/sdk'; -import ContentCopyIcon from '@mui/icons-material/ContentCopy'; -import { - IconButton, - Tooltip, - Typography, - type TypographyProps, -} from '@mui/material'; +import { Box, IconButton, Link, Tooltip } from '@mui/material'; -import { OpenInNewIcon } from '@/icons'; +import { CopyIcon } from '@/icons'; import { formatAddress, getExplorerUrl } from '@/utils'; type Props = { address: string; chainId: ChainId; withCopy?: boolean; - variant?: TypographyProps['variant']; + size?: 'small' | 'medium' | 'large'; }; -const iconButtonSx = { - color: 'text.primary', - p: 0, - zIndex: 1, - '&:hover': { background: 'none' }, -}; - -const handleOpenInExplorerClick = ( - e: MouseEvent, - chainId: ChainId, - address: string -) => { - e.stopPropagation(); - const explorerUrl = getExplorerUrl(chainId, address); - window.open(explorerUrl, '_blank'); +const AddressLink: FC = ({ address, chainId, size }) => { + return ( + + {formatAddress(address)} + + ); }; const CampaignAddress: FC = ({ address, chainId, withCopy = false, - variant = 'subtitle2', + size = 'medium', }) => { const [isCopied, setIsCopied] = useState(false); const timeoutRef = useRef(null); @@ -69,28 +67,29 @@ const CampaignAddress: FC = ({ }, 1500); }; - return ( - - {formatAddress(address)} - {withCopy && ( + if (withCopy) { + return ( + + - + - )} - handleOpenInExplorerClick(e, chainId, address)} - sx={iconButtonSx} - > - - - - ); + + ); + } + + return ; }; export default CampaignAddress; diff --git a/campaign-launcher/client/src/components/CampaignSymbol/index.tsx b/campaign-launcher/client/src/components/CampaignSymbol/index.tsx index 65080c177..31a53ca02 100644 --- a/campaign-launcher/client/src/components/CampaignSymbol/index.tsx +++ b/campaign-launcher/client/src/components/CampaignSymbol/index.tsx @@ -15,29 +15,29 @@ export const getSymbolStyles = (size: 'xs' | 'small' | 'medium' | 'large') => { case 'xs': return { image: { - width: 16, + size: 16, border: '1px solid white', }, text: { - fontWeight: 700, - fontSize: 12, + fontWeight: 500, + fontSize: 16, }, }; case 'small': return { image: { - width: 24, + size: 22, border: '1px solid white', }, text: { - fontWeight: 400, - fontSize: 16, + fontWeight: 600, + fontSize: 20, }, }; case 'medium': return { image: { - width: 32, + size: 32, border: '1px solid white', }, text: { @@ -48,24 +48,24 @@ export const getSymbolStyles = (size: 'xs' | 'small' | 'medium' | 'large') => { case 'large': return { image: { - width: 72, + size: 32, border: '2px solid white', }, text: { fontWeight: 800, - fontSize: 30, - lineHeight: '35px', + fontSize: 32, + lineHeight: '36px', }, }; default: return { image: { - width: 24, + size: 22, border: '1px solid white', }, text: { - fontWeight: 400, - fontSize: 16, + fontWeight: 600, + fontSize: 20, }, }; } diff --git a/campaign-launcher/client/src/components/CampaignsTable/index.tsx b/campaign-launcher/client/src/components/CampaignsTable/index.tsx index 29814ea87..378a50510 100644 --- a/campaign-launcher/client/src/components/CampaignsTable/index.tsx +++ b/campaign-launcher/client/src/components/CampaignsTable/index.tsx @@ -74,7 +74,7 @@ const MyCampaignsNoRows: FC = () => { At the moment you are not running any campaign. - + ); }; @@ -340,7 +340,6 @@ const CampaignsTable: FC = ({ minWidth: 175, renderCell: (params) => ( diff --git a/campaign-launcher/client/src/components/ConnectWallet/index.tsx b/campaign-launcher/client/src/components/ConnectWallet/index.tsx index 7ace64f22..6c2384bc7 100644 --- a/campaign-launcher/client/src/components/ConnectWallet/index.tsx +++ b/campaign-launcher/client/src/components/ConnectWallet/index.tsx @@ -1,7 +1,14 @@ import { type FC, useState, type MouseEvent } from 'react'; import CloseIcon from '@mui/icons-material/Close'; -import { Button, Popover, Box, Typography, IconButton } from '@mui/material'; +import { + Button, + Box, + Grid, + IconButton, + Popover, + Typography, +} from '@mui/material'; import { useConnect, useConnectors, @@ -9,28 +16,26 @@ import { type Connector, } from 'wagmi'; -import coinbaseSvg from '@/assets/coinbase.svg'; -import metaMaskSvg from '@/assets/metamask.svg'; -import walletConnectSvg from '@/assets/walletconnect.svg'; -import BaseModal from '@/components/modals/BaseModal'; +import coinbasePng from '@/assets/coinbase.png'; +import metaMaskPng from '@/assets/metamask.png'; +import walletConnectPng from '@/assets/walletConnect.png'; +import BaseDrawer from '@/components/BaseDrawer'; import { useIsMobile } from '@/hooks/useBreakpoints'; +import { ConnectWalletIcon } from '@/icons'; import { useActiveAccount } from '@/providers/ActiveAccountProvider'; import { useWeb3Auth } from '@/providers/Web3AuthProvider'; const WALLET_ICONS: Record = { - metaMask: metaMaskSvg, - coinbaseWalletSDK: coinbaseSvg, - walletConnect: walletConnectSvg, + metaMaskSDK: metaMaskPng, + coinbaseWalletSDK: coinbasePng, + walletConnect: walletConnectPng, }; -const ConnectWallet: FC = () => { - const [anchorEl, setAnchorEl] = useState(null); - - const connect = useConnect(); +const Content: FC<{ onClose: () => void }> = ({ onClose }) => { const connectors = useConnectors(); - const { isConnecting } = useActiveAccount(); - const { setShowSignInPrompt } = useWeb3Auth(); + const connect = useConnect(); const disconnect = useDisconnect(); + const { setShowSignInPrompt } = useWeb3Auth(); const isMobile = useIsMobile(); const handleConnect = async (connector: Connector) => { @@ -50,79 +55,90 @@ const ConnectWallet: FC = () => { } }; + return ( + + {connectors.map((connector) => ( + + + + ))} + + ); +}; + +type Props = { + size?: 'small' | 'medium' | 'large'; + handleClickCallback?: () => void; +}; + +const ConnectWallet: FC = ({ size = 'large', handleClickCallback }) => { + const [anchorEl, setAnchorEl] = useState(null); + + const { isConnecting } = useActiveAccount(); + const isMobile = useIsMobile(); + const handleConnectWalletButtonClick = (e: MouseEvent) => { setAnchorEl(e.currentTarget); + handleClickCallback?.(); }; const onClose = () => setAnchorEl(null); - const renderContent = () => { - return ( - <> - - {connectors.map((connector) => ( - - ))} - - - By connecting a wallet, you agree to HUMAN Protocol Terms of Service - and consent to its Privacy Policy. - - - ); - }; - return ( <> {isMobile ? ( - - {renderContent()} - + + + Connect Wallet + + + Connect your wallet to create, participate in campaigns and even + track your performance on the leaderboard. + + + ) : ( { boxShadow: '0px 0px 10px 0px rgba(255, 255, 255, 0.15)', borderRadius: '10px', p: 2, - width: '320px', + width: '380px', }, }, }} @@ -157,7 +173,7 @@ const ConnectWallet: FC = () => { - {renderContent()} + )} diff --git a/campaign-launcher/client/src/components/Container/index.tsx b/campaign-launcher/client/src/components/Container/index.tsx index be8bc7b44..79b3f7f09 100644 --- a/campaign-launcher/client/src/components/Container/index.tsx +++ b/campaign-launcher/client/src/components/Container/index.tsx @@ -8,6 +8,7 @@ const Container: FC> = ({ sx, children }) => { maxWidth={false} sx={{ width: '100%', + maxWidth: '1280px', px: { xs: 0, sm: 0, md: 4, lg: 5, xl: 6, xxl: 7 }, ...sx, }} diff --git a/campaign-launcher/client/src/components/CryptoEntity/index.tsx b/campaign-launcher/client/src/components/CryptoEntity/index.tsx index e1f5bb8d6..b03f45887 100644 --- a/campaign-launcher/client/src/components/CryptoEntity/index.tsx +++ b/campaign-launcher/client/src/components/CryptoEntity/index.tsx @@ -20,12 +20,13 @@ const CryptoEntity: FC = ({ symbol, size = 'small' }) => { component="img" src={icon} alt={label} - width={getSymbolStyles(size).image.width} + width={getSymbolStyles(size).image.size} + height={getSymbolStyles(size).image.size} border={getSymbolStyles(size).image.border} borderRadius="100%" /> )} - + {label} diff --git a/campaign-launcher/client/src/components/CryptoPairEntity/index.tsx b/campaign-launcher/client/src/components/CryptoPairEntity/index.tsx index 4238a97b9..64a7b3111 100644 --- a/campaign-launcher/client/src/components/CryptoPairEntity/index.tsx +++ b/campaign-launcher/client/src/components/CryptoPairEntity/index.tsx @@ -26,31 +26,25 @@ const CryptoPairEntity: FC = ({ symbol, size = 'small' }) => { component="img" src={baseIcon} alt={baseLabel} + width={getSymbolStyles(size).image.size} + height={getSymbolStyles(size).image.size} + border={getSymbolStyles(size).image.border} borderRadius="100%" - {...getSymbolStyles(size).image} /> )} - - {isLarge ? ( - <> - {baseLabel ?? base} -
- {quoteLabel ?? quote} - - ) : ( - <> - {baseLabel ?? base}/{quoteLabel ?? quote} - - )} + + {baseLabel ?? base}/{quoteLabel ?? quote}
); diff --git a/campaign-launcher/client/src/components/DeleteApiKeyDialog/index.tsx b/campaign-launcher/client/src/components/DeleteApiKeyDialog/index.tsx new file mode 100644 index 000000000..9bcb81636 --- /dev/null +++ b/campaign-launcher/client/src/components/DeleteApiKeyDialog/index.tsx @@ -0,0 +1,130 @@ +import { type FC } from 'react'; + +import { Button, Stack, Typography } from '@mui/material'; + +import { useDeleteApiKeyByExchange } from '@/hooks/recording-oracle/exchangeApiKeys'; +import { useIsMobile } from '@/hooks/useBreakpoints'; +import { WarningIcon } from '@/icons'; + +import { ModalError, ModalLoading, ModalSuccess } from '../ModalState'; +import ResponsiveOverlay from '../ResponsiveOverlay'; + +type Props = { + open: boolean; + onClose: () => void; + exchangeName: string; +}; + +const DeleteApiKeyDialog: FC = ({ open, onClose, exchangeName }) => { + const { + mutate: deleteApiKey, + reset: resetMutation, + isPending, + isIdle, + isSuccess, + isError, + } = useDeleteApiKeyByExchange(exchangeName); + + const isMobile = useIsMobile(); + + const handleDelete = () => { + deleteApiKey(); + }; + + const handleClose = () => { + if (isPending) return; + resetMutation(); + onClose(); + }; + + return ( + + + + + Delete API key? + + {isPending && } + {isIdle && ( + <> + + You are about to delete an API KEY for{' '} + + {exchangeName} + + . This action can't be undone and will end your participation + in related campaigns. +
+ You can update it instead. +
+ + Do you want to continue? + + + )} + {isSuccess && ( + + + You have successfully deleted your API KEY + + + )} + {isError && } + + + {isIdle && ( + + )} + +
+
+ ); +}; + +export default DeleteApiKeyDialog; diff --git a/campaign-launcher/client/src/components/EditApiKeyDialog/index.tsx b/campaign-launcher/client/src/components/EditApiKeyDialog/index.tsx new file mode 100644 index 000000000..f3cb3acfd --- /dev/null +++ b/campaign-launcher/client/src/components/EditApiKeyDialog/index.tsx @@ -0,0 +1,353 @@ +import { useEffect, type FC } from 'react'; + +import { yupResolver } from '@hookform/resolvers/yup'; +import { + Button, + FormControl, + FormHelperText, + Stack, + TextField, + Typography, +} from '@mui/material'; +import { Controller, useForm } from 'react-hook-form'; + +import { + type APIKeyFormValues, + validationSchema, +} from '@/components/AddApiKeyDialog'; +import FormExchangeSelect from '@/components/FormExchangeSelect'; +import { + ModalError, + ModalLoading, + ModalSuccess, +} from '@/components/ModalState'; +import ResponsiveOverlay from '@/components/ResponsiveOverlay'; +import { usePostExchangeApiKey } from '@/hooks/recording-oracle'; +import { useIsMobile } from '@/hooks/useBreakpoints'; +import { ExchangeType } from '@/types'; +import { scrollToFirstErrorFieldOnMobile } from '@/utils'; + +type Props = { + open: boolean; + exchangeName: string; + onClose: () => void; +}; + +const EditApiKeyDialog: FC = ({ open, exchangeName, onClose }) => { + const { + mutate: postExchangeApiKey, + reset: resetMutation, + error: postExchangeApiKeyError, + isIdle, + isPending, + isSuccess, + isError, + } = usePostExchangeApiKey(); + const { + control, + formState: { errors }, + handleSubmit, + reset, + watch, + } = useForm({ + mode: 'onBlur', + resolver: yupResolver(validationSchema), + defaultValues: { + exchange: '', + apiKey: '', + secret: '', + memo: '', + }, + }); + + const isMobile = useIsMobile(); + const [apiKeyValue, secretValue, exchange, memoValue] = watch([ + 'apiKey', + 'secret', + 'exchange', + 'memo', + ]); + + const isBitmart = exchange === 'bitmart'; + const isSaveDisabled = + !apiKeyValue?.trim() || + !secretValue?.trim() || + (isBitmart && !memoValue?.trim()); + + useEffect(() => { + if (open && exchangeName) { + reset({ + exchange: exchangeName, + apiKey: '', + secret: '', + memo: '', + }); + } + }, [open, exchangeName, reset]); + + useEffect(() => { + scrollToFirstErrorFieldOnMobile(isMobile, errors); + }, [isMobile, errors]); + + const handleClose = () => { + if (isPending) return; + reset(); + resetMutation(); + onClose(); + }; + + const onSubmit = (values: APIKeyFormValues) => { + postExchangeApiKey({ + exchangeName: values.exchange, + apiKey: values.apiKey, + secret: values.secret, + extras: isBitmart ? { api_key_memo: values.memo || '' } : undefined, + }); + }; + + return ( + + + + + Edit API KEY + + + + {isPending && ( + + + + Editing API key... + + + )} + {isIdle && ( + + + + Exchange + + ( + + field={field} + disabled + exchangeTypes={[ExchangeType.CEX]} + /> + )} + /> + {errors.exchange && ( + {errors.exchange.message} + )} + + + + API Key + + ( + + )} + /> + {errors.apiKey && ( + {errors.apiKey.message} + )} + + + + Secret + + ( + + )} + /> + {errors.secret && ( + {errors.secret.message} + )} + + {isBitmart && ( + + + Memo + + ( + + )} + /> + {errors.memo && ( + {errors.memo.message} + )} + + )} + + )} + {isSuccess && ( + + + + You have successfully edited your API KEY + + + + )} + {isError && ( + + + + )} + + + {isIdle && ( + + )} + {(isPending || isSuccess) && ( + + )} + {isError && ( + + )} + + + + ); +}; + +export default EditApiKeyDialog; diff --git a/campaign-launcher/client/src/components/ExchangeSelect/index.tsx b/campaign-launcher/client/src/components/ExchangeSelect/index.tsx deleted file mode 100644 index 7588bb4fd..000000000 --- a/campaign-launcher/client/src/components/ExchangeSelect/index.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import type { FC } from 'react'; - -import { FormControl, InputLabel, MenuItem, Select } from '@mui/material'; - -import type { Exchange } from '@/types'; - -type Props = { - data: Exchange[] | undefined; - onChange: (exchange: string) => void; -}; - -const ExchangeSelect: FC = ({ data, onChange }) => { - return ( - - Sort by Exchange - - - ); -}; - -export default ExchangeSelect; diff --git a/campaign-launcher/client/src/components/Footer/index.tsx b/campaign-launcher/client/src/components/Footer/index.tsx index efed57b7d..08da2b115 100644 --- a/campaign-launcher/client/src/components/Footer/index.tsx +++ b/campaign-launcher/client/src/components/Footer/index.tsx @@ -3,9 +3,10 @@ import type { FC } from 'react'; import GitHubIcon from '@mui/icons-material/GitHub'; import TelegramIcon from '@mui/icons-material/Telegram'; import XIcon from '@mui/icons-material/X'; -import { Box, IconButton, styled, Typography } from '@mui/material'; +import { Box, IconButton, Stack, styled, Typography } from '@mui/material'; import Container from '@/components/Container'; +import { MOBILE_BOTTOM_NAV_HEIGHT } from '@/constants'; const SocialMediaIconButton = styled(IconButton)(({ theme }) => ({ padding: 0, @@ -27,18 +28,22 @@ const handleClickOnSocialButton = (url: string) => { window.open(url, '_blank'); }; -const Footer: FC = () => { +const Footer: FC<{ reserveBottomOffset: boolean }> = ({ + reserveBottomOffset, +}) => { return ( - + - © {new Date().getFullYear()} HuFi powered by HUMAN Protocol @@ -48,7 +53,7 @@ const Footer: FC = () => { alignItems="center" gap="30px" width={{ xs: '100%', md: 'auto' }} - justifyContent={{ xs: 'flex-start', md: 'center' }} + justifyContent="center" > { - + ); diff --git a/campaign-launcher/client/src/components/FormExchangeSelect/index.tsx b/campaign-launcher/client/src/components/FormExchangeSelect/index.tsx index f788ac8b1..712759a7d 100644 --- a/campaign-launcher/client/src/components/FormExchangeSelect/index.tsx +++ b/campaign-launcher/client/src/components/FormExchangeSelect/index.tsx @@ -69,7 +69,12 @@ const FormExchangeSelect = < return exchange?.display_name || option || ''; }} renderInput={(params) => ( - + )} renderOption={(props, option) => { const exchange = exchangesMap.get(option); diff --git a/campaign-launcher/client/src/components/Header/index.tsx b/campaign-launcher/client/src/components/Header/index.tsx index 36a798771..b9c5780c7 100644 --- a/campaign-launcher/client/src/components/Header/index.tsx +++ b/campaign-launcher/client/src/components/Header/index.tsx @@ -10,7 +10,6 @@ import { Stack, type SxProps, Toolbar, - Typography, } from '@mui/material'; import { Link, useLocation } from 'react-router'; import { useConnection } from 'wagmi'; @@ -19,48 +18,63 @@ import logo from '@/assets/logo.svg'; import Account from '@/components/Account'; import ConnectWallet from '@/components/ConnectWallet'; import Container from '@/components/Container'; -import CustomTooltip from '@/components/CustomTooltip'; -import InfoTooltipInner from '@/components/InfoTooltipInner'; -import LaunchCampaignButton from '@/components/LaunchCampaignButton'; -import NetworkSwitcher from '@/components/NetworkSwitcher'; import { ROUTES } from '@/constants'; import { useIsMobile } from '@/hooks/useBreakpoints'; +import { OpenInNewIcon } from '@/icons'; import { useActiveAccount } from '@/providers/ActiveAccountProvider'; -import { useSignerContext } from '@/providers/SignerProvider'; type StyledLinkProps = { to: string; text: string; + isActive?: boolean; + isExternal?: boolean; sx?: SxProps; target?: string; onClick?: () => void; }; -const StyledLink = ({ to, text, sx, target, onClick }: StyledLinkProps) => { +const StyledLink = ({ + to, + text, + isActive, + isExternal, + sx, + onClick, +}: StyledLinkProps) => { return ( {text} + {isExternal && } ); }; const DOCS_URL = import.meta.env.VITE_APP_DOCS_URL; const STAKING_DASHBOARD_URL = import.meta.env.VITE_APP_STAKING_DASHBOARD_URL; -const LAUNCH_CAMPAIGN_TOOLTIP = - "You'll need to connect your wallet before launching a campaign"; const Header: FC = () => { const [anchorEl, setAnchorEl] = useState(null); @@ -68,7 +82,6 @@ const Header: FC = () => { const { pathname } = useLocation(); const { activeAddress } = useActiveAccount(); const { isConnected } = useConnection(); - const { isSignerReady } = useSignerContext(); const isMobile = useIsMobile(); const handleMenuOpen = useCallback( @@ -99,12 +112,13 @@ const Header: FC = () => { position="static" elevation={0} sx={{ - position: { xs: 'sticky', sm: 'static' }, + position: 'sticky', top: 0, zIndex: (theme) => theme.zIndex.appBar, bgcolor: 'background.default', boxShadow: 'none', width: '100%', + borderBottom: '1px solid #433679', '& .MuiToolbar-root': { px: { xs: 2, md: 0 }, }, @@ -144,25 +158,32 @@ const Header: FC = () => { - - + + + - - - {activeAddress && isConnected ? : } - - - - {activeAddress && isConnected ? : } + {activeAddress && isConnected ? ( + + ) : ( + + )} { }} > + - - - - {!isSignerReady && ( - - {LAUNCH_CAMPAIGN_TOOLTIP} - - } - slotProps={{ - tooltip: { - sx: { - width: '150px', - lineHeight: '14px', - mr: '12px !important', - }, - }, - }} - > - - - )} - diff --git a/campaign-launcher/client/src/components/JoinedCampaigns/index.tsx b/campaign-launcher/client/src/components/JoinedCampaigns/index.tsx index 931881011..b31782e3c 100644 --- a/campaign-launcher/client/src/components/JoinedCampaigns/index.tsx +++ b/campaign-launcher/client/src/components/JoinedCampaigns/index.tsx @@ -5,7 +5,7 @@ import { useNavigate } from 'react-router'; import CampaignsTable from '@/components/CampaignsTable'; import CampaignsTablePagination from '@/components/CampaignsTablePagination'; -import { useGetJoinedCampaigns } from '@/hooks/recording-oracle'; +import { useJoinedCampaigns } from '@/hooks/recording-oracle'; import usePagination from '@/hooks/usePagination'; import { CampaignStatus } from '@/types'; import { filterFalsyQueryParams } from '@/utils'; @@ -33,7 +33,7 @@ const JoinedCampaigns: FC = ({ skip, }); - const { data, isLoading, isFetching } = useGetJoinedCampaigns(queryParams); + const { data, isLoading, isFetching } = useJoinedCampaigns(queryParams); const onViewAllClick = () => { navigate('/?view=joined'); diff --git a/campaign-launcher/client/src/components/LaunchCampaignButton/index.tsx b/campaign-launcher/client/src/components/LaunchCampaignButton/index.tsx index 5e03362df..1dd30c318 100644 --- a/campaign-launcher/client/src/components/LaunchCampaignButton/index.tsx +++ b/campaign-launcher/client/src/components/LaunchCampaignButton/index.tsx @@ -1,106 +1,42 @@ -import { type FC, type PropsWithChildren, useState } from 'react'; +import { type FC } from 'react'; -import { Box, Button, Tooltip, Typography, type SxProps } from '@mui/material'; +import { Button, type SxProps } from '@mui/material'; import { useNavigate } from 'react-router'; -import StakingRequirementModal from '@/components/modals/StakingRequirementModal'; import { ROUTES } from '@/constants'; -import { useIsXlDesktop } from '@/hooks/useBreakpoints'; -import { useStakeContext } from '@/providers/StakeProvider'; - -type ButtonWrapperProps = { - isDisabled: boolean; - withTooltip: boolean; -}; - -const ButtonWrapper: FC> = ({ - isDisabled, - withTooltip, - children, -}) => { - if (isDisabled && withTooltip) { - return ( - - You'll need to connect your wallet before launching a campaign - - } - slotProps={{ - tooltip: { - sx: { - width: '150px', - lineHeight: '14px', - }, - }, - }} - arrow - placement="left" - > - {children} - - ); - } - - return children; -}; type Props = { - variant: 'outlined' | 'contained'; + size?: 'small' | 'medium' | 'large'; sx?: SxProps; - withTooltip?: boolean; handleCallbackOnClick?: () => void; }; const LaunchCampaignButton: FC = ({ - variant, + size = 'medium', sx, - withTooltip = false, handleCallbackOnClick, }) => { - const [isSetupModalOpen, setIsSetupModalOpen] = useState(false); - const navigate = useNavigate(); - const isXl = useIsXlDesktop(); - const { fetchStakingData, isClientReady } = useStakeContext(); - - const isDisabled = !isClientReady; const handleLaunchCampaignClick = async () => { - if (isDisabled) return null; - - const _stakedAmount = Number(await fetchStakingData()); - if (_stakedAmount === 0) { - setIsSetupModalOpen(true); - } else { - navigate(ROUTES.LAUNCH_CAMPAIGN); - } - + navigate(ROUTES.LAUNCH_CAMPAIGN); handleCallbackOnClick?.(); }; return ( - <> - - - - setIsSetupModalOpen(false)} - /> - + ); }; diff --git a/campaign-launcher/client/src/components/LaunchCampaignForm/components/ApprovalStep.tsx b/campaign-launcher/client/src/components/LaunchCampaignForm/components/ApprovalStep.tsx new file mode 100644 index 000000000..0c56d355d --- /dev/null +++ b/campaign-launcher/client/src/components/LaunchCampaignForm/components/ApprovalStep.tsx @@ -0,0 +1,466 @@ +import { type SetStateAction, type Dispatch, useEffect, type FC } from 'react'; + +import { yupResolver } from '@hookform/resolvers/yup'; +import { + Box, + CircularProgress, + Divider, + FormControl, + FormControlLabel, + FormHelperText, + InputAdornment, + Radio, + RadioGroup, + Stack, + TextField, + Typography, +} from '@mui/material'; +import { Controller, useForm } from 'react-hook-form'; + +import CustomTooltip from '@/components/CustomTooltip'; +import InfoTooltipInner from '@/components/InfoTooltipInner'; +import { UNLIMITED_AMOUNT } from '@/constants'; +import { useIsMobile } from '@/hooks/useBreakpoints'; +import { useNotification } from '@/hooks/useNotification'; +import { useTokenAllowance } from '@/hooks/useTokenAllowance'; +import { AllowanceType, type CampaignFormValues } from '@/types'; +import { getTokenInfo, isExceedingMaximumInteger } from '@/utils'; + +import { formatInputValue } from '../utils'; +import { createFundAmountValidationSchema } from '../validation'; + +import { BottomNavigation } from './'; + +const allowanceTooltipText = { + unlimited: + 'Approve unlimited token amount so you don’t need to approve again for future escrows.', + custom: + 'You can change this allowance later by sending another approve transaction.', +} as const; + +const AllowanceTooltip = ({ type }: { type: AllowanceType }) => { + const isMobile = useIsMobile(); + return ( + + {allowanceTooltipText[type]} + + } + > + + + ); +}; + +type Props = { + fundAmount: string; + setFundAmount: (amount: string) => void; + formValues: CampaignFormValues; + handleChangeStep: Dispatch>; +}; + +const ApprovalStep: FC = ({ + fundAmount, + setFundAmount, + formValues, + handleChangeStep, +}) => { + const { fund_token: fundToken } = formValues; + const isMobile = useIsMobile(); + const formId = 'approval-form'; + + const { + control, + formState: { errors, dirtyFields }, + handleSubmit, + watch, + setValue, + } = useForm<{ + fund_amount: string; + selected_allowance: AllowanceType; + custom_allowance_amount: string; + }>({ + mode: isMobile ? 'onSubmit' : 'onChange', + resolver: yupResolver( + createFundAmountValidationSchema( + formValues.start_date, + formValues.end_date, + formValues.fund_token + ) + ), + defaultValues: { + fund_amount: fundAmount, + selected_allowance: AllowanceType.CUSTOM, + custom_allowance_amount: '', + }, + shouldFocusError: isMobile, + }); + + const { selected_allowance, custom_allowance_amount } = watch(); + + const { + approve, + fetchAllowance, + allowance: currentAllowance, + isApproving, + isLoading, + resetApproval, + } = useTokenAllowance(); + + const { showError } = useNotification(); + + useEffect(() => { + if ( + !dirtyFields.selected_allowance && + !isLoading && + currentAllowance === UNLIMITED_AMOUNT + ) { + setValue('selected_allowance', AllowanceType.UNLIMITED); + } + }, [currentAllowance, dirtyFields, setValue, isLoading]); + + useEffect(() => { + fetchAllowance(fundToken); + }, [fundToken, fetchAllowance]); + + useEffect(() => { + if (isMobile && errors.fund_amount) { + const errorElement = document.querySelector( + '[name="fund_amount"]' + ) as HTMLElement; + errorElement?.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + } + }, [isMobile, errors]); + + const handleBackClick = () => { + handleChangeStep((prev) => prev - 1); + }; + + const handleNextClick = () => { + handleChangeStep((prev) => prev + 1); + }; + + const onSubmit = async (data: { + fund_amount: string; + selected_allowance: AllowanceType; + custom_allowance_amount: string; + }) => { + const { fund_amount, selected_allowance, custom_allowance_amount } = data; + let canSkipApproval = false; + + if (!custom_allowance_amount) { + canSkipApproval = + (selected_allowance === AllowanceType.UNLIMITED && + currentAllowance === UNLIMITED_AMOUNT) || + (selected_allowance === AllowanceType.CUSTOM && + (currentAllowance === UNLIMITED_AMOUNT || + Number(currentAllowance) >= Number(fund_amount))); + } + + if (canSkipApproval) { + setFundAmount(fund_amount); + handleNextClick(); + return; + } + + const amountToApprove = + selected_allowance === AllowanceType.UNLIMITED + ? UNLIMITED_AMOUNT + : custom_allowance_amount || fund_amount; + + const isApproved = await approve(fundToken, amountToApprove); + + if (isApproved) { + setFundAmount(fund_amount); + handleNextClick(); + return; + } else { + resetApproval(); + showError('Failed to approve tokens, please try again'); + return; + } + }; + + const inputAdornmentLabel = getTokenInfo(fundToken)?.label || ''; + + const hasValidationErrors = Object.keys(errors).length > 0; + + return ( + <> + +
+ + + + + Campaign Fund Amount + + + ( + { + const value = formatInputValue(e.target.value); + if (isExceedingMaximumInteger(value)) { + return; + } + field.onChange(value); + }} + slotProps={{ + input: { + endAdornment: ( + + {inputAdornmentLabel} + + ), + }, + }} + /> + )} + /> + {errors.fund_amount && ( + + {errors.fund_amount.message} + + )} + + Current allowance: + + {currentAllowance === UNLIMITED_AMOUNT + ? 'Unlimited' + : `${currentAllowance ?? 0} ${fundToken.toUpperCase()}`} + + + + + + + + + Token Approval + + {(isLoading || isApproving) && } + + + ( + { + field.onChange(e); + if (e.target.value === AllowanceType.UNLIMITED) { + setValue('custom_allowance_amount', ''); + } + }} + sx={{ + display: 'flex', + flexDirection: { xs: 'column', md: 'row' }, + alignItems: 'flex-start', + gap: 3, + }} + > + + + } + label="Limited Allowance" + sx={{ + mr: 0, + '& .MuiRadio-root, & .MuiTypography-root': { + color: + selected_allowance === + AllowanceType.CUSTOM && !isApproving + ? 'white' + : '#8b8b8b', + }, + }} + /> + + + + ( + { + const rawValue = e.target.value; + if (rawValue === '') { + field.onChange(''); + return; + } + const value = formatInputValue(rawValue); + if (isExceedingMaximumInteger(value)) { + return; + } + field.onChange(value); + }} + slotProps={{ + htmlInput: { + min: 0, + sx: { + fieldSizing: 'content', + maxWidth: '12ch', + minWidth: '1ch', + width: 'unset', + }, + }, + input: { + endAdornment: custom_allowance_amount ? ( + + + {inputAdornmentLabel} + + + ) : null, + }, + }} + /> + )} + /> + {errors.custom_allowance_amount && + selected_allowance === AllowanceType.CUSTOM && ( + + {errors.custom_allowance_amount.message} + + )} + + + + } + label="Unlimited Approval" + sx={{ + mr: 0, + '& .MuiRadio-root, & .MuiTypography-root': { + color: + selected_allowance === + AllowanceType.UNLIMITED && !isApproving + ? 'white' + : '#8b8b8b', + }, + }} + /> + + + + )} + /> + + + + +
+
+ {}} + handleBackClick={handleBackClick} + /> + + ); +}; + +export default ApprovalStep; diff --git a/campaign-launcher/client/src/components/LaunchCampaignForm/components/BottomNavigation.tsx b/campaign-launcher/client/src/components/LaunchCampaignForm/components/BottomNavigation.tsx index 8899e30fb..515c6937d 100644 --- a/campaign-launcher/client/src/components/LaunchCampaignForm/components/BottomNavigation.tsx +++ b/campaign-launcher/client/src/components/LaunchCampaignForm/components/BottomNavigation.tsx @@ -1,32 +1,80 @@ -import { type FC } from 'react'; +import { type FC, type PropsWithChildren } from 'react'; import { Box, Button, Stack } from '@mui/material'; +import { MOBILE_BOTTOM_NAV_HEIGHT } from '@/constants'; import { useIsMobile } from '@/hooks/useBreakpoints'; -import { StepsIndicator } from './'; - type Props = { - step: number; handleNextClick: () => void; - disableNextButton?: boolean; + handleBackClick?: () => void; disableBackButton?: boolean; + disableNextButton?: boolean; nextButtonText?: string; - nextButtonType?: 'button' | 'submit'; - handleBackClick?: () => void; + formId?: string; +}; + +const Wrapper = ({ + isMobile, + children, +}: { + isMobile: boolean; + children: React.ReactNode; +}) => { + if (isMobile) { + return ( + theme.zIndex.appBar, + }} + > + {children} + + ); + } + return ( + + {children} + + ); }; -const BottomNavigation: FC = ({ - step, +const BottomNavigation: FC> = ({ handleNextClick, disableNextButton = false, disableBackButton = false, nextButtonText = 'Next', - nextButtonType = 'button', + formId, handleBackClick, + children, }) => { const isMobile = useIsMobile(); + const showBackButton = !!handleBackClick; + const onBackClick = () => { if (disableBackButton) return; handleBackClick?.(); @@ -38,44 +86,43 @@ const BottomNavigation: FC = ({ }; return ( - - {!isMobile && } + + {children} - {step > 1 && ( + {showBackButton && ( )} - + ); }; diff --git a/campaign-launcher/client/src/components/LaunchCampaignForm/components/CampaignTypeStep.tsx b/campaign-launcher/client/src/components/LaunchCampaignForm/components/CampaignTypeStep.tsx new file mode 100644 index 000000000..453403739 --- /dev/null +++ b/campaign-launcher/client/src/components/LaunchCampaignForm/components/CampaignTypeStep.tsx @@ -0,0 +1,100 @@ +import { type SetStateAction, type Dispatch, type FC } from 'react'; + +import StarIcon from '@mui/icons-material/Star'; +import { Box, Grid, Paper, Stack, Typography } from '@mui/material'; + +import { + type CampaignFormValues, + CampaignType, + CampaignTypeNames, +} from '@/types'; + +import { getFormDefaultValues } from '../utils'; + +import { BottomNavigation } from '.'; + +type Props = { + formValues: CampaignFormValues | null; + setFormValues: (values: CampaignFormValues) => void; + handleChangeStep: Dispatch>; +}; + +const CAMPAIGN_TYPE_DESCRIPTIONS = { + [CampaignType.MARKET_MAKING]: + 'Allows you to generate trading activity on a pair.', + [CampaignType.HOLDING]: + 'Requires market makers to collectively maintain a specified amount of a particular token.', + [CampaignType.THRESHOLD]: + 'Requires market makers to maintain a minimum balance of a specified token.', +}; + +const CampaignTypeStep: FC = ({ + formValues, + setFormValues, + handleChangeStep, +}) => { + const handleChangeCampaignType = (type: CampaignType) => { + const defaultValues = getFormDefaultValues(type); + + setFormValues({ + ...defaultValues, + type, + } as CampaignFormValues); + }; + + return ( + <> + + + {Object.values(CampaignType).map((type) => { + const isSelected = formValues?.type === type; + return ( + + handleChangeCampaignType(type)} + > + + + + {CampaignTypeNames[type]} + + + + {CAMPAIGN_TYPE_DESCRIPTIONS[type]} + + + + ); + })} + + + handleChangeStep((prev) => prev - 1)} + handleNextClick={() => handleChangeStep((prev) => prev + 1)} + disableNextButton={!formValues?.type} + /> + + ); +}; + +export default CampaignTypeStep; diff --git a/campaign-launcher/client/src/components/LaunchCampaignForm/components/ErrorView.tsx b/campaign-launcher/client/src/components/LaunchCampaignForm/components/ErrorView.tsx index 41113ebe9..2bbee65ae 100644 --- a/campaign-launcher/client/src/components/LaunchCampaignForm/components/ErrorView.tsx +++ b/campaign-launcher/client/src/components/LaunchCampaignForm/components/ErrorView.tsx @@ -15,15 +15,16 @@ const ErrorView: FC = ({ onRetry }) => { return ( - + {!isMobile && ( + + )} ); }; diff --git a/campaign-launcher/client/src/components/LaunchCampaignForm/components/SecondStep.tsx b/campaign-launcher/client/src/components/LaunchCampaignForm/components/EscrowDetailsStep.tsx similarity index 77% rename from campaign-launcher/client/src/components/LaunchCampaignForm/components/SecondStep.tsx rename to campaign-launcher/client/src/components/LaunchCampaignForm/components/EscrowDetailsStep.tsx index a1bed195d..8bbe1a563 100644 --- a/campaign-launcher/client/src/components/LaunchCampaignForm/components/SecondStep.tsx +++ b/campaign-launcher/client/src/components/LaunchCampaignForm/components/EscrowDetailsStep.tsx @@ -1,4 +1,4 @@ -import { type FC, useEffect } from 'react'; +import { type Dispatch, type FC, type SetStateAction, useEffect } from 'react'; import { yupResolver } from '@hookform/resolvers/yup'; import { Stack } from '@mui/material'; @@ -17,6 +17,7 @@ import { type ThresholdFormValues, type HoldingFormValues, } from '@/types'; +import { scrollToFirstErrorFieldOnMobile } from '@/utils'; import { campaignValidationSchema } from '../validation'; @@ -25,20 +26,20 @@ import { ThresholdForm, HoldingForm, BottomNavigation, - SummaryCard, } from '.'; type Props = { formValues: CampaignFormValues; setFormValues: (values: CampaignFormValues) => void; - handleChangeStep: (step: number) => void; + handleChangeStep: Dispatch>; }; -const SecondStep: FC = ({ +const EscrowDetailsStep: FC = ({ formValues, setFormValues, handleChangeStep, }) => { + const formId = 'escrow-details-form'; const isMobile = useIsMobile(); const { @@ -54,33 +55,25 @@ const SecondStep: FC = ({ shouldFocusError: isMobile, }); - useEffect(() => { - if (isMobile && Object.keys(errors).length > 0) { - const firstErrorField = Object.keys(errors)[0]; - const errorElement = document.querySelector( - `[name="${firstErrorField}"]` - ) as HTMLElement; + const campaignType = formValues.type as CampaignType; - if (errorElement) { - errorElement.scrollIntoView({ - behavior: 'smooth', - block: 'center', - }); - } - } + useEffect(() => { + scrollToFirstErrorFieldOnMobile(isMobile, errors); }, [isMobile, errors]); - const campaignType = formValues.type as CampaignType; + const handleBackClick = () => { + handleChangeStep((prev) => prev - 1); + }; const onSubmit = async (data: CampaignFormValues) => { setFormValues(data); - handleChangeStep(3); + handleChangeStep((prev) => prev + 1); }; return ( <> - -
+ + = ({ /> )} - {!isMobile && } - {}} - handleBackClick={() => handleChangeStep(1)} - />
+ {}} + handleBackClick={handleBackClick} + /> ); }; -export default SecondStep; +export default EscrowDetailsStep; diff --git a/campaign-launcher/client/src/components/LaunchCampaignForm/components/ExchangeInfoTooltip.tsx b/campaign-launcher/client/src/components/LaunchCampaignForm/components/ExchangeInfoTooltip.tsx deleted file mode 100644 index e2dd71944..000000000 --- a/campaign-launcher/client/src/components/LaunchCampaignForm/components/ExchangeInfoTooltip.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Link, Typography } from '@mui/material'; - -import CustomTooltip from '@/components/CustomTooltip'; -import InfoTooltipInner from '@/components/InfoTooltipInner'; -import { useIsMobile } from '@/hooks/useBreakpoints'; - -const link = import.meta.env.VITE_REQUEST_EXCHANGE_FORM_URL; - -const ExchangeInfoTooltip = () => { - const isMobile = useIsMobile(); - return ( - - Can't find the exchange?
- Click the link below to submit a request.
- We'd love to hear from you!
- - Submit request - - - } - > - -
- ); -}; - -export default ExchangeInfoTooltip; diff --git a/campaign-launcher/client/src/components/LaunchCampaignForm/components/FinalView.tsx b/campaign-launcher/client/src/components/LaunchCampaignForm/components/FinalView.tsx index 2da092c57..6db4f023a 100644 --- a/campaign-launcher/client/src/components/LaunchCampaignForm/components/FinalView.tsx +++ b/campaign-launcher/client/src/components/LaunchCampaignForm/components/FinalView.tsx @@ -29,31 +29,29 @@ const FinalView: FC = ({ px={{ xs: 2, md: 3 }} > - + Congratulations! - + Your {mapTypeToLabel(campaignType)} campaign has been successfully launched.
Everything is set up and ready to go.
- - Click the buttons below to view the campaign details or launch another - campaign. - - - - + + + )} + {!isError && !isEscrowCreated && ( + <> + + {!isMobile && ( + + + + + )} + + )} + + + {isMobile && !isEscrowCreated && ( + + )} + {isMobile && isEscrowCreated && ( + navigate(ROUTES.DASHBOARD)} + nextButtonText="Back to Dashboard" + /> )} - + ); }; diff --git a/campaign-launcher/client/src/components/LaunchCampaignForm/components/MarketMakingForm.tsx b/campaign-launcher/client/src/components/LaunchCampaignForm/components/MarketMakingForm.tsx index 2cb9f6869..54ba99830 100644 --- a/campaign-launcher/client/src/components/LaunchCampaignForm/components/MarketMakingForm.tsx +++ b/campaign-launcher/client/src/components/LaunchCampaignForm/components/MarketMakingForm.tsx @@ -1,4 +1,4 @@ -import type { FC } from 'react'; +import type { FC, KeyboardEvent, WheelEvent } from 'react'; import { Autocomplete, @@ -7,8 +7,6 @@ import { FormControl, FormHelperText, InputAdornment, - inputBaseClasses, - InputLabel, MenuItem, Select, Stack, @@ -28,6 +26,7 @@ import { import CryptoEntity from '@/components/CryptoEntity'; import CryptoPairEntity from '@/components/CryptoPairEntity'; import FormExchangeSelect from '@/components/FormExchangeSelect'; +import { MAX_NUMBER_INPUT_LENGTH } from '@/constants'; import { FUND_TOKENS } from '@/constants/tokens'; import { useTradingPairs } from '@/hooks/useTradingPairs'; import type { CampaignType, MarketMakingFormValues } from '@/types'; @@ -35,8 +34,6 @@ import { getTokenInfo, isExceedingMaximumInteger } from '@/utils'; import { formatInputValue } from '../utils'; -import { ExchangeInfoTooltip } from './'; - type Props = { control: Control; errors: FieldErrors; @@ -45,6 +42,13 @@ type Props = { campaignType: CampaignType; }; +const labelStyles = { + color: 'white', + mb: 1.5, + lineHeight: '100%', + letterSpacing: '0px', +}; + const MarketMakingForm: FC = ({ control, errors, @@ -62,37 +66,29 @@ const MarketMakingForm: FC = ({ return ( <> - - - ( - - field={field} - campaignType={campaignType} - error={!!errors.exchange} - /> - )} - /> - {errors.exchange && ( - {errors.exchange.message} + + + Exchange + + ( + + field={field} + campaignType={campaignType} + error={!!errors.exchange} + /> )} - - - + /> + {errors.exchange && ( + {errors.exchange.message} + )} + + + Trading Pair + = ({ renderInput={(params) => ( = ({ component="li" sx={{ '& > img': { mr: 2, flexShrink: 0 } }} > - + ); }} @@ -167,12 +163,14 @@ const MarketMakingForm: FC = ({ width: '100%', }} > + + Start Date + ( = ({ slotProps={{ textField: { error: !!errors.start_date, + placeholder: 'Select', }, }} /> @@ -201,12 +200,14 @@ const MarketMakingForm: FC = ({ width: '100%', }} > + + End Date + ( = ({ slotProps={{ textField: { error: !!errors.end_date, + placeholder: 'Select', }, }} /> @@ -233,15 +235,16 @@ const MarketMakingForm: FC = ({ - Fund Token + + Fund Token + ( @@ -268,18 +271,23 @@ const MarketMakingForm: FC = ({ error={!!errors.daily_volume_target} sx={{ width: '100%' }} > + + Daily Volume Target + ( { + if (e.target.value.length > MAX_NUMBER_INPUT_LENGTH) { + return; + } const value = formatInputValue(e.target.value); if (isExceedingMaximumInteger(value)) { return; @@ -288,9 +296,17 @@ const MarketMakingForm: FC = ({ }} slotProps={{ htmlInput: { + onWheel: (e: WheelEvent) => { + e.currentTarget.blur(); + }, + onKeyDown: (e: KeyboardEvent) => { + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + e.preventDefault(); + } + }, sx: { fieldSizing: 'content', - maxWidth: '12ch', + maxWidth: '16ch', minWidth: '1ch', width: 'unset', }, @@ -307,10 +323,12 @@ const MarketMakingForm: FC = ({ height: '23px', opacity: 0, pointerEvents: 'none', - [`[data-shrink=true] ~ .${inputBaseClasses.root} > &`]: - { - opacity: 1, - }, + '.Mui-focused &': { + opacity: 1, + }, + 'input:not(:placeholder-shown) ~ &': { + opacity: 1, + }, }} > diff --git a/campaign-launcher/client/src/components/LaunchCampaignForm/components/NetworkStep.tsx b/campaign-launcher/client/src/components/LaunchCampaignForm/components/NetworkStep.tsx new file mode 100644 index 000000000..8e9b1cd6f --- /dev/null +++ b/campaign-launcher/client/src/components/LaunchCampaignForm/components/NetworkStep.tsx @@ -0,0 +1,227 @@ +import { + type RefObject, + useEffect, + useRef, + useState, + type Dispatch, + type FC, + type SetStateAction, +} from 'react'; + +import { StakingClient, type ChainId } from '@human-protocol/sdk'; +import { Box, Button, Grid, Paper, Stack, Typography } from '@mui/material'; + +import { useIsMobile } from '@/hooks/useBreakpoints'; +import { useNotification } from '@/hooks/useNotification'; +import { ArrowLeftIcon, RefreshIcon, WarningIcon } from '@/icons'; +import { useNetwork } from '@/providers/NetworkProvider'; +import { useSignerContext } from '@/providers/SignerProvider'; +import { config as wagmiConfig } from '@/providers/WagmiProvider'; +import { getChainIcon } from '@/utils'; + +import BottomNavigation from './BottomNavigation'; + +const STAKING_DASHBOARD_URL = import.meta.env.VITE_APP_STAKING_DASHBOARD_URL; + +const NotStakedWarning: FC<{ + handleRefresh: () => void; + isCheckingStake: boolean; + warningRef: RefObject; +}> = ({ handleRefresh, isCheckingStake, warningRef }) => { + return ( + + + + No HMT staked on this network + + + To continue, please stake HMT on the selected network. Once updated try + refreshing to apply the changes + + + + + + + ); +}; + +type Props = { + chainId: ChainId | null; + handleSetNetwork: (network: ChainId) => void; + handleChangeStep: Dispatch>; +}; + +const NetworkStep: FC = ({ + chainId, + handleChangeStep, + handleSetNetwork, +}) => { + const [isCheckingStake, setIsCheckingStake] = useState(false); + const [showNotStakedWarning, setShowNotStakedWarning] = useState(false); + const warningRef = useRef(null); + + const networks = wagmiConfig.chains.map((chain) => ({ + value: chain.id, + label: chain.name, + })); + const { setAppChainId } = useNetwork(); + const { signer, isSignerReady, isSignerMissing } = useSignerContext(); + const isMobile = useIsMobile(); + const { showError } = useNotification(); + + const handleClickOnNetwork = (chainId: ChainId) => { + if (isCheckingStake) return; + handleSetNetwork(chainId); + setShowNotStakedWarning(false); + }; + + const handleNextClick = async (chainId: ChainId) => { + if (isCheckingStake) return; + setAppChainId(chainId); + setIsCheckingStake(true); + }; + + useEffect(() => { + if (isSignerMissing) { + setIsCheckingStake(false); + showError('Error checking stake'); + return; + } + + if (isCheckingStake && isSignerReady) { + (async () => { + try { + const stakingClient = await StakingClient.build(signer); + const stakedAmount = await stakingClient.getStakerInfo( + signer.address + ); + if (Number(stakedAmount.stakedAmount) > 0) { + if (showNotStakedWarning) { + setShowNotStakedWarning(false); + } else { + handleChangeStep((prev) => prev + 1); + } + } else { + setShowNotStakedWarning(true); + } + } catch (error) { + console.error('Error checking stake: ', error); + showError('Error checking stake'); + } finally { + setIsCheckingStake(false); + } + })(); + } + }, [ + isCheckingStake, + isSignerReady, + signer, + handleChangeStep, + isSignerMissing, + showError, + showNotStakedWarning, + ]); + + useEffect(() => { + if (isMobile && showNotStakedWarning && warningRef.current) { + warningRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }, [isMobile, showNotStakedWarning]); + + return ( + <> + + + {networks.map(({ value, label }) => { + const isSelected = chainId === value; + return ( + + svg': { fontSize: '44px' }, + }} + onClick={() => handleClickOnNetwork(value)} + > + {getChainIcon(value)} + + {label} + + + + ); + })} + + {isMobile && showNotStakedWarning && ( + setIsCheckingStake(true)} + isCheckingStake={isCheckingStake} + /> + )} + + chainId && handleNextClick(chainId)} + disableNextButton={!chainId || isCheckingStake || showNotStakedWarning} + > + {!isMobile && showNotStakedWarning && ( + setIsCheckingStake(true)} + isCheckingStake={isCheckingStake} + /> + )} + + + ); +}; + +export default NetworkStep; diff --git a/campaign-launcher/client/src/components/LaunchCampaignForm/components/StepsIndicator.tsx b/campaign-launcher/client/src/components/LaunchCampaignForm/components/StepsIndicator.tsx index 67d33af5a..8597de9e2 100644 --- a/campaign-launcher/client/src/components/LaunchCampaignForm/components/StepsIndicator.tsx +++ b/campaign-launcher/client/src/components/LaunchCampaignForm/components/StepsIndicator.tsx @@ -1,23 +1,83 @@ import { type FC } from 'react'; -import { Box, Stack } from '@mui/material'; +import { Box, Link, Stack, Typography } from '@mui/material'; + +import CustomTooltip from '@/components/CustomTooltip'; +import InfoTooltipInner from '@/components/InfoTooltipInner'; +import { useIsMobile } from '@/hooks/useBreakpoints'; + +const CampaignTypeTooltip: FC = () => { + const isMobile = useIsMobile(); + return ( + + What are the campaign types? + + } + > + + + ); +}; type Props = { - step: number; + steps: string[]; + currentStep: number; }; -const StepsIndicator: FC = ({ step }) => { +const StepsIndicator: FC = ({ steps, currentStep }) => { + const isLastStep = currentStep === steps.length; return ( - - {Array.from({ length: 3 }).map((_, index) => ( - = index + 1 ? 'primary.main' : 'background.default'} - borderRadius="90px" - /> - ))} + + + {!isLastStep && ( + + {currentStep}. + + )} + + {steps[currentStep - 1]} + + {currentStep === 2 && } + + {!isLastStep && ( + + {steps.map((step, index) => ( + = index + 1 ? 'primary.main' : '#251D47'} + borderRadius="90px" + /> + ))} + + )} ); }; diff --git a/campaign-launcher/client/src/components/LaunchCampaignForm/components/SummaryCard.tsx b/campaign-launcher/client/src/components/LaunchCampaignForm/components/SummaryCard.tsx index d7841c82e..eac83e112 100644 --- a/campaign-launcher/client/src/components/LaunchCampaignForm/components/SummaryCard.tsx +++ b/campaign-launcher/client/src/components/LaunchCampaignForm/components/SummaryCard.tsx @@ -1,6 +1,7 @@ import { type FC } from 'react'; -import { Paper, Stack, Typography } from '@mui/material'; +import { type ChainId } from '@human-protocol/sdk'; +import { Paper, Stack, styled, Typography } from '@mui/material'; import { useExchangesContext } from '@/providers/ExchangesProvider'; import { @@ -11,12 +12,13 @@ import { CampaignTypeNames, CampaignType, } from '@/types'; -import { getTokenInfo } from '@/utils'; +import { getNetworkName, getTokenInfo } from '@/utils'; import dayjs from '@/utils/dayjs'; type Props = { step: number; - formValues: CampaignFormValues; + chainId: ChainId | null; + formValues: CampaignFormValues | null; fundAmount?: string; }; @@ -26,204 +28,186 @@ const Row = ({ children }: { children: React.ReactNode }) => ( alignItems="center" justifyContent="space-between" width="100%" - height="24px" + py={1} > {children} ); -const getDailyTargetLabel = (campaignType: CampaignType) => { - switch (campaignType) { - case CampaignType.MARKET_MAKING: - return 'Daily volume target'; - case CampaignType.HOLDING: - return 'Daily balance target'; - case CampaignType.THRESHOLD: - return 'Minimum balance target'; - default: - return campaignType as never; - } -}; +const RowName = styled(Typography)({ + color: '#a0a0a0', + fontSize: '14px', + fontWeight: 500, +}); -const getDailyTargetValue = ( - campaignType: CampaignType, - formValues: CampaignFormValues -) => { - switch (campaignType) { - case CampaignType.MARKET_MAKING: - return (formValues as MarketMakingFormValues)?.daily_volume_target; - case CampaignType.HOLDING: - return (formValues as HoldingFormValues)?.daily_balance_target; - case CampaignType.THRESHOLD: - return (formValues as ThresholdFormValues)?.minimum_balance_target; - } -}; +const RowValue = styled(Typography)({ + color: '#ffffff', + fontSize: '14px', + fontWeight: 500, + textAlign: 'right', +}); -const getDailyTargetToken = ( +const getTargetInfo = ( campaignType: CampaignType, formValues: CampaignFormValues ) => { switch (campaignType) { case CampaignType.MARKET_MAKING: - return (formValues as MarketMakingFormValues)?.pair.split('/')[1]; - case CampaignType.HOLDING: - return (formValues as HoldingFormValues)?.symbol; - case CampaignType.THRESHOLD: - return (formValues as ThresholdFormValues)?.symbol; - } -}; - -const getSymbolOrPairLabel = (campaignType: CampaignType) => { - switch (campaignType) { - case CampaignType.MARKET_MAKING: - return 'Pair'; + return { + label: 'Daily volume target', + value: (formValues as MarketMakingFormValues)?.daily_volume_target, + token: (formValues as MarketMakingFormValues)?.pair.split('/')[1], + }; case CampaignType.HOLDING: - return 'Symbol'; + return { + label: 'Daily balance target', + value: (formValues as HoldingFormValues)?.daily_balance_target, + token: (formValues as HoldingFormValues)?.symbol, + }; case CampaignType.THRESHOLD: - return 'Symbol'; + return { + label: 'Minimum balance target', + value: (formValues as ThresholdFormValues)?.minimum_balance_target, + token: (formValues as ThresholdFormValues)?.symbol, + }; } }; -const getSymbolOrPair = ( +const getSymbolOrPairInfo = ( campaignType: CampaignType, formValues: CampaignFormValues ) => { switch (campaignType) { case CampaignType.MARKET_MAKING: - return (formValues as MarketMakingFormValues)?.pair; + return { + label: 'Pair', + value: (formValues as MarketMakingFormValues)?.pair, + }; case CampaignType.HOLDING: - return (formValues as HoldingFormValues)?.symbol; + return { + label: 'Symbol', + value: (formValues as HoldingFormValues)?.symbol, + }; case CampaignType.THRESHOLD: - return (formValues as ThresholdFormValues)?.symbol; - default: - return 0; + return { + label: 'Symbol', + }; } }; -const SummaryCard: FC = ({ step, formValues, fundAmount }) => { +const SummaryCard: FC = ({ step, chainId, formValues, fundAmount }) => { const { exchangesMap } = useExchangesContext(); const exchangeName = exchangesMap.get( formValues?.exchange || '' )?.display_name; - const { type: campaignType } = formValues; + const { type: campaignType } = formValues || {}; - const symbolOrPairLabel = getSymbolOrPairLabel(campaignType); + const isLastStep = step === 5; - const symbolOrPair = getSymbolOrPair( - campaignType, - formValues as CampaignFormValues - ); + const showFormValues = step > 3; - const dailyTargetValue = getDailyTargetValue( - campaignType, - formValues as CampaignFormValues - ); + const symbolOrPairLabel = campaignType + ? getSymbolOrPairInfo(campaignType, formValues as CampaignFormValues).label + : ''; - const dailyTargetToken = getDailyTargetToken( - campaignType, - formValues as CampaignFormValues - ); + const symbolOrPair = campaignType + ? getSymbolOrPairInfo(campaignType, formValues as CampaignFormValues).value + : ''; + + const targetValue = campaignType + ? getTargetInfo(campaignType, formValues as CampaignFormValues).value + : ''; + + const targetToken = campaignType + ? getTargetInfo(campaignType, formValues as CampaignFormValues).token + : ''; return ( 3 ? '600px' : '400px' }, - minHeight: '150px', + minHeight: '50px', height: 'fit-content', - py: 3, - px: 4, - gap: 2, - bgcolor: 'background.default', - borderRadius: '20px', - boxShadow: 'none', + py: 1, + px: 2, + bgcolor: '#251d47', + borderRadius: '8px', + border: isLastStep ? 'none' : '1px solid #433679', }} > - - Campaign Type - - - {CampaignTypeNames[campaignType]} - + Network + {chainId ? getNetworkName(chainId) : '-'} - {!!fundAmount && ( + {step > 1 && ( - - Fund Amount - - - {fundAmount} {getTokenInfo(formValues?.fund_token || '')?.label} - + Campaign Type + + {campaignType ? CampaignTypeNames[campaignType] : '-'} + )} {step > 2 && !!formValues && ( <> - - Exchange - - - {exchangeName} - + Exchange + {showFormValues ? exchangeName : '-'} + + + {symbolOrPairLabel} + {showFormValues ? symbolOrPair : '-'} - - {symbolOrPairLabel} - - - {symbolOrPair} - + Fund Token + + {showFormValues + ? getTokenInfo(formValues?.fund_token || '')?.label + : '-'} + - - {getDailyTargetLabel(campaignType)} - - - {dailyTargetValue} {dailyTargetToken} - + + {campaignType + ? getTargetInfo(campaignType, formValues).label + : ''} + + + {showFormValues ? `${targetValue} ${targetToken}` : '-'} + - - Start Date - - - {dayjs(formValues.start_date).format('Do MMM YYYY HH:mm')} - + Start Date + + {showFormValues && formValues?.start_date + ? dayjs(formValues?.start_date).format('Do MMM YYYY HH:mm') + : '-'} + - - End Date - - - {dayjs(formValues.end_date).format('Do MMM YYYY HH:mm')} - + End Date + + {showFormValues && formValues?.end_date + ? dayjs(formValues?.end_date).format('Do MMM YYYY HH:mm') + : '-'} + )} + {step > 3 && ( + + Fund Amount + + {fundAmount || '-'}{' '} + {fundAmount + ? getTokenInfo(formValues?.fund_token || '')?.label + : ''} + + + )} ); }; diff --git a/campaign-launcher/client/src/components/LaunchCampaignForm/components/ThirdStep.tsx b/campaign-launcher/client/src/components/LaunchCampaignForm/components/ThirdStep.tsx deleted file mode 100644 index 69c78ed4d..000000000 --- a/campaign-launcher/client/src/components/LaunchCampaignForm/components/ThirdStep.tsx +++ /dev/null @@ -1,452 +0,0 @@ -import { useEffect, type FC } from 'react'; - -import { yupResolver } from '@hookform/resolvers/yup'; -import { - Box, - CircularProgress, - Divider, - FormControl, - FormControlLabel, - FormHelperText, - InputAdornment, - Radio, - RadioGroup, - Stack, - TextField, - Typography, -} from '@mui/material'; -import { Controller, useForm } from 'react-hook-form'; - -import CustomTooltip from '@/components/CustomTooltip'; -import InfoTooltipInner from '@/components/InfoTooltipInner'; -import { UNLIMITED_AMOUNT } from '@/constants'; -import { useIsMobile } from '@/hooks/useBreakpoints'; -import { useNotification } from '@/hooks/useNotification'; -import { useTokenAllowance } from '@/hooks/useTokenAllowance'; -import { AllowanceType, type CampaignFormValues } from '@/types'; -import { getTokenInfo, isExceedingMaximumInteger } from '@/utils'; - -import { formatInputValue } from '../utils'; -import { createFundAmountValidationSchema } from '../validation'; - -import { SummaryCard, BottomNavigation } from './'; - -const allowanceTooltipText = { - unlimited: - 'Approve unlimited token amount so you don’t need to approve again for future escrows.', - custom: - 'You can change this allowance later by sending another approve transaction.', -} as const; - -const AllowanceTooltip = ({ type }: { type: AllowanceType }) => { - const isMobile = useIsMobile(); - return ( - - {allowanceTooltipText[type]} - - } - > - - - ); -}; - -type Props = { - fundAmount: string; - setFundAmount: (amount: string) => void; - formValues: CampaignFormValues; - handleChangeStep: (step: number) => void; -}; - -const ThirdStep: FC = ({ - fundAmount, - setFundAmount, - formValues, - handleChangeStep, -}) => { - const { fund_token: fundToken } = formValues; - const isMobile = useIsMobile(); - - const { - control, - formState: { errors, dirtyFields }, - handleSubmit, - watch, - setValue, - } = useForm<{ - fund_amount: string; - selected_allowance: AllowanceType; - custom_allowance_amount: string; - }>({ - mode: isMobile ? 'onSubmit' : 'onChange', - resolver: yupResolver( - createFundAmountValidationSchema( - formValues.start_date, - formValues.end_date, - formValues.fund_token - ) - ), - defaultValues: { - fund_amount: fundAmount, - selected_allowance: AllowanceType.CUSTOM, - custom_allowance_amount: '', - }, - shouldFocusError: isMobile, - }); - - const { selected_allowance, custom_allowance_amount } = watch(); - - const { - approve, - fetchAllowance, - allowance: currentAllowance, - isApproving, - isLoading, - resetApproval, - } = useTokenAllowance(); - - const { showError } = useNotification(); - - useEffect(() => { - if ( - !dirtyFields.selected_allowance && - !isLoading && - currentAllowance === UNLIMITED_AMOUNT - ) { - setValue('selected_allowance', AllowanceType.UNLIMITED); - } - }, [currentAllowance, dirtyFields.selected_allowance, setValue, isLoading]); - - useEffect(() => { - fetchAllowance(fundToken); - }, [fundToken, fetchAllowance]); - - useEffect(() => { - if (isMobile && errors.fund_amount) { - const errorElement = document.querySelector( - '[name="fund_amount"]' - ) as HTMLElement; - errorElement?.scrollIntoView({ - behavior: 'smooth', - block: 'center', - }); - } - }, [isMobile, errors]); - - const onSubmit = async (data: { - fund_amount: string; - selected_allowance: AllowanceType; - custom_allowance_amount: string; - }) => { - const { fund_amount, selected_allowance, custom_allowance_amount } = data; - let canSkipApproval = false; - - if (!custom_allowance_amount) { - canSkipApproval = - (selected_allowance === AllowanceType.UNLIMITED && - currentAllowance === UNLIMITED_AMOUNT) || - (selected_allowance === AllowanceType.CUSTOM && - (currentAllowance === UNLIMITED_AMOUNT || - Number(currentAllowance) >= Number(fund_amount))); - } - - if (canSkipApproval) { - setFundAmount(fund_amount); - handleChangeStep(4); - return; - } - - const amountToApprove = - selected_allowance === AllowanceType.UNLIMITED - ? UNLIMITED_AMOUNT - : custom_allowance_amount || fund_amount; - - const isApproved = await approve(fundToken, amountToApprove); - - if (isApproved) { - setFundAmount(fund_amount); - handleChangeStep(4); - return; - } else { - resetApproval(); - showError('Failed to approve tokens, please try again'); - return; - } - }; - - const inputAdornmentLabel = getTokenInfo(fundToken)?.label || ''; - - return ( - -
- - - - - Campaign Fund Amount - - - ( - { - const value = formatInputValue(e.target.value); - if (isExceedingMaximumInteger(value)) { - return; - } - field.onChange(value); - }} - slotProps={{ - input: { - endAdornment: ( - - {inputAdornmentLabel} - - ), - }, - }} - /> - )} - /> - {errors.fund_amount && ( - {errors.fund_amount.message} - )} - - Current allowance: - - {currentAllowance === UNLIMITED_AMOUNT - ? 'Unlimited' - : `${currentAllowance ?? 0} ${fundToken.toUpperCase()}`} - - - - - - - - - Token Approval - - {(isLoading || isApproving) && } - - - ( - { - field.onChange(e); - if (e.target.value === AllowanceType.UNLIMITED) { - setValue('custom_allowance_amount', ''); - } - }} - sx={{ - display: 'flex', - flexDirection: { xs: 'column', md: 'row' }, - alignItems: 'flex-start', - gap: 3, - }} - > - - - } - label="Limited Allowance" - sx={{ - mr: 0, - '& .MuiRadio-root, & .MuiTypography-root': { - color: - selected_allowance === AllowanceType.CUSTOM && - !isApproving - ? 'text.primary' - : 'text.disabled', - }, - }} - /> - - - - ( - { - const rawValue = e.target.value; - if (rawValue === '') { - field.onChange(''); - return; - } - const value = formatInputValue(rawValue); - if (isExceedingMaximumInteger(value)) { - return; - } - field.onChange(value); - }} - slotProps={{ - htmlInput: { - min: 0, - sx: { - fieldSizing: 'content', - maxWidth: '12ch', - minWidth: '1ch', - width: 'unset', - }, - }, - input: { - endAdornment: custom_allowance_amount ? ( - - - {inputAdornmentLabel} - - - ) : null, - }, - }} - /> - )} - /> - {errors.custom_allowance_amount && - selected_allowance === AllowanceType.CUSTOM && ( - - {errors.custom_allowance_amount.message} - - )} - - - - } - label="Unlimited Approval" - sx={{ - mr: 0, - '& .MuiRadio-root, & .MuiTypography-root': { - color: - selected_allowance === - AllowanceType.UNLIMITED && !isApproving - ? 'text.primary' - : 'text.disabled', - }, - }} - /> - - - - )} - /> - - - - {!isMobile && } - - {}} - handleBackClick={() => handleChangeStep(2)} - /> - -
- ); -}; - -export default ThirdStep; diff --git a/campaign-launcher/client/src/components/LaunchCampaignForm/components/ThresholdForm.tsx b/campaign-launcher/client/src/components/LaunchCampaignForm/components/ThresholdForm.tsx index f32a0abe1..2060b3431 100644 --- a/campaign-launcher/client/src/components/LaunchCampaignForm/components/ThresholdForm.tsx +++ b/campaign-launcher/client/src/components/LaunchCampaignForm/components/ThresholdForm.tsx @@ -1,4 +1,4 @@ -import type { FC } from 'react'; +import type { FC, KeyboardEvent, WheelEvent } from 'react'; import { Autocomplete, @@ -7,8 +7,6 @@ import { FormControl, FormHelperText, InputAdornment, - inputBaseClasses, - InputLabel, MenuItem, Select, Stack, @@ -27,6 +25,7 @@ import { import CryptoEntity from '@/components/CryptoEntity'; import FormExchangeSelect from '@/components/FormExchangeSelect'; +import { MAX_NUMBER_INPUT_LENGTH } from '@/constants'; import { FUND_TOKENS } from '@/constants/tokens'; import { useExchangeCurrencies } from '@/hooks/useExchangeCurrencies'; import type { CampaignType, ThresholdFormValues } from '@/types'; @@ -34,8 +33,6 @@ import { getTokenInfo, isExceedingMaximumInteger } from '@/utils'; import { formatInputValue } from '../utils'; -import { ExchangeInfoTooltip } from './'; - type Props = { control: Control; errors: FieldErrors; @@ -44,6 +41,13 @@ type Props = { campaignType: CampaignType; }; +const labelStyles = { + color: 'white', + mb: 1.5, + lineHeight: '100%', + letterSpacing: '0px', +}; + const ThresholdForm: FC = ({ control, errors, @@ -60,37 +64,29 @@ const ThresholdForm: FC = ({ return ( <> - - - ( - - field={field} - campaignType={campaignType} - error={!!errors.exchange} - /> - )} - /> - {errors.exchange && ( - {errors.exchange.message} + + + Exchange + + ( + + field={field} + campaignType={campaignType} + error={!!errors.exchange} + /> )} - - - + /> + {errors.exchange && ( + {errors.exchange.message} + )} +
+ + Symbol + = ({ renderInput={(params) => ( = ({ component="li" sx={{ '& > img': { mr: 2, flexShrink: 0 } }} > - + ); }} @@ -157,18 +152,15 @@ const ThresholdForm: FC = ({
- + + + Start Date + ( = ({ slotProps={{ textField: { error: !!errors.start_date, + placeholder: 'Select', }, }} /> @@ -191,18 +184,15 @@ const ThresholdForm: FC = ({ {errors.start_date.message} )} - + + + End Date + ( = ({ slotProps={{ textField: { error: !!errors.end_date, + placeholder: 'Select', }, }} /> @@ -229,15 +220,16 @@ const ThresholdForm: FC = ({ - Fund Token + + Fund Token + ( @@ -264,18 +256,23 @@ const ThresholdForm: FC = ({ error={!!errors.minimum_balance_target} sx={{ width: '100%' }} > + + Minimum Balance Target + ( { + if (e.target.value.length > MAX_NUMBER_INPUT_LENGTH) { + return; + } const value = formatInputValue(e.target.value); if (isExceedingMaximumInteger(value)) { return; @@ -284,9 +281,17 @@ const ThresholdForm: FC = ({ }} slotProps={{ htmlInput: { + onWheel: (e: WheelEvent) => { + e.currentTarget.blur(); + }, + onKeyDown: (e: KeyboardEvent) => { + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + e.preventDefault(); + } + }, sx: { fieldSizing: 'content', - maxWidth: '12ch', + maxWidth: '16ch', minWidth: '1ch', width: 'unset', }, @@ -303,10 +308,12 @@ const ThresholdForm: FC = ({ height: '23px', opacity: 0, pointerEvents: 'none', - [`[data-shrink=true] ~ .${inputBaseClasses.root} > &`]: - { - opacity: 1, - }, + '.Mui-focused &': { + opacity: 1, + }, + 'input:not(:placeholder-shown) ~ &': { + opacity: 1, + }, }} > diff --git a/campaign-launcher/client/src/components/LaunchCampaignForm/components/TopNavigation.tsx b/campaign-launcher/client/src/components/LaunchCampaignForm/components/TopNavigation.tsx deleted file mode 100644 index 5443602cd..000000000 --- a/campaign-launcher/client/src/components/LaunchCampaignForm/components/TopNavigation.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { type FC } from 'react'; - -import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import { IconButton, Link, Stack, Typography } from '@mui/material'; - -import CustomTooltip from '@/components/CustomTooltip'; -import InfoTooltipInner from '@/components/InfoTooltipInner'; -import { useIsMobile } from '@/hooks/useBreakpoints'; - -type Props = { - step: number; - handleBackClick: () => void; -}; - -const stepNames = ['Select Campaign Type', 'Create Escrow', 'Approve Tokens']; - -const TopNavigation: FC = ({ step, handleBackClick }) => { - const isMobile = useIsMobile(); - - return ( - - {step > 1 && ( - - - - )} - - {step}. {stepNames[step - 1]} - - {step === 1 && ( - - What are the campaign types? - - } - > - - - )} - - ); -}; - -export default TopNavigation; diff --git a/campaign-launcher/client/src/components/LaunchCampaignForm/components/index.tsx b/campaign-launcher/client/src/components/LaunchCampaignForm/components/index.tsx index b9c122262..bca8b8006 100644 --- a/campaign-launcher/client/src/components/LaunchCampaignForm/components/index.tsx +++ b/campaign-launcher/client/src/components/LaunchCampaignForm/components/index.tsx @@ -1,31 +1,29 @@ +import ApprovalStep from './ApprovalStep'; import BottomNavigation from './BottomNavigation'; +import CampaignTypeStep from './CampaignTypeStep'; import ErrorView from './ErrorView'; -import ExchangeInfoTooltip from './ExchangeInfoTooltip'; +import EscrowDetailsStep from './EscrowDetailsStep'; import FinalView from './FinalView'; -import FirstStep from './FirstStep'; import HoldingForm from './HoldingForm'; import LaunchStep from './LaunchStep'; import MarketMakingForm from './MarketMakingForm'; -import SecondStep from './SecondStep'; +import NetworkStep from './NetworkStep'; import StepsIndicator from './StepsIndicator'; import SummaryCard from './SummaryCard'; -import ThirdStep from './ThirdStep'; import ThresholdForm from './ThresholdForm'; -import TopNavigation from './TopNavigation'; export { + ApprovalStep, BottomNavigation, + CampaignTypeStep, ErrorView, - ExchangeInfoTooltip, + EscrowDetailsStep, FinalView, - FirstStep, HoldingForm, LaunchStep, MarketMakingForm, - SecondStep, + NetworkStep, StepsIndicator, SummaryCard, - ThirdStep, ThresholdForm, - TopNavigation, }; diff --git a/campaign-launcher/client/src/components/LaunchCampaignForm/index.tsx b/campaign-launcher/client/src/components/LaunchCampaignForm/index.tsx index b640c8883..ad57ab2a5 100644 --- a/campaign-launcher/client/src/components/LaunchCampaignForm/index.tsx +++ b/campaign-launcher/client/src/components/LaunchCampaignForm/index.tsx @@ -1,72 +1,112 @@ import { useEffect, useState, type FC } from 'react'; -import { Stack } from '@mui/material'; +import { type ChainId } from '@human-protocol/sdk'; +import { Box } from '@mui/material'; +import { useIsMobile } from '@/hooks/useBreakpoints'; import { type CampaignFormValues } from '@/types'; import { - TopNavigation, - FirstStep, - SecondStep, - ThirdStep, + NetworkStep, + CampaignTypeStep, + EscrowDetailsStep, + ApprovalStep, LaunchStep, + SummaryCard, + StepsIndicator, } from './components'; +const steps = [ + 'Select Network', + 'Select Campaign Type', + 'Create Escrow', + 'Approve Tokens', + 'One final look before you initiate the campaign', +]; + const LaunchCampaignForm: FC = () => { const [step, setStep] = useState(1); + const [chainId, setChainId] = useState(null); const [fundAmount, setFundAmount] = useState(''); const [formValues, setFormValues] = useState(null); + const isMobile = useIsMobile(); + const handleStartOver = () => { setStep(1); setFundAmount(''); setFormValues(null); + setChainId(null); }; useEffect(() => { window.scrollTo({ top: 0, behavior: 'smooth' }); }, [step]); + const isLastStep = step === steps.length; + return ( - - {step < 4 && ( - setStep(step - 1)} /> - )} + {!isLastStep && } {step === 1 && ( - + )} + {step === 2 && ( + )} - {step === 2 && ( - )} - {step === 3 && ( - )} - {step === 4 && ( + {step === 5 && formValues && chainId && ( )} - + {!isLastStep && !isMobile && ( + + )} + ); }; diff --git a/campaign-launcher/client/src/components/LaunchCampaignForm/validation.ts b/campaign-launcher/client/src/components/LaunchCampaignForm/validation.ts index 7fccfbf36..a764d6e86 100644 --- a/campaign-launcher/client/src/components/LaunchCampaignForm/validation.ts +++ b/campaign-launcher/client/src/components/LaunchCampaignForm/validation.ts @@ -12,6 +12,7 @@ import { const MAX_DURATION = 100 * 24 * 60 * 60 * 1000; // 100 days const MIN_DURATION = 6 * 60 * 60 * 1000; // 6 hours +const TEN_MINUTES_IN_MS = 10 * 60 * 1000; const validateCampaignDuration = (startDate: Date, endDate: Date) => { const duration = endDate.getTime() - startDate.getTime(); @@ -105,7 +106,7 @@ const baseValidationSchema = { .required('Required') .test('is-future', 'Start date cannot be in the past', function (value) { if (!value) return true; - return value.getTime() > Date.now(); + return value.getTime() + TEN_MINUTES_IN_MS > Date.now(); }) .test( 'duration', @@ -120,7 +121,7 @@ const baseValidationSchema = { .required('Required') .test('is-future', 'End date cannot be in the past', function (value) { if (!value) return true; - return value.getTime() > Date.now(); + return value.getTime() + TEN_MINUTES_IN_MS > Date.now(); }) .test( 'duration', diff --git a/campaign-launcher/client/src/components/Layout/index.tsx b/campaign-launcher/client/src/components/Layout/index.tsx index 7db7f3ec8..ce65d0d7c 100644 --- a/campaign-launcher/client/src/components/Layout/index.tsx +++ b/campaign-launcher/client/src/components/Layout/index.tsx @@ -1,4 +1,13 @@ -import { type FC, type PropsWithChildren, useEffect } from 'react'; +import { + createContext, + type Dispatch, + type FC, + type PropsWithChildren, + type SetStateAction, + useContext, + useEffect, + useState, +} from 'react'; import { Box } from '@mui/material'; import { useLocation } from 'react-router'; @@ -7,7 +16,30 @@ import Container from '@/components/Container'; import Footer from '@/components/Footer'; import Header from '@/components/Header'; +const LayoutBottomOffsetContext = createContext +> | null>(null); + +export const useReserveLayoutBottomOffset = (enabled: boolean) => { + const setReserveBottomOffset = useContext(LayoutBottomOffsetContext); + + if (!setReserveBottomOffset) { + throw new Error( + 'useReserveLayoutBottomOffset must be used within Layout provider' + ); + } + + useEffect(() => { + setReserveBottomOffset(enabled); + + return () => { + setReserveBottomOffset(false); + }; + }, [enabled, setReserveBottomOffset]); +}; + const Layout: FC = ({ children }) => { + const [reserveBottomOffset, setReserveBottomOffset] = useState(false); const { pathname } = useLocation(); useEffect(() => { @@ -17,8 +49,10 @@ const Layout: FC = ({ children }) => { return (
- {children} -