From 09653749037fb4b4bf2af921574ad8b0428723f4 Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Mon, 15 Dec 2025 16:35:25 +0300 Subject: [PATCH 01/17] feat: add the staking info UI, a styles and components refactoring, add icons --- packages/apps/human-app/frontend/.env.example | 1 + .../assets/icons-dark-mode/content-copy.svg | 3 + .../assets/icons-dark-mode/delete-icon.svg | 3 + .../src/assets/icons-dark-mode/edit-icon.svg | 3 + .../src/assets/icons/content-copy.svg | 3 + .../frontend/src/assets/icons/delete-icon.svg | 3 + .../frontend/src/assets/icons/edit-icon.svg | 3 + .../worker/hooks/use-idv-notification.tsx | 18 -- .../profile/components/add-api-key-modal.tsx | 136 +++++++++++++++ .../profile/components/api-key-data.tsx | 64 ++++++++ .../profile/components/custom-text-field.tsx | 38 +++++ .../components/delete-api-key-modal.tsx | 50 ++++++ .../profile/components/edit-api-key-modal.tsx | 155 ++++++++++++++++++ .../identity-verification-control.tsx | 2 +- .../worker/profile/components/index.ts | 9 +- .../profile/components/profile-actions.tsx | 17 -- .../profile/components/profile-data.tsx | 23 +-- .../profile/components/staking-info.tsx | 74 +++++++++ .../components/wallet-connect-done.tsx | 102 ++++++++---- .../profile/hooks/use-api-key-modals.tsx | 32 ++++ .../worker/profile/hooks/use-staking-info.ts | 77 +++++++++ .../worker/profile/hooks/use-start-idv.ts | 14 +- .../worker/profile/views/profile.page.tsx | 23 +-- .../src/shared/components/ui/icons.tsx | 21 +++ .../components/ui/modal/global-modal.tsx | 36 ++-- .../components/ui/modal/modal-header.tsx | 42 ----- .../apps/human-app/frontend/src/shared/env.ts | 1 + .../frontend/src/shared/i18n/en.json | 31 +++- .../apps/human-app/frontend/vite.config.mjs | 5 + 29 files changed, 841 insertions(+), 148 deletions(-) create mode 100644 packages/apps/human-app/frontend/src/assets/icons-dark-mode/content-copy.svg create mode 100644 packages/apps/human-app/frontend/src/assets/icons-dark-mode/delete-icon.svg create mode 100644 packages/apps/human-app/frontend/src/assets/icons-dark-mode/edit-icon.svg create mode 100644 packages/apps/human-app/frontend/src/assets/icons/content-copy.svg create mode 100644 packages/apps/human-app/frontend/src/assets/icons/delete-icon.svg create mode 100644 packages/apps/human-app/frontend/src/assets/icons/edit-icon.svg delete mode 100644 packages/apps/human-app/frontend/src/modules/worker/hooks/use-idv-notification.tsx create mode 100644 packages/apps/human-app/frontend/src/modules/worker/profile/components/add-api-key-modal.tsx create mode 100644 packages/apps/human-app/frontend/src/modules/worker/profile/components/api-key-data.tsx create mode 100644 packages/apps/human-app/frontend/src/modules/worker/profile/components/custom-text-field.tsx create mode 100644 packages/apps/human-app/frontend/src/modules/worker/profile/components/delete-api-key-modal.tsx create mode 100644 packages/apps/human-app/frontend/src/modules/worker/profile/components/edit-api-key-modal.tsx delete mode 100644 packages/apps/human-app/frontend/src/modules/worker/profile/components/profile-actions.tsx create mode 100644 packages/apps/human-app/frontend/src/modules/worker/profile/components/staking-info.tsx create mode 100644 packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-api-key-modals.tsx create mode 100644 packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-staking-info.ts delete mode 100644 packages/apps/human-app/frontend/src/shared/components/ui/modal/modal-header.tsx diff --git a/packages/apps/human-app/frontend/.env.example b/packages/apps/human-app/frontend/.env.example index b0ea764d9e..8a13a7385e 100644 --- a/packages/apps/human-app/frontend/.env.example +++ b/packages/apps/human-app/frontend/.env.example @@ -25,6 +25,7 @@ VITE_H_CAPTCHA_ORACLE_TASK_TYPES=image_points,image_boxes # Other VITE_HUMAN_PROTOCOL_HELP_URL=https://docs.humanprotocol.org/ VITE_HUMAN_PROTOCOL_URL=https://humanprotocol.org/ +VITE_STAKING_DASHBOARD_URL=https://staking.humanprotocol.org/ VITE_HUMAN_SUPPORT_EMAIL=support@local.app VITE_NAVBAR__LINK__HOW_IT_WORK_URL=https://humanprotocol.org/ VITE_NAVBAR__LINK__PROTOCOL_URL=https://humanprotocol.org/ diff --git a/packages/apps/human-app/frontend/src/assets/icons-dark-mode/content-copy.svg b/packages/apps/human-app/frontend/src/assets/icons-dark-mode/content-copy.svg new file mode 100644 index 0000000000..c4c4f89b16 --- /dev/null +++ b/packages/apps/human-app/frontend/src/assets/icons-dark-mode/content-copy.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/apps/human-app/frontend/src/assets/icons-dark-mode/delete-icon.svg b/packages/apps/human-app/frontend/src/assets/icons-dark-mode/delete-icon.svg new file mode 100644 index 0000000000..7be6824ded --- /dev/null +++ b/packages/apps/human-app/frontend/src/assets/icons-dark-mode/delete-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/apps/human-app/frontend/src/assets/icons-dark-mode/edit-icon.svg b/packages/apps/human-app/frontend/src/assets/icons-dark-mode/edit-icon.svg new file mode 100644 index 0000000000..37d6a7af11 --- /dev/null +++ b/packages/apps/human-app/frontend/src/assets/icons-dark-mode/edit-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/apps/human-app/frontend/src/assets/icons/content-copy.svg b/packages/apps/human-app/frontend/src/assets/icons/content-copy.svg new file mode 100644 index 0000000000..4423c76961 --- /dev/null +++ b/packages/apps/human-app/frontend/src/assets/icons/content-copy.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/apps/human-app/frontend/src/assets/icons/delete-icon.svg b/packages/apps/human-app/frontend/src/assets/icons/delete-icon.svg new file mode 100644 index 0000000000..77a98a0a93 --- /dev/null +++ b/packages/apps/human-app/frontend/src/assets/icons/delete-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/apps/human-app/frontend/src/assets/icons/edit-icon.svg b/packages/apps/human-app/frontend/src/assets/icons/edit-icon.svg new file mode 100644 index 0000000000..3bbfdd7749 --- /dev/null +++ b/packages/apps/human-app/frontend/src/assets/icons/edit-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/apps/human-app/frontend/src/modules/worker/hooks/use-idv-notification.tsx b/packages/apps/human-app/frontend/src/modules/worker/hooks/use-idv-notification.tsx deleted file mode 100644 index 7f6663e162..0000000000 --- a/packages/apps/human-app/frontend/src/modules/worker/hooks/use-idv-notification.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { - TopNotificationType, - useNotification, -} from '@/shared/hooks/use-notification'; -import { getErrorMessageForError } from '@/shared/errors'; -import type { ResponseError } from '@/shared/types/global.type'; - -export function useIdvErrorNotifications() { - const { showNotification } = useNotification(); - - return (error: ResponseError) => { - showNotification({ - type: TopNotificationType.WARNING, - message: getErrorMessageForError(error), - durationMs: 5000, - }); - }; -} diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/add-api-key-modal.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/add-api-key-modal.tsx new file mode 100644 index 0000000000..ed1d7ddbbf --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/add-api-key-modal.tsx @@ -0,0 +1,136 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { + Autocomplete, + FormControl, + FormHelperText, + Stack, + TextField, + Typography, +} from '@mui/material'; +import { Controller, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { z } from 'zod'; +import { Button } from '@/shared/components/ui/button'; +import { useIsMobile } from '@/shared/hooks'; + +export function AddApiKeyModal() { + const { t } = useTranslation(); + const isMobile = useIsMobile(); + + const { + control, + handleSubmit, + formState: { errors }, + } = useForm({ + defaultValues: { + exchange: '', + apiKey: '', + apiSecret: '', + }, + resolver: zodResolver( + z.object({ + exchange: z.string().min(1, t('validation.required')), + apiKey: z.string().min(1, t('validation.required')), + apiSecret: z.string().min(1, t('validation.required')), + }) + ), + }); + + const onSubmit = (data: { + exchange: string; + apiKey: string; + apiSecret: string; + }) => { + console.log(data); + }; + + return ( +
+ + + {isMobile + ? t('worker.profile.apiKeyData.connectApiKey') + : t('worker.profile.apiKeyData.connectYourApiKey')} + + + {t('worker.profile.apiKeyData.modalDescription')} + + + + ( + ( + + )} + /> + )} + /> + {errors.exchange && ( + {errors.exchange.message} + )} + + + ( + + )} + /> + {errors.apiKey && ( + {errors.apiKey.message} + )} + + + + ( + + )} + /> + + + + {t('worker.profile.apiKeyData.modalFooterAgreement')} + + +
+ ); +} diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/api-key-data.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/api-key-data.tsx new file mode 100644 index 0000000000..79ce5f76a0 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/api-key-data.tsx @@ -0,0 +1,64 @@ +import { IconButton, Stack, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { Chip } from '@/shared/components/ui/chip'; +import { CustomTextField, CustomTextFieldDark } from './custom-text-field'; +import { useColorMode } from '@/shared/contexts/color-mode'; +import { DeleteIcon, EditIcon } from '@/shared/components/ui/icons'; +import { + useDeleteApiKeyModal, + useEditApiKeyModal, +} from '../hooks/use-api-key-modals'; + +export function ApiKeyData() { + const { isDarkMode } = useColorMode(); + const { t } = useTranslation(); + const { openModal: openEditApiKeyModal } = useEditApiKeyModal(); + const { openModal: openDeleteApiKeyModal } = useDeleteApiKeyModal(); + + const textField = isDarkMode ? ( + + ) : ( + + ); + + return ( + + + + {t('worker.profile.apiKeyData.apiKey')} + + + + + {textField} + + + + + + + + + + + ); +} diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/custom-text-field.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/custom-text-field.tsx new file mode 100644 index 0000000000..b4ed47d39b --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/custom-text-field.tsx @@ -0,0 +1,38 @@ +import { colorPalette } from '@/shared/styles/color-palette'; +import { + darkColorPalette, + onlyDarkModeColor, +} from '@/shared/styles/dark-color-palette'; +import { styled, TextField } from '@mui/material'; + +const CustomTextField = styled(TextField)(() => ({ + '& .Mui-disabled': { + height: '48px', + maxWidth: '376px', + color: colorPalette.text.disabledSecondary, + WebkitTextFillColor: colorPalette.text.disabledSecondary, + }, + '& .MuiOutlinedInput-root': { + '& fieldset': { + border: '1px dashed', + borderColor: `${colorPalette.text.primary} !important`, + }, + }, +})); + +const CustomTextFieldDark = styled(TextField)(() => ({ + '& .Mui-disabled': { + height: '48px', + maxWidth: '376px', + color: darkColorPalette.text.disabledSecondary, + WebkitTextFillColor: darkColorPalette.text.disabledSecondary, + }, + '& .MuiOutlinedInput-root': { + '& fieldset': { + border: '1px dashed', + borderColor: `${onlyDarkModeColor.mainColorWithOpacity} !important`, + }, + }, +})); + +export { CustomTextField, CustomTextFieldDark }; diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/delete-api-key-modal.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/delete-api-key-modal.tsx new file mode 100644 index 0000000000..88edb72202 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/delete-api-key-modal.tsx @@ -0,0 +1,50 @@ +import { Stack, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/shared/components/ui/button'; +import { useIsMobile } from '@/shared/hooks'; + +interface DeleteApiKeyModalProps { + onClose: () => void; +} + +export function DeleteApiKeyModal({ onClose }: DeleteApiKeyModalProps) { + const { t } = useTranslation(); + const isMobile = useIsMobile(); + + return ( + + + {t('worker.profile.apiKeyData.deleteApiKey')} + + + {t('worker.profile.apiKeyData.deleteApiKeyConfirmation')} + + + {t('worker.profile.apiKeyData.deleteApiKeyDescription')} + + + + + + + ); +} diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/edit-api-key-modal.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/edit-api-key-modal.tsx new file mode 100644 index 0000000000..ca5e069f84 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/edit-api-key-modal.tsx @@ -0,0 +1,155 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { + Autocomplete, + FormControl, + FormHelperText, + Stack, + TextField, + Typography, +} from '@mui/material'; +import { Controller, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { z } from 'zod'; +import { Button } from '@/shared/components/ui/button'; +import { useIsMobile } from '@/shared/hooks'; + +interface EditApiKeyModalProps { + onClose: () => void; +} + +export function EditApiKeyModal({ onClose }: EditApiKeyModalProps) { + const { t } = useTranslation(); + const isMobile = useIsMobile(); + + const { + control, + handleSubmit, + formState: { errors }, + } = useForm({ + defaultValues: { + exchange: '', + apiKey: '', + apiSecret: '', + }, + resolver: zodResolver( + z.object({ + exchange: z.string().min(1, t('validation.required')), + apiKey: z.string().min(1, t('validation.required')), + apiSecret: z.string().min(1, t('validation.required')), + }) + ), + }); + + const onSubmit = (data: { + exchange: string; + apiKey: string; + apiSecret: string; + }) => { + console.log(data); + }; + + return ( +
+ + + {t('worker.profile.apiKeyData.editApiKey')} + + + {t('worker.profile.apiKeyData.modalDescription')} + + + + ( + ( + + )} + /> + )} + /> + {errors.exchange && ( + {errors.exchange.message} + )} + + + ( + + )} + /> + {errors.apiKey && ( + {errors.apiKey.message} + )} + + + + ( + + )} + /> + + + + + + +
+ ); +} diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/identity-verification-control.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/identity-verification-control.tsx index b808043119..6acd2ab252 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/components/identity-verification-control.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/identity-verification-control.tsx @@ -29,7 +29,7 @@ export function IdentityVerificationControl() { return ( - {t('worker.profile.identityVerificationStatus')}:{' '} + {t('worker.profile.identityVerificationStatus')} diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/index.ts b/packages/apps/human-app/frontend/src/modules/worker/profile/components/index.ts index 43ff1e202d..7df62f04b8 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/components/index.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/index.ts @@ -1,3 +1,10 @@ export * from './wallet-connect-done'; -export * from './profile-actions'; export * from './profile-data'; +export * from './identity-verification-control'; +export * from './wallet-connection-control'; +export * from './staking-info'; +export * from './api-key-data'; +export * from './custom-text-field'; +export * from './add-api-key-modal'; +export * from './edit-api-key-modal'; +export * from './delete-api-key-modal'; diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/profile-actions.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/profile-actions.tsx deleted file mode 100644 index 56551421f6..0000000000 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/components/profile-actions.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import Grid from '@mui/material/Grid'; -import { IdentityVerificationControl } from './identity-verification-control'; -import { WalletConnectionControl } from './wallet-connection-control'; - -export function ProfileActions() { - return ( - - - - - - - - - - ); -} diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/profile-data.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/profile-data.tsx index b3222fe956..42278a97c8 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/components/profile-data.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/profile-data.tsx @@ -1,5 +1,4 @@ -import Grid from '@mui/material/Grid'; -import Typography from '@mui/material/Typography'; +import { Stack, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { useAuthenticatedUser } from '@/modules/auth/hooks/use-authenticated-user'; @@ -13,8 +12,8 @@ export function ProfileData() { const { user } = useAuthenticatedUser(); const { t } = useTranslation(); return ( - - + + {t('worker.profile.email')} {user.email} - - + + + + {t('worker.profile.password')} + - - + + ); } diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/staking-info.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/staking-info.tsx new file mode 100644 index 0000000000..ee7393919f --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/staking-info.tsx @@ -0,0 +1,74 @@ +import { Button, Skeleton, Stack, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { Chip } from '@/shared/components/ui/chip'; +import { env } from '@/shared/env'; +import { useAddApiKeyModal } from '../hooks/use-api-key-modals'; +import { ApiKeyData } from '.'; + +export function StakingInfo() { + const { t } = useTranslation(); + const { openModal: openAddApiKeyModal } = useAddApiKeyModal(); + + const stakedAmount = 0; + const isLoading = false; + const isError = false; + + const isStaked = stakedAmount > 0; + + return ( + + + + {t('worker.profile.stakingInfo.stakeHmt')} + + {isLoading ? ( + + ) : ( + + )} + + {!isStaked && ( + + {t('worker.profile.stakingInfo.prompt')} + + )} + + {t('worker.profile.stakingInfo.stakedAmount')} + + + {stakedAmount} HMT + + + + + + + + ); +} diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/wallet-connect-done.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/wallet-connect-done.tsx index 263b262173..808b282eb1 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/components/wallet-connect-done.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/wallet-connect-done.tsx @@ -1,57 +1,97 @@ -import { Stack, TextField, Typography } from '@mui/material'; +import { IconButton, Stack, Tooltip, Typography } from '@mui/material'; import { t } from 'i18next'; -import styled from '@mui/material/styles/styled'; -import { colorPalette } from '@/shared/styles/color-palette'; import { useAuthenticatedUser } from '@/modules/auth/hooks/use-authenticated-user'; import { useColorMode } from '@/shared/contexts/color-mode'; -import { - darkColorPalette, - onlyDarkModeColor, -} from '@/shared/styles/dark-color-palette'; import { useWalletConnect } from '@/shared/contexts/wallet-connect'; import { Chip } from '@/shared/components/ui/chip'; - -const CustomTextField = styled(TextField)(() => ({ - '& .Mui-disabled': { - color: colorPalette.text.disabledSecondary, - WebkitTextFillColor: colorPalette.text.disabledSecondary, - }, -})); -const CustomTextFieldDark = styled(TextField)(() => ({ - '& .Mui-disabled': { - color: darkColorPalette.text.disabledSecondary, - WebkitTextFillColor: darkColorPalette.text.disabledSecondary, - }, - '& .MuiOutlinedInput-root': { - '& fieldset': { - borderColor: `${onlyDarkModeColor.mainColorWithOpacity} !important`, - }, - }, -})); +import { CopyIcon } from '@/shared/components/ui/icons'; +import { MouseEvent, useRef, useState } from 'react'; +import { shortenEscrowAddress } from '@/shared/helpers/evm'; +import { CustomTextField, CustomTextFieldDark } from './custom-text-field'; export function WalletConnectDone() { + const [isCopied, setIsCopied] = useState(false); + const timeoutRef = useRef(); const { isDarkMode } = useColorMode(); const { address } = useWalletConnect(); - const { user } = useAuthenticatedUser(); + const { + user: { wallet_address }, + } = useAuthenticatedUser(); + + if (!wallet_address) { + return null; + } + + const shortAddress = shortenEscrowAddress(wallet_address, 6, 6); + + const handleCopyClick = (e: MouseEvent) => { + if (isCopied) return; + + e.stopPropagation(); + navigator.clipboard.writeText(wallet_address); + setIsCopied(true); + + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => { + setIsCopied(false); + }, 1500); + }; const textFiled = isDarkMode ? ( - + + + + + + ), + }} + /> ) : ( - + + + + + + ), + }} + /> ); return ( - + - {t('worker.profile.wallet')}:{' '} + {t('worker.profile.wallet')} - {address && !user.wallet_address ? null : textFiled} + {address && !wallet_address ? null : textFiled} ); } diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-api-key-modals.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-api-key-modals.tsx new file mode 100644 index 0000000000..3d0ff2ab5f --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-api-key-modals.tsx @@ -0,0 +1,32 @@ +import { useModal } from '@/shared/contexts/modal-context'; +import { + AddApiKeyModal, + EditApiKeyModal, + DeleteApiKeyModal, +} from '../components'; + +export function useAddApiKeyModal() { + const { openModal } = useModal(); + + return { + openModal: () => openModal({ content: }), + }; +} + +export function useEditApiKeyModal() { + const { openModal, closeModal } = useModal(); + + return { + openModal: () => + openModal({ content: }), + }; +} + +export function useDeleteApiKeyModal() { + const { openModal, closeModal } = useModal(); + + return { + openModal: () => + openModal({ content: }), + }; +} diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-staking-info.ts b/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-staking-info.ts new file mode 100644 index 0000000000..1333293ac6 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-staking-info.ts @@ -0,0 +1,77 @@ +import { useCallback, useEffect, useState } from 'react'; +import { StakerInfo, StakingClient } from '@human-protocol/sdk'; +import { useAuthenticatedUser } from '@/modules/auth/hooks/use-authenticated-user'; +import { useConnectedWallet } from '@/shared/contexts/wallet-connect'; + +export const useStakingInfo = () => { + const [stakingClient, setStakingClient] = useState( + null + ); + const [isClientInitializing, setIsClientInitializing] = useState(false); + const [isFetching, setIsFetching] = useState(true); + const [isError, setIsError] = useState(false); + const [data, setData] = useState(null); + + const { + user: { wallet_address: walletAddress }, + } = useAuthenticatedUser(); + + const { web3ProviderMutation } = useConnectedWallet(); + const { provider } = web3ProviderMutation.data || {}; + + useEffect(() => { + const initStakingClient = async () => { + if (!provider) { + return; + } + try { + setIsClientInitializing(true); + const client = await StakingClient.build(provider); + setStakingClient(client); + setIsError(false); + } catch (error) { + console.error('Failed to init staking client', error); + setStakingClient(null); + setIsError(true); + } finally { + setIsClientInitializing(false); + } + }; + + initStakingClient(); + }, [provider]); + + const fetchStakingData = useCallback(async () => { + if (stakingClient && walletAddress) { + setIsFetching(true); + try { + // const stakingInfo = await stakingClient.getStakerInfo( + // '0x63099ef7f337d85f45e2e481e78d129fb6af739d' + // ); + const stakingInfo = await stakingClient.getStakerInfo(walletAddress); + setData(stakingInfo); + setIsError(false); + } catch (error) { + setIsError(true); + console.error('Error fetching staking data', error); + return null; + } finally { + setIsFetching(false); + } + } else { + setData(null); + } + }, [stakingClient, walletAddress]); + + useEffect(() => { + if (stakingClient && walletAddress) { + fetchStakingData(); + } + }, [stakingClient, walletAddress, fetchStakingData]); + + return { + data, + isError, + isLoading: isClientInitializing || isFetching, + }; +}; diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-start-idv.ts b/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-start-idv.ts index ef40c57822..ca9bc21805 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-start-idv.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-start-idv.ts @@ -1,11 +1,13 @@ import { useEffect, useState, useCallback } from 'react'; -import { useIdvErrorNotifications } from '@/modules/worker/hooks/use-idv-notification'; import { ApiClientError } from '@/api'; import { useIdvStartMutation } from './use-start-idv-mutation'; +import { TopNotificationType, useNotification } from '@/shared/hooks'; +import { getErrorMessageForError } from '@/shared/errors'; export function useStartIdv() { const [isIdvAlreadyInProgress, setIsIdvAlreadyInProgress] = useState(false); - const onError = useIdvErrorNotifications(); + + const { showNotification } = useNotification(); const { data: idvStartData, isPending: idvStartIsPending, @@ -28,7 +30,11 @@ export function useStartIdv() { setIsIdvAlreadyInProgress(true); return; } - onError(idvStartMutationError); + showNotification({ + type: TopNotificationType.WARNING, + message: getErrorMessageForError(idvStartMutationError), + durationMs: 5000, + }); } if (idvStarted && idvStartData.url) { @@ -39,7 +45,7 @@ export function useStartIdv() { idvStartFailed, idvStarted, idvStartMutationError, - onError, + showNotification, ]); return { diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/views/profile.page.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/views/profile.page.tsx index 4f03b09c81..577cc26a86 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/views/profile.page.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/views/profile.page.tsx @@ -1,4 +1,4 @@ -import { Grid, Paper } from '@mui/material'; +import { Paper, Stack } from '@mui/material'; import { useEffect } from 'react'; import { t } from 'i18next'; import { useIsMobile } from '@/shared/hooks/use-is-mobile'; @@ -8,7 +8,12 @@ import { TopNotificationType, useNotification, } from '@/shared/hooks/use-notification'; -import { ProfileData, ProfileActions } from '../components'; +import { + ProfileData, + IdentityVerificationControl, + WalletConnectionControl, + StakingInfo, +} from '../components'; export function WorkerProfilePage() { const { user } = useAuthenticatedUser(); @@ -55,16 +60,12 @@ export function WorkerProfilePage() { justifyContent: 'center', }} > - + - - + + + {!!user.wallet_address && } + ); } diff --git a/packages/apps/human-app/frontend/src/shared/components/ui/icons.tsx b/packages/apps/human-app/frontend/src/shared/components/ui/icons.tsx index 25681beef6..f58d0b3cba 100644 --- a/packages/apps/human-app/frontend/src/shared/components/ui/icons.tsx +++ b/packages/apps/human-app/frontend/src/shared/components/ui/icons.tsx @@ -39,6 +39,12 @@ import MoonIconLight from '@/assets/icons/moon.svg'; import { useColorMode } from '@/shared/contexts/color-mode'; import WorkHeaderDark from '@/assets/icons-dark-mode/work-header.svg'; import WorkHeaderLight from '@/assets/icons/work-header.svg'; +import CopyIconLight from '@/assets/icons/content-copy.svg'; +import CopyIconDark from '@/assets/icons-dark-mode/content-copy.svg'; +import EditIconLight from '@/assets/icons/edit-icon.svg'; +import EditIconDark from '@/assets/icons-dark-mode/edit-icon.svg'; +import DeleteIconLight from '@/assets/icons/delete-icon.svg'; +import DeleteIconDark from '@/assets/icons-dark-mode/delete-icon.svg'; function HomepageLogoIcon() { const { isDarkMode } = useColorMode(); @@ -124,6 +130,18 @@ function MoonIcon() { const { isDarkMode } = useColorMode(); return isDarkMode ? : ; } +function CopyIcon() { + const { isDarkMode } = useColorMode(); + return isDarkMode ? : ; +} +function EditIcon() { + const { isDarkMode } = useColorMode(); + return isDarkMode ? : ; +} +function DeleteIcon() { + const { isDarkMode } = useColorMode(); + return isDarkMode ? : ; +} export { HomepageLogoIcon, @@ -148,4 +166,7 @@ export { SunIcon, MoonIcon, WorkHeaderIcon, + CopyIcon, + EditIcon, + DeleteIcon, }; diff --git a/packages/apps/human-app/frontend/src/shared/components/ui/modal/global-modal.tsx b/packages/apps/human-app/frontend/src/shared/components/ui/modal/global-modal.tsx index 16958d5dc7..0d44c0155c 100644 --- a/packages/apps/human-app/frontend/src/shared/components/ui/modal/global-modal.tsx +++ b/packages/apps/human-app/frontend/src/shared/components/ui/modal/global-modal.tsx @@ -1,30 +1,42 @@ -import { DialogContent } from '@mui/material'; -import DialogMui from '@mui/material/Dialog'; +import { DialogContent, IconButton, Dialog } from '@mui/material'; import { useModal } from '../../../contexts/modal-context'; -import { ModalHeader } from './modal-header'; +import CloseIcon from '@mui/icons-material/Close'; export function GlobalModal() { const { open, closeModal, showCloseButton, content, onTransitionExited } = useModal(); return ( - - - {content} - + {showCloseButton && ( + + + + )} + + {content} + + ); } diff --git a/packages/apps/human-app/frontend/src/shared/components/ui/modal/modal-header.tsx b/packages/apps/human-app/frontend/src/shared/components/ui/modal/modal-header.tsx deleted file mode 100644 index 423a295366..0000000000 --- a/packages/apps/human-app/frontend/src/shared/components/ui/modal/modal-header.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import Grid from '@mui/material/Grid'; -import Button from '@mui/material/Button'; -import { Typography } from '@mui/material'; -import { useTranslation } from 'react-i18next'; - -interface ModalHeaderElementProps { - isVisible: boolean; - onClick: () => void; -} - -interface ModalHeaderProps { - closeButton?: ModalHeaderElementProps; -} - -export function ModalHeader({ closeButton }: Readonly) { - const { t } = useTranslation(); - return ( - - - {closeButton?.isVisible && ( - - - - )} - - - ); -} diff --git a/packages/apps/human-app/frontend/src/shared/env.ts b/packages/apps/human-app/frontend/src/shared/env.ts index b442081d05..3be4fb921a 100644 --- a/packages/apps/human-app/frontend/src/shared/env.ts +++ b/packages/apps/human-app/frontend/src/shared/env.ts @@ -5,6 +5,7 @@ const envSchema = z.object({ VITE_PRIVACY_POLICY_URL: z.string(), VITE_TERMS_OF_SERVICE_URL: z.string(), VITE_HUMAN_PROTOCOL_URL: z.string(), + VITE_STAKING_DASHBOARD_URL: z.string(), VITE_NAVBAR__LINK__PROTOCOL_URL: z.string(), VITE_NAVBAR__LINK__HOW_IT_WORK_URL: z.string(), VITE_HUMAN_SUPPORT_EMAIL: z.string(), diff --git a/packages/apps/human-app/frontend/src/shared/i18n/en.json b/packages/apps/human-app/frontend/src/shared/i18n/en.json index 91bae6249a..a982fd6d1d 100644 --- a/packages/apps/human-app/frontend/src/shared/i18n/en.json +++ b/packages/apps/human-app/frontend/src/shared/i18n/en.json @@ -90,7 +90,8 @@ "pageCardError": { "reload": "Reload", "goHome": "Home Page" - } + }, + "copyToClipboard": "Copied" }, "homepage": { "humanApp": "HUMAN App", @@ -225,6 +226,34 @@ "review": "Under Review", "expired": "Expired", "abandoned": "Abandoned" + }, + "stakingStatusValues": { + "staked": "Staked", + "error": "Error", + "notStaked": "Not Staked" + }, + "stakingInfo": { + "prompt": "Before you can start earning on the HUMAN App, you’ll need to stake HMT. You can stake directly on our Staking Dashboard, or connect your exchange account to stake automatically via API KEY.", + "stakeHmt": "Stake HMT", + "stakingInfoValue": "Staking Info Value", + "stakedAmount": "Staked amount" + }, + "apiKeyData": { + "apiKey": "API KEY", + "exchange": "Exchange", + "apiSecret": "API Secret", + "apiKeyConnected": "Connected", + "apiKeyNotConnected": "Not connected", + "cancel": "Cancel", + "connectYourApiKey": "Connect your API KEY", + "connectApiKey": "Connect API KEY", + "modalDescription": "To stake HMT using your exchange balance, connect your exchange account by adding your API key and secret. Your credentials are encrypted and securely stored.", + "modalFooterAgreement": "By connecting your API KEY, you agree to HUMAN Protocol Terms of Service and consent to its Privacy Policy.", + "deleteApiKey": "Delete API KEY", + "deleteApiKeyConfirmation": "Are you sure you want to delete your API key?", + "deleteApiKeyDescription": "You can only solve jobs in the HUMAN App if you have staked HMT.", + "editApiKey": "Edit API KEY", + "saveChanges": "Save Changes" } }, "oraclesTable": { diff --git a/packages/apps/human-app/frontend/vite.config.mjs b/packages/apps/human-app/frontend/vite.config.mjs index 40943d146b..436d6c86a8 100644 --- a/packages/apps/human-app/frontend/vite.config.mjs +++ b/packages/apps/human-app/frontend/vite.config.mjs @@ -22,6 +22,9 @@ const config = defineConfig({ }, build: { target: 'esnext', + commonjsOptions: { + include: [/core/, /human-protocol-sdk/, /node_modules/], + }, }, server: { host: '127.0.0.1', @@ -29,12 +32,14 @@ const config = defineConfig({ }, optimizeDeps: { include: [ + '@human-protocol/sdk', '@mui/material', '@emotion/react', '@emotion/styled', '@mui/material/Tooltip', '@mui/material/Paper', ], + force: true, }, }); From dc256f1a3abd773996e67ac87c2bf9b13319fa9a Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Mon, 15 Dec 2025 16:37:44 +0300 Subject: [PATCH 02/17] minor fix --- .../src/modules/worker/profile/components/add-api-key-modal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/add-api-key-modal.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/add-api-key-modal.tsx index ed1d7ddbbf..66031887a0 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/components/add-api-key-modal.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/add-api-key-modal.tsx @@ -85,7 +85,7 @@ export function AddApiKeyModal() { )} Date: Tue, 16 Dec 2025 12:35:12 +0300 Subject: [PATCH 03/17] feat: an exchanges endpoint --- .../worker/hooks/use-exchange-api-keys.ts | 47 +++++++++++++++++ .../profile/components/add-api-key-modal.tsx | 4 +- .../profile/components/api-key-data.tsx | 9 +++- .../components/delete-api-key-modal.tsx | 7 +++ .../services/exchangeApiKeys.service.ts | 50 +++++++++++++++++++ 5 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 packages/apps/human-app/frontend/src/modules/worker/hooks/use-exchange-api-keys.ts create mode 100644 packages/apps/human-app/frontend/src/modules/worker/services/exchangeApiKeys.service.ts diff --git a/packages/apps/human-app/frontend/src/modules/worker/hooks/use-exchange-api-keys.ts b/packages/apps/human-app/frontend/src/modules/worker/hooks/use-exchange-api-keys.ts new file mode 100644 index 0000000000..1e85125db0 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/hooks/use-exchange-api-keys.ts @@ -0,0 +1,47 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + deleteExchangeApiKeys, + enrollExchangeApiKeys, + getExchangeApiKeys, +} from '../services/exchangeApiKeys.service'; + +function useGetExchangeApiKeys() { + return useQuery({ + queryKey: ['exchange-api-keys'], + queryFn: () => getExchangeApiKeys(), + }); +} + +function useEnrollExchangeApiKeys() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ['enroll-exchange-api-keys'], + mutationFn: (data: { + exchange: string; + apiKey: string; + apiSecret: string; + }) => enrollExchangeApiKeys(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['exchange-api-keys'] }); + }, + }); +} + +function useDeleteExchangeApiKeys() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ['delete-exchange-api-keys'], + mutationFn: () => deleteExchangeApiKeys(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['exchange-api-keys'] }); + }, + }); +} + +export { + useDeleteExchangeApiKeys, + useGetExchangeApiKeys, + useEnrollExchangeApiKeys, +}; diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/add-api-key-modal.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/add-api-key-modal.tsx index 66031887a0..a590dc5cbe 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/components/add-api-key-modal.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/add-api-key-modal.tsx @@ -12,9 +12,11 @@ import { useTranslation } from 'react-i18next'; import { z } from 'zod'; import { Button } from '@/shared/components/ui/button'; import { useIsMobile } from '@/shared/hooks'; +import { useEnrollExchangeApiKeys } from '../../hooks/use-exchange-api-keys'; export function AddApiKeyModal() { const { t } = useTranslation(); + const { mutate: enrollExchangeApiKey } = useEnrollExchangeApiKeys(); const isMobile = useIsMobile(); const { @@ -41,7 +43,7 @@ export function AddApiKeyModal() { apiKey: string; apiSecret: string; }) => { - console.log(data); + enrollExchangeApiKey(data); }; return ( diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/api-key-data.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/api-key-data.tsx index 79ce5f76a0..7e7f9e293c 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/components/api-key-data.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/api-key-data.tsx @@ -8,12 +8,14 @@ import { useDeleteApiKeyModal, useEditApiKeyModal, } from '../hooks/use-api-key-modals'; +import { useGetExchangeApiKeys } from '../../hooks/use-exchange-api-keys'; export function ApiKeyData() { const { isDarkMode } = useColorMode(); const { t } = useTranslation(); const { openModal: openEditApiKeyModal } = useEditApiKeyModal(); const { openModal: openDeleteApiKeyModal } = useDeleteApiKeyModal(); + const { data: exchangeApiKey } = useGetExchangeApiKeys(); const textField = isDarkMode ? ( {textField} - + void; @@ -9,8 +10,13 @@ interface DeleteApiKeyModalProps { export function DeleteApiKeyModal({ onClose }: DeleteApiKeyModalProps) { const { t } = useTranslation(); + const { mutate: deleteExchangeApiKey } = useDeleteExchangeApiKeys(); const isMobile = useIsMobile(); + const handleDeleteExchangeApiKey = () => { + deleteExchangeApiKey(); + }; + return ( @@ -41,6 +47,7 @@ export function DeleteApiKeyModal({ onClose }: DeleteApiKeyModalProps) { size="large" fullWidth={isMobile} sx={{ bgcolor: 'primary.light' }} + onClick={handleDeleteExchangeApiKey} > {t('worker.profile.apiKeyData.deleteApiKey')} diff --git a/packages/apps/human-app/frontend/src/modules/worker/services/exchangeApiKeys.service.ts b/packages/apps/human-app/frontend/src/modules/worker/services/exchangeApiKeys.service.ts new file mode 100644 index 0000000000..14235001c8 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/services/exchangeApiKeys.service.ts @@ -0,0 +1,50 @@ +import { ApiClientError, authorizedHumanAppApiClient } from '@/api'; + +async function getExchangeApiKeys(): Promise<{ apiKey: string }> { + try { + const response = await authorizedHumanAppApiClient.get<{ apiKey: string }>( + '/exchange-api-keys' + ); + return response; + } catch (error) { + if (error instanceof ApiClientError) { + throw error; + } + throw new Error('Failed to get exchange API keys'); + } +} + +async function enrollExchangeApiKeys(data: { + exchange: string; + apiKey: string; + apiSecret: string; +}) { + const { exchange, ...body } = data; + try { + const response = await authorizedHumanAppApiClient.post( + `/exchange-api-keys/${exchange}`, + { + body, + } + ); + return response; + } catch (error) { + if (error instanceof ApiClientError) { + throw error; + } + throw new Error('Failed to enroll exchange API keys'); + } +} + +async function deleteExchangeApiKeys(): Promise { + try { + await authorizedHumanAppApiClient.delete('/exchange-api-keys'); + } catch (error) { + if (error instanceof ApiClientError) { + throw error; + } + } + throw new Error('Failed to delete exchange API keys'); +} + +export { enrollExchangeApiKeys, getExchangeApiKeys, deleteExchangeApiKeys }; From 0d39ef104d15aa0802669f7834929b4b1ad8c169 Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Wed, 17 Dec 2025 12:33:51 +0300 Subject: [PATCH 04/17] feat: add new endpoints, UI improvements --- .../worker/hooks/use-exchange-api-keys.ts | 25 +++++- .../profile/components/add-api-key-modal.tsx | 46 ++++++++-- .../profile/components/api-key-data.tsx | 41 ++++----- .../profile/components/edit-api-key-modal.tsx | 69 +++++++++++++-- .../profile/components/staking-info.tsx | 2 +- .../profile/hooks/use-api-key-modals.tsx | 14 ++- .../services/exchangeApiKeys.service.ts | 88 +++++++++++-------- 7 files changed, 202 insertions(+), 83 deletions(-) diff --git a/packages/apps/human-app/frontend/src/modules/worker/hooks/use-exchange-api-keys.ts b/packages/apps/human-app/frontend/src/modules/worker/hooks/use-exchange-api-keys.ts index 1e85125db0..132c4332ae 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/hooks/use-exchange-api-keys.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/hooks/use-exchange-api-keys.ts @@ -3,8 +3,29 @@ import { deleteExchangeApiKeys, enrollExchangeApiKeys, getExchangeApiKeys, + getSupportedExchanges, + getStakeSummary, } from '../services/exchangeApiKeys.service'; +function useGetStakeSummary() { + return useQuery({ + queryKey: ['stake-summary'], + queryFn: () => getStakeSummary(), + }); +} + +function useGetSupportedExchanges() { + return useQuery({ + queryKey: ['supported-exchanges'], + queryFn: () => getSupportedExchanges(), + select: (data) => + data.map((exchange: string) => ({ + name: exchange, + displayName: exchange.charAt(0).toUpperCase() + exchange.slice(1), + })), + }); +} + function useGetExchangeApiKeys() { return useQuery({ queryKey: ['exchange-api-keys'], @@ -20,7 +41,7 @@ function useEnrollExchangeApiKeys() { mutationFn: (data: { exchange: string; apiKey: string; - apiSecret: string; + secretKey: string; }) => enrollExchangeApiKeys(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['exchange-api-keys'] }); @@ -41,6 +62,8 @@ function useDeleteExchangeApiKeys() { } export { + useGetStakeSummary, + useGetSupportedExchanges, useDeleteExchangeApiKeys, useGetExchangeApiKeys, useEnrollExchangeApiKeys, diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/add-api-key-modal.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/add-api-key-modal.tsx index a590dc5cbe..9835a31e8b 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/components/add-api-key-modal.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/add-api-key-modal.tsx @@ -1,6 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { Autocomplete, + Box, FormControl, FormHelperText, Stack, @@ -12,12 +13,16 @@ import { useTranslation } from 'react-i18next'; import { z } from 'zod'; import { Button } from '@/shared/components/ui/button'; import { useIsMobile } from '@/shared/hooks'; -import { useEnrollExchangeApiKeys } from '../../hooks/use-exchange-api-keys'; +import { + useEnrollExchangeApiKeys, + useGetSupportedExchanges, +} from '../../hooks/use-exchange-api-keys'; export function AddApiKeyModal() { const { t } = useTranslation(); const { mutate: enrollExchangeApiKey } = useEnrollExchangeApiKeys(); const isMobile = useIsMobile(); + const { data: supportedExchanges } = useGetSupportedExchanges(); const { control, @@ -27,13 +32,13 @@ export function AddApiKeyModal() { defaultValues: { exchange: '', apiKey: '', - apiSecret: '', + secretKey: '', }, resolver: zodResolver( z.object({ exchange: z.string().min(1, t('validation.required')), apiKey: z.string().min(1, t('validation.required')), - apiSecret: z.string().min(1, t('validation.required')), + secretKey: z.string().min(1, t('validation.required')), }) ), }); @@ -41,7 +46,7 @@ export function AddApiKeyModal() { const onSubmit = (data: { exchange: string; apiKey: string; - apiSecret: string; + secretKey: string; }) => { enrollExchangeApiKey(data); }; @@ -71,14 +76,39 @@ export function AddApiKeyModal() { control={control} render={({ field }) => ( exchange.name) || [] + } + getOptionLabel={(option) => { + const exchange = supportedExchanges?.find( + (exchange) => exchange.name === option + ); + return exchange?.displayName || option || ''; + }} renderInput={(params) => ( )} + renderOption={(props, option) => { + const exchange = supportedExchanges?.find( + (exchange) => exchange.name === option + ); + return ( + + + {exchange?.displayName || exchange?.name} + + + ); + }} + {...field} + onChange={(_, value) => field.onChange(value)} /> )} /> @@ -107,9 +137,9 @@ export function AddApiKeyModal() { )} - + ( {textField} - - - - - - - - + {exchangeApiKeyData?.exchange && ( + + openEditApiKeyModal(exchangeApiKeyData.exchange)} + > + + + + + + + )} ); diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/edit-api-key-modal.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/edit-api-key-modal.tsx index ca5e069f84..f676bc95c7 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/components/edit-api-key-modal.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/edit-api-key-modal.tsx @@ -1,6 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { Autocomplete, + Box, FormControl, FormHelperText, Stack, @@ -12,40 +13,64 @@ import { useTranslation } from 'react-i18next'; import { z } from 'zod'; import { Button } from '@/shared/components/ui/button'; import { useIsMobile } from '@/shared/hooks'; +import { useEffect } from 'react'; +import { + useEnrollExchangeApiKeys, + useGetSupportedExchanges, +} from '../../hooks/use-exchange-api-keys'; interface EditApiKeyModalProps { + exchangeName: string; + isOpen: boolean; onClose: () => void; } -export function EditApiKeyModal({ onClose }: EditApiKeyModalProps) { +export function EditApiKeyModal({ + isOpen, + onClose, + exchangeName, +}: EditApiKeyModalProps) { const { t } = useTranslation(); const isMobile = useIsMobile(); + const { mutate: postExchangeApiKey } = useEnrollExchangeApiKeys(); + const { data: supportedExchanges } = useGetSupportedExchanges(); const { control, handleSubmit, formState: { errors }, + reset, } = useForm({ defaultValues: { exchange: '', apiKey: '', - apiSecret: '', + secretKey: '', }, resolver: zodResolver( z.object({ exchange: z.string().min(1, t('validation.required')), apiKey: z.string().min(1, t('validation.required')), - apiSecret: z.string().min(1, t('validation.required')), + secretKey: z.string().min(1, t('validation.required')), }) ), }); + useEffect(() => { + if (isOpen) { + reset({ + exchange: exchangeName, + apiKey: '', + secretKey: '', + }); + } + }, [isOpen, exchangeName, reset]); + const onSubmit = (data: { exchange: string; apiKey: string; - apiSecret: string; + secretKey: string; }) => { - console.log(data); + postExchangeApiKey(data); }; return ( @@ -71,14 +96,40 @@ export function EditApiKeyModal({ onClose }: EditApiKeyModalProps) { control={control} render={({ field }) => ( exchange.name) || [] + } + getOptionLabel={(option) => { + const exchange = supportedExchanges?.find( + (exchange) => exchange.name === option + ); + return exchange?.displayName || option || ''; + }} renderInput={(params) => ( )} + renderOption={(props, option) => { + const exchange = supportedExchanges?.find( + (exchange) => exchange.name === option + ); + return ( + + + {exchange?.displayName || exchange?.name} + + + ); + }} + {...field} + disabled + onChange={(_, value) => field.onChange(value)} /> )} /> @@ -110,9 +161,9 @@ export function EditApiKeyModal({ onClose }: EditApiKeyModalProps) { )} - + ( - openModal({ content: }), + openModal: (exchangeName: string) => + openModal({ + content: ( + + ), + }), }; } diff --git a/packages/apps/human-app/frontend/src/modules/worker/services/exchangeApiKeys.service.ts b/packages/apps/human-app/frontend/src/modules/worker/services/exchangeApiKeys.service.ts index 14235001c8..cf961a8c6c 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/services/exchangeApiKeys.service.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/services/exchangeApiKeys.service.ts @@ -1,50 +1,60 @@ -import { ApiClientError, authorizedHumanAppApiClient } from '@/api'; - -async function getExchangeApiKeys(): Promise<{ apiKey: string }> { - try { - const response = await authorizedHumanAppApiClient.get<{ apiKey: string }>( - '/exchange-api-keys' - ); - return response; - } catch (error) { - if (error instanceof ApiClientError) { - throw error; - } - throw new Error('Failed to get exchange API keys'); - } +import { authorizedHumanAppApiClient } from '@/api'; + +interface StakeSummary { + exchangeStake: number; + onChainStake: number; + minThreshold: number; +} + +interface ExchangeApiKey { + apiKey: string; + exchange: string; +} + +// interface Exchange { +// name: string; +// displayName: string; +// } + +async function getStakeSummary(): Promise { + const response = await authorizedHumanAppApiClient.get( + '/exchange-api-keys/stake' + ); + return response || null; +} + +async function getSupportedExchanges(): Promise { + const response = await authorizedHumanAppApiClient.get( + '/exchange-api-keys/supported-exchanges' + ); + return response || []; +} + +async function getExchangeApiKeys(): Promise { + const response = + await authorizedHumanAppApiClient.get('/exchange-api-keys'); + return response || null; } async function enrollExchangeApiKeys(data: { exchange: string; apiKey: string; - apiSecret: string; -}) { + secretKey: string; +}): Promise { const { exchange, ...body } = data; - try { - const response = await authorizedHumanAppApiClient.post( - `/exchange-api-keys/${exchange}`, - { - body, - } - ); - return response; - } catch (error) { - if (error instanceof ApiClientError) { - throw error; - } - throw new Error('Failed to enroll exchange API keys'); - } + await authorizedHumanAppApiClient.post(`/exchange-api-keys/${exchange}`, { + body, + }); } async function deleteExchangeApiKeys(): Promise { - try { - await authorizedHumanAppApiClient.delete('/exchange-api-keys'); - } catch (error) { - if (error instanceof ApiClientError) { - throw error; - } - } - throw new Error('Failed to delete exchange API keys'); + await authorizedHumanAppApiClient.delete('/exchange-api-keys'); } -export { enrollExchangeApiKeys, getExchangeApiKeys, deleteExchangeApiKeys }; +export { + enrollExchangeApiKeys, + getExchangeApiKeys, + deleteExchangeApiKeys, + getSupportedExchanges, + getStakeSummary, +}; From 42263f4b11c96d88720e654d237307542512d433 Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Wed, 17 Dec 2025 22:40:21 +0300 Subject: [PATCH 05/17] feat: add new endpoints, handle modal states, fix some lint errors --- .../worker/hooks/use-exchange-api-keys.ts | 14 - .../src/modules/worker/hooks/use-staking.ts | 11 + .../should-navigate-to-registration.ts | 6 +- .../profile/components/add-api-key-modal.tsx | 266 +++++++++++------ .../profile/components/api-key-data.tsx | 43 ++- .../components/delete-api-key-modal.tsx | 92 ++++-- .../profile/components/edit-api-key-modal.tsx | 281 +++++++++++------- .../profile/components/modal-states.tsx | 55 ++++ .../profile/components/staking-info.tsx | 57 +++- .../profile/hooks/use-api-key-modals.tsx | 13 +- .../services/exchangeApiKeys.service.ts | 28 +- .../worker/services/staking.service.ts | 17 ++ .../shared/components/data-entry/input.tsx | 6 +- .../frontend/src/shared/i18n/en.json | 12 +- 14 files changed, 608 insertions(+), 293 deletions(-) create mode 100644 packages/apps/human-app/frontend/src/modules/worker/hooks/use-staking.ts create mode 100644 packages/apps/human-app/frontend/src/modules/worker/profile/components/modal-states.tsx create mode 100644 packages/apps/human-app/frontend/src/modules/worker/services/staking.service.ts diff --git a/packages/apps/human-app/frontend/src/modules/worker/hooks/use-exchange-api-keys.ts b/packages/apps/human-app/frontend/src/modules/worker/hooks/use-exchange-api-keys.ts index 132c4332ae..a7077d1939 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/hooks/use-exchange-api-keys.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/hooks/use-exchange-api-keys.ts @@ -4,25 +4,12 @@ import { enrollExchangeApiKeys, getExchangeApiKeys, getSupportedExchanges, - getStakeSummary, } from '../services/exchangeApiKeys.service'; -function useGetStakeSummary() { - return useQuery({ - queryKey: ['stake-summary'], - queryFn: () => getStakeSummary(), - }); -} - function useGetSupportedExchanges() { return useQuery({ queryKey: ['supported-exchanges'], queryFn: () => getSupportedExchanges(), - select: (data) => - data.map((exchange: string) => ({ - name: exchange, - displayName: exchange.charAt(0).toUpperCase() + exchange.slice(1), - })), }); } @@ -62,7 +49,6 @@ function useDeleteExchangeApiKeys() { } export { - useGetStakeSummary, useGetSupportedExchanges, useDeleteExchangeApiKeys, useGetExchangeApiKeys, diff --git a/packages/apps/human-app/frontend/src/modules/worker/hooks/use-staking.ts b/packages/apps/human-app/frontend/src/modules/worker/hooks/use-staking.ts new file mode 100644 index 0000000000..3d723d6644 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/hooks/use-staking.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query'; +import { getStakingSummary } from '../services/staking.service'; + +function useGetStakingSummary() { + return useQuery({ + queryKey: ['stake-summary'], + queryFn: () => getStakingSummary(), + }); +} + +export { useGetStakingSummary }; diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/helpers/should-navigate-to-registration.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/helpers/should-navigate-to-registration.ts index 8bd98d3628..e92ec7dc33 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/helpers/should-navigate-to-registration.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/helpers/should-navigate-to-registration.ts @@ -8,7 +8,5 @@ export const shouldNavigateToRegistration = ( oracle: Oracle, registrationData?: RegistrationResult ): boolean => - Boolean( - oracle.registrationNeeded && - !registrationData?.oracle_addresses.includes(oracle.address) - ); + !!oracle.registrationNeeded && + !registrationData?.oracle_addresses.includes(oracle.address); diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/add-api-key-modal.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/add-api-key-modal.tsx index 9835a31e8b..ce9eb36b7f 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/components/add-api-key-modal.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/add-api-key-modal.tsx @@ -17,10 +17,24 @@ import { useEnrollExchangeApiKeys, useGetSupportedExchanges, } from '../../hooks/use-exchange-api-keys'; +import { ModalError, ModalLoading, ModalSuccess } from './modal-states'; +import { useEffect } from 'react'; -export function AddApiKeyModal() { +interface AddApiKeyModalProps { + onClose: () => void; +} + +export function AddApiKeyModal({ onClose }: AddApiKeyModalProps) { const { t } = useTranslation(); - const { mutate: enrollExchangeApiKey } = useEnrollExchangeApiKeys(); + const { + mutate: enrollExchangeApiKey, + reset: resetMutation, + error, + isSuccess, + isPending, + isError, + isIdle, + } = useEnrollExchangeApiKeys(); const isMobile = useIsMobile(); const { data: supportedExchanges } = useGetSupportedExchanges(); @@ -28,6 +42,7 @@ export function AddApiKeyModal() { control, handleSubmit, formState: { errors }, + reset, } = useForm({ defaultValues: { exchange: '', @@ -43,6 +58,13 @@ export function AddApiKeyModal() { ), }); + useEffect(() => { + return () => { + reset(); + resetMutation(); + }; + }, [reset, resetMutation]); + const onSubmit = (data: { exchange: string; apiKey: string; @@ -59,106 +81,158 @@ export function AddApiKeyModal() { ? t('worker.profile.apiKeyData.connectApiKey') : t('worker.profile.apiKeyData.connectYourApiKey')} - - {t('worker.profile.apiKeyData.modalDescription')} - - - - ( - exchange.name) || [] - } - getOptionLabel={(option) => { - const exchange = supportedExchanges?.find( - (exchange) => exchange.name === option - ); - return exchange?.displayName || option || ''; - }} - renderInput={(params) => ( - } + {isIdle && ( + <> + + {t('worker.profile.apiKeyData.modalDescription')} + + + + ( + exchange.name) || + [] + } + getOptionLabel={(option) => { + const exchange = supportedExchanges?.find( + (exchange) => exchange.name === option + ); + return exchange?.display_name || option || ''; + }} + renderInput={(params) => ( + + )} + renderOption={(props, option) => { + const exchange = supportedExchanges?.find( + (exchange) => exchange.name === option + ); + return ( + + + {exchange?.display_name || exchange?.name} + + + ); + }} + {...field} + onChange={(_, value) => field.onChange(value)} /> )} - renderOption={(props, option) => { - const exchange = supportedExchanges?.find( - (exchange) => exchange.name === option - ); - return ( - - - {exchange?.displayName || exchange?.name} - - - ); - }} - {...field} - onChange={(_, value) => field.onChange(value)} /> - )} - /> - {errors.exchange && ( - {errors.exchange.message} - )} - - - ( - + {errors.exchange.message} + + )} + + + ( + + )} /> - )} - /> - {errors.apiKey && ( - {errors.apiKey.message} - )} - - - - ( - {errors.apiKey.message} + )} + + + + ( + + )} /> - )} + + + )} + {isSuccess && ( + + + {t('worker.profile.apiKeyData.connectSuccess')} + + + )} + {isError && ( + - - + )} + {isIdle && ( + + )} + {(isPending || isSuccess) && ( + + )} + {isError && ( + + )} {t('worker.profile.apiKeyData.modalFooterAgreement')} diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/api-key-data.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/api-key-data.tsx index 11eee86631..55cb3efb93 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/components/api-key-data.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/api-key-data.tsx @@ -15,19 +15,38 @@ export function ApiKeyData() { const { t } = useTranslation(); const { openModal: openEditApiKeyModal } = useEditApiKeyModal(); const { openModal: openDeleteApiKeyModal } = useDeleteApiKeyModal(); - const { data: exchangeApiKeyData } = useGetExchangeApiKeys(); + const { data: exchangeApiKeyData, isError: isExchangeApiKeyError } = + useGetExchangeApiKeys(); const textField = isDarkMode ? ( ) : ( ); @@ -38,18 +57,32 @@ export function ApiKeyData() { {t('worker.profile.apiKeyData.apiKey')} {textField} - {exchangeApiKeyData?.exchange && ( + {exchangeApiKeyData?.exchange_name && ( openEditApiKeyModal(exchangeApiKeyData.exchange)} + onClick={() => + openEditApiKeyModal(exchangeApiKeyData.exchange_name) + } > diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/delete-api-key-modal.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/delete-api-key-modal.tsx index 775521c273..6ee603150b 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/components/delete-api-key-modal.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/delete-api-key-modal.tsx @@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next'; import { Button } from '@/shared/components/ui/button'; import { useIsMobile } from '@/shared/hooks'; import { useDeleteExchangeApiKeys } from '../../hooks/use-exchange-api-keys'; +import { useEffect } from 'react'; +import { ModalError, ModalSuccess, ModalLoading } from './modal-states'; interface DeleteApiKeyModalProps { onClose: () => void; @@ -10,48 +12,98 @@ interface DeleteApiKeyModalProps { export function DeleteApiKeyModal({ onClose }: DeleteApiKeyModalProps) { const { t } = useTranslation(); - const { mutate: deleteExchangeApiKey } = useDeleteExchangeApiKeys(); + const { + mutate: deleteExchangeApiKey, + reset: resetMutation, + isSuccess, + isError, + isPending, + isIdle, + } = useDeleteExchangeApiKeys(); const isMobile = useIsMobile(); const handleDeleteExchangeApiKey = () => { deleteExchangeApiKey(); }; + useEffect(() => { + return () => { + resetMutation(); + }; + }, [resetMutation]); + return ( {t('worker.profile.apiKeyData.deleteApiKey')} - - {t('worker.profile.apiKeyData.deleteApiKeyConfirmation')} - - - {t('worker.profile.apiKeyData.deleteApiKeyDescription')} - - + {isPending && } + {isIdle && ( + <> + + {t('worker.profile.apiKeyData.deleteApiKeyConfirmation')} + + + {t('worker.profile.apiKeyData.deleteApiKeyDescription')} + + + )} + {isSuccess && ( + + + {t('worker.profile.apiKeyData.deleteKeySuccess')} + + + )} + {isError && ( + + )} + {isIdle && ( + + + + + )} + {(isPending || isSuccess) && ( + )} + {isError && ( - + )} ); } diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/edit-api-key-modal.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/edit-api-key-modal.tsx index f676bc95c7..168d531ad3 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/components/edit-api-key-modal.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/edit-api-key-modal.tsx @@ -18,21 +18,28 @@ import { useEnrollExchangeApiKeys, useGetSupportedExchanges, } from '../../hooks/use-exchange-api-keys'; +import { ModalError, ModalLoading, ModalSuccess } from './modal-states'; interface EditApiKeyModalProps { exchangeName: string; - isOpen: boolean; onClose: () => void; } export function EditApiKeyModal({ - isOpen, onClose, exchangeName, }: EditApiKeyModalProps) { const { t } = useTranslation(); const isMobile = useIsMobile(); - const { mutate: postExchangeApiKey } = useEnrollExchangeApiKeys(); + const { + mutate: postExchangeApiKey, + reset: resetMutation, + error, + isSuccess, + isError, + isPending, + isIdle, + } = useEnrollExchangeApiKeys(); const { data: supportedExchanges } = useGetSupportedExchanges(); const { @@ -56,14 +63,21 @@ export function EditApiKeyModal({ }); useEffect(() => { - if (isOpen) { + if (exchangeName && supportedExchanges) { reset({ exchange: exchangeName, apiKey: '', secretKey: '', }); } - }, [isOpen, exchangeName, reset]); + }, [exchangeName, reset, supportedExchanges]); + + useEffect(() => { + return () => { + reset(); + resetMutation(); + }; + }, [reset, resetMutation]); const onSubmit = (data: { exchange: string; @@ -79,127 +93,176 @@ export function EditApiKeyModal({ {t('worker.profile.apiKeyData.editApiKey')} - - {t('worker.profile.apiKeyData.modalDescription')} - - - - ( - exchange.name) || [] - } - getOptionLabel={(option) => { - const exchange = supportedExchanges?.find( - (exchange) => exchange.name === option - ); - return exchange?.displayName || option || ''; - }} - renderInput={(params) => ( - } + {isIdle && ( + <> + + {t('worker.profile.apiKeyData.modalDescription')} + + + + ( + exchange.name) || + [] + } + getOptionLabel={(option) => { + const exchange = supportedExchanges?.find( + (exchange) => exchange.name === option + ); + return exchange?.display_name || option || ''; + }} + renderInput={(params) => ( + + )} + renderOption={(props, option) => { + const exchange = supportedExchanges?.find( + (exchange) => exchange.name === option + ); + return ( + + + {exchange?.display_name || exchange?.name} + + + ); + }} + {...field} + onChange={(_, value) => field.onChange(value)} /> )} - renderOption={(props, option) => { - const exchange = supportedExchanges?.find( - (exchange) => exchange.name === option - ); - return ( - - - {exchange?.displayName || exchange?.name} - - - ); - }} - {...field} - disabled - onChange={(_, value) => field.onChange(value)} /> - )} - /> - {errors.exchange && ( - {errors.exchange.message} - )} - - - ( - + {errors.exchange.message} + + )} + + + ( + + )} /> - )} - /> - {errors.apiKey && ( - {errors.apiKey.message} - )} - - - - ( - {errors.apiKey.message} + )} + + + + ( + + )} /> - )} + + + )} + {isSuccess && ( + + + {t('worker.profile.apiKeyData.editSuccess')} + + + )} + {isError && ( + - - + )} + {isIdle && ( + + + + + )} + {(isPending || isSuccess) && ( + )} + {isError && ( - + )} ); diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/modal-states.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/modal-states.tsx new file mode 100644 index 0000000000..5225e3bd2b --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/modal-states.tsx @@ -0,0 +1,55 @@ +import CheckIcon from '@mui/icons-material/CheckCircle'; +import { Loader } from '@/shared/components/ui/loader'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import CloseIcon from '@mui/icons-material/Cancel'; + +export function ModalLoading() { + return ( + + + + ); +} + +export function ModalSuccess({ children }: { children: React.ReactNode }) { + return ( + <> + + + + {children} + + ); +} + +export function ModalError({ message }: { message: string }) { + return ( + <> + + + + + {message} + + + ); +} diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/staking-info.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/staking-info.tsx index e4b1e62dde..c74782c076 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/components/staking-info.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/staking-info.tsx @@ -1,19 +1,40 @@ -import { Button, Skeleton, Stack, Typography } from '@mui/material'; +import { Button, IconButton, Skeleton, Stack, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { Chip } from '@/shared/components/ui/chip'; import { env } from '@/shared/env'; import { useAddApiKeyModal } from '../hooks/use-api-key-modals'; import { ApiKeyData } from './'; +import { useGetStakingSummary } from '../../hooks/use-staking'; +import { RefreshIcon } from '@/shared/components/ui/icons'; +import { useGetExchangeApiKeys } from '../../hooks/use-exchange-api-keys'; export function StakingInfo() { const { t } = useTranslation(); const { openModal: openAddApiKeyModal } = useAddApiKeyModal(); + const { data: exchangeApiKeyData, isLoading: isExchangeApiKeyLoading } = + useGetExchangeApiKeys(); + const { + data: stakingSummary, + isLoading, + isError, + refetch, + isRefetching, + } = useGetStakingSummary(); - const stakedAmount = 0; - const isLoading = false; - const isError = false; + const isConnectButtonDisabled = + !!exchangeApiKeyData?.exchange_name || isExchangeApiKeyLoading; - const isStaked = stakedAmount > 0; + const stakedAmount = + Number(stakingSummary?.on_chain_stake || 0) + + Number(stakingSummary?.exchange_stake || 0); + + const isStaked = + stakedAmount >= Number(stakingSummary?.min_threshold || '1000'); + + const isStakingError = + !!stakingSummary?.on_chain_error || + !!stakingSummary?.exchange_error || + isError; return ( @@ -26,7 +47,7 @@ export function StakingInfo() { ) : ( {!isStaked && ( - {t('worker.profile.stakingInfo.prompt')} + {t('worker.profile.stakingInfo.prompt', { + amount: stakingSummary?.min_threshold || '1000', + })} )} - - {t('worker.profile.stakingInfo.stakedAmount')} - + + + {t('worker.profile.stakingInfo.stakedAmount')} + + (isRefetching ? undefined : refetch())} + > + + + + isConnectButtonDisabled ? undefined : openAddApiKeyModal() + } > Connect API KEY diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-api-key-modals.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-api-key-modals.tsx index ffcab2b7d6..0399862d08 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-api-key-modals.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-api-key-modals.tsx @@ -6,25 +6,22 @@ import { } from '../components'; export function useAddApiKeyModal() { - const { openModal } = useModal(); + const { openModal, closeModal } = useModal(); return { - openModal: () => openModal({ content: }), + openModal: () => + openModal({ content: }), }; } export function useEditApiKeyModal() { - const { openModal, closeModal, open } = useModal(); + const { openModal, closeModal } = useModal(); return { openModal: (exchangeName: string) => openModal({ content: ( - + ), }), }; diff --git a/packages/apps/human-app/frontend/src/modules/worker/services/exchangeApiKeys.service.ts b/packages/apps/human-app/frontend/src/modules/worker/services/exchangeApiKeys.service.ts index cf961a8c6c..41db9404e2 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/services/exchangeApiKeys.service.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/services/exchangeApiKeys.service.ts @@ -1,30 +1,17 @@ import { authorizedHumanAppApiClient } from '@/api'; -interface StakeSummary { - exchangeStake: number; - onChainStake: number; - minThreshold: number; -} - interface ExchangeApiKey { - apiKey: string; - exchange: string; + api_key: string; + exchange_name: string; } -// interface Exchange { -// name: string; -// displayName: string; -// } - -async function getStakeSummary(): Promise { - const response = await authorizedHumanAppApiClient.get( - '/exchange-api-keys/stake' - ); - return response || null; +interface Exchange { + name: string; + display_name: string; } -async function getSupportedExchanges(): Promise { - const response = await authorizedHumanAppApiClient.get( +async function getSupportedExchanges(): Promise { + const response = await authorizedHumanAppApiClient.get( '/exchange-api-keys/supported-exchanges' ); return response || []; @@ -56,5 +43,4 @@ export { getExchangeApiKeys, deleteExchangeApiKeys, getSupportedExchanges, - getStakeSummary, }; diff --git a/packages/apps/human-app/frontend/src/modules/worker/services/staking.service.ts b/packages/apps/human-app/frontend/src/modules/worker/services/staking.service.ts new file mode 100644 index 0000000000..9f9ea2b237 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/services/staking.service.ts @@ -0,0 +1,17 @@ +import { authorizedHumanAppApiClient } from '@/api'; + +interface StakeSummary { + exchange_stake: string; + exchange_error?: string; + on_chain_stake: string; + on_chain_error?: string; + min_threshold: string; +} + +async function getStakingSummary(): Promise { + const response = + await authorizedHumanAppApiClient.get('/staking/summary'); + return response || null; +} + +export { getStakingSummary }; diff --git a/packages/apps/human-app/frontend/src/shared/components/data-entry/input.tsx b/packages/apps/human-app/frontend/src/shared/components/data-entry/input.tsx index 4a8c8ada84..6c0e1ee35e 100644 --- a/packages/apps/human-app/frontend/src/shared/components/data-entry/input.tsx +++ b/packages/apps/human-app/frontend/src/shared/components/data-entry/input.tsx @@ -5,10 +5,8 @@ import { Typography } from '@mui/material'; import { type InputMask } from '@/shared/components/data-entry/input-masks'; import { useColorMode } from '@/shared/contexts/color-mode'; -export interface InputProps extends Omit< - TextFieldProps, - 'name' | 'error' | 'helperText' -> { +export interface InputProps + extends Omit { name: string; label?: string; autoComplete?: string; diff --git a/packages/apps/human-app/frontend/src/shared/i18n/en.json b/packages/apps/human-app/frontend/src/shared/i18n/en.json index a982fd6d1d..04df83ffce 100644 --- a/packages/apps/human-app/frontend/src/shared/i18n/en.json +++ b/packages/apps/human-app/frontend/src/shared/i18n/en.json @@ -233,7 +233,7 @@ "notStaked": "Not Staked" }, "stakingInfo": { - "prompt": "Before you can start earning on the HUMAN App, you’ll need to stake HMT. You can stake directly on our Staking Dashboard, or connect your exchange account to stake automatically via API KEY.", + "prompt": "Before you can start earning on the HUMAN App, you’ll need to stake HMT. You can stake directly on our Staking Dashboard, or connect your API key having minimum balance of {{amount}} HMT in your exchange account to stake automatically via API KEY.", "stakeHmt": "Stake HMT", "stakingInfoValue": "Staking Info Value", "stakedAmount": "Staked amount" @@ -245,8 +245,18 @@ "apiKeyConnected": "Connected", "apiKeyNotConnected": "Not connected", "cancel": "Cancel", + "error": "Error", + "close": "Close", + "edit": "Edit", + "tryAgain": "Try Again", "connectYourApiKey": "Connect your API KEY", "connectApiKey": "Connect API KEY", + "connectSuccess": "You have successfully connected your API key", + "connectError": "Failed to connect API key.", + "editSuccess": "You have successfully edited your API key", + "editError": "Failed to edit API key.", + "deleteKeySuccess": "You have successfully deleted your API key", + "deleteKeyError": "Failed to delete API key.", "modalDescription": "To stake HMT using your exchange balance, connect your exchange account by adding your API key and secret. Your credentials are encrypted and securely stored.", "modalFooterAgreement": "By connecting your API KEY, you agree to HUMAN Protocol Terms of Service and consent to its Privacy Policy.", "deleteApiKey": "Delete API KEY", From 2220c7251576fc0ee9c6a2292661909c009d3b58 Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Thu, 18 Dec 2025 16:18:19 +0300 Subject: [PATCH 06/17] feat: handle refresh logic --- .../src/modules/auth/context/auth-context.tsx | 1 + .../profile/components/staking-info.tsx | 36 +++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/apps/human-app/frontend/src/modules/auth/context/auth-context.tsx b/packages/apps/human-app/frontend/src/modules/auth/context/auth-context.tsx index 2b2478b5f4..13b9001fed 100644 --- a/packages/apps/human-app/frontend/src/modules/auth/context/auth-context.tsx +++ b/packages/apps/human-app/frontend/src/modules/auth/context/auth-context.tsx @@ -11,6 +11,7 @@ const userDataSchema = z.object({ user_id: z.number(), reputation_network: z.string(), exp: z.number(), + is_stake_eligible: z.boolean(), }); export type UserData = z.infer; diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/staking-info.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/staking-info.tsx index c74782c076..19bd77e181 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/components/staking-info.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/staking-info.tsx @@ -7,9 +7,16 @@ import { ApiKeyData } from './'; import { useGetStakingSummary } from '../../hooks/use-staking'; import { RefreshIcon } from '@/shared/components/ui/icons'; import { useGetExchangeApiKeys } from '../../hooks/use-exchange-api-keys'; +import { useAuthenticatedUser } from '@/modules/auth/hooks/use-authenticated-user'; +import { useEffect, useRef } from 'react'; +import { useAccessTokenRefresh } from '@/api/hooks/use-access-token-refresh'; export function StakingInfo() { + const { user } = useAuthenticatedUser(); + const { refreshAccessTokenAsync } = useAccessTokenRefresh(); const { t } = useTranslation(); + const hasAttemptedRefresh = useRef(false); + const { openModal: openAddApiKeyModal } = useAddApiKeyModal(); const { data: exchangeApiKeyData, isLoading: isExchangeApiKeyLoading } = useGetExchangeApiKeys(); @@ -36,6 +43,31 @@ export function StakingInfo() { !!stakingSummary?.exchange_error || isError; + useEffect(() => { + if (isRefetching || isLoading) return; + + if (isStaked !== user.is_stake_eligible) { + if (!hasAttemptedRefresh.current) { + hasAttemptedRefresh.current = true; + refreshAccessTokenAsync({ authType: 'web2' }); + } + } else { + hasAttemptedRefresh.current = false; + } + }, [ + isStaked, + user.is_stake_eligible, + refreshAccessTokenAsync, + isRefetching, + isLoading, + ]); + + const handleRefreshStakingInfo = () => { + if (isRefetching || isLoading) return; + hasAttemptedRefresh.current = false; + refetch(); + }; + return ( @@ -69,9 +101,9 @@ export function StakingInfo() { {t('worker.profile.stakingInfo.stakedAmount')} (isRefetching ? undefined : refetch())} + onClick={handleRefreshStakingInfo} > From 570df0652722d0909c78dc7fd33940539e4e07c6 Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Thu, 18 Dec 2025 17:23:11 +0300 Subject: [PATCH 07/17] feat: disable jobs related routes, if not stake eligible --- .../worker/providers/require-stake.tsx | 25 +++++++++++++ .../drawer-menu-items-worker.tsx | 5 ++- .../human-app/frontend/src/router/router.tsx | 35 ++++++++++--------- 3 files changed, 48 insertions(+), 17 deletions(-) create mode 100644 packages/apps/human-app/frontend/src/modules/worker/providers/require-stake.tsx diff --git a/packages/apps/human-app/frontend/src/modules/worker/providers/require-stake.tsx b/packages/apps/human-app/frontend/src/modules/worker/providers/require-stake.tsx new file mode 100644 index 0000000000..1eff2ddd2e --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/providers/require-stake.tsx @@ -0,0 +1,25 @@ +import { useAuthenticatedUser } from '@/modules/auth/hooks/use-authenticated-user'; +import { routerPaths } from '@/router/router-paths'; +import { protectedRoutes } from '@/router/routes'; +import { Navigate, useLocation, matchPath } from 'react-router-dom'; + +const stakeProtectedPaths = protectedRoutes + .map((route) => route.routerProps.path) + .filter((path) => path !== routerPaths.worker.profile); + +export function RequireStake({ + children, +}: Readonly<{ children: JSX.Element }>) { + const { user } = useAuthenticatedUser(); + const location = useLocation(); + + const isStakeProtectedRoute = stakeProtectedPaths.some( + (path) => path && matchPath(path, location.pathname) + ); + + if (!user?.is_stake_eligible && isStakeProtectedRoute) { + return ; + } + + return children; +} diff --git a/packages/apps/human-app/frontend/src/router/components/drawer-menu-items/drawer-menu-items-worker.tsx b/packages/apps/human-app/frontend/src/router/components/drawer-menu-items/drawer-menu-items-worker.tsx index 427bba124b..a8d8c137b4 100644 --- a/packages/apps/human-app/frontend/src/router/components/drawer-menu-items/drawer-menu-items-worker.tsx +++ b/packages/apps/human-app/frontend/src/router/components/drawer-menu-items/drawer-menu-items-worker.tsx @@ -16,7 +16,10 @@ export const workerDrawerTopMenuItems = (user: UserData | null): MenuItem[] => { label: t('components.DrawerNavigation.jobs'), icon: , link: routerPaths.worker.jobsDiscovery, - disabled: !user?.wallet_address || user.kyc_status !== KycStatus.APPROVED, + disabled: + !user?.wallet_address || + !user?.is_stake_eligible || + user.kyc_status !== KycStatus.APPROVED, }, ]; }; diff --git a/packages/apps/human-app/frontend/src/router/router.tsx b/packages/apps/human-app/frontend/src/router/router.tsx index b22c8052d8..2d53f9be63 100644 --- a/packages/apps/human-app/frontend/src/router/router.tsx +++ b/packages/apps/human-app/frontend/src/router/router.tsx @@ -20,6 +20,7 @@ import { workerDrawerBottomMenuItems, workerDrawerTopMenuItems, } from './components'; +import { RequireStake } from '@/modules/worker/providers/require-stake'; export function Router() { const { user } = useAuth(); @@ -57,22 +58,24 @@ export function Router() { - ( - - )} - renderHCaptchaStatisticsDrawer={(isOpen) => ( - - )} - renderGovernanceBanner - /> + + ( + + )} + renderHCaptchaStatisticsDrawer={(isOpen) => ( + + )} + renderGovernanceBanner + /> + } key={routerProps.path} From bb94c1dd8c871fd6b37b9c728eed1e84e82d5dc5 Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Thu, 18 Dec 2025 17:29:04 +0300 Subject: [PATCH 08/17] fix: remove redundant file --- .../worker/profile/hooks/use-staking-info.ts | 77 ------------------- 1 file changed, 77 deletions(-) delete mode 100644 packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-staking-info.ts diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-staking-info.ts b/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-staking-info.ts deleted file mode 100644 index 1333293ac6..0000000000 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-staking-info.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { StakerInfo, StakingClient } from '@human-protocol/sdk'; -import { useAuthenticatedUser } from '@/modules/auth/hooks/use-authenticated-user'; -import { useConnectedWallet } from '@/shared/contexts/wallet-connect'; - -export const useStakingInfo = () => { - const [stakingClient, setStakingClient] = useState( - null - ); - const [isClientInitializing, setIsClientInitializing] = useState(false); - const [isFetching, setIsFetching] = useState(true); - const [isError, setIsError] = useState(false); - const [data, setData] = useState(null); - - const { - user: { wallet_address: walletAddress }, - } = useAuthenticatedUser(); - - const { web3ProviderMutation } = useConnectedWallet(); - const { provider } = web3ProviderMutation.data || {}; - - useEffect(() => { - const initStakingClient = async () => { - if (!provider) { - return; - } - try { - setIsClientInitializing(true); - const client = await StakingClient.build(provider); - setStakingClient(client); - setIsError(false); - } catch (error) { - console.error('Failed to init staking client', error); - setStakingClient(null); - setIsError(true); - } finally { - setIsClientInitializing(false); - } - }; - - initStakingClient(); - }, [provider]); - - const fetchStakingData = useCallback(async () => { - if (stakingClient && walletAddress) { - setIsFetching(true); - try { - // const stakingInfo = await stakingClient.getStakerInfo( - // '0x63099ef7f337d85f45e2e481e78d129fb6af739d' - // ); - const stakingInfo = await stakingClient.getStakerInfo(walletAddress); - setData(stakingInfo); - setIsError(false); - } catch (error) { - setIsError(true); - console.error('Error fetching staking data', error); - return null; - } finally { - setIsFetching(false); - } - } else { - setData(null); - } - }, [stakingClient, walletAddress]); - - useEffect(() => { - if (stakingClient && walletAddress) { - fetchStakingData(); - } - }, [stakingClient, walletAddress, fetchStakingData]); - - return { - data, - isError, - isLoading: isClientInitializing || isFetching, - }; -}; From 37e60493ccb511bedc04855964fec0be8e06b3ae Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Thu, 18 Dec 2025 18:09:13 +0300 Subject: [PATCH 09/17] fix prettier error --- .../frontend/src/shared/components/data-entry/input.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/apps/human-app/frontend/src/shared/components/data-entry/input.tsx b/packages/apps/human-app/frontend/src/shared/components/data-entry/input.tsx index 6c0e1ee35e..ba1cb12ec9 100644 --- a/packages/apps/human-app/frontend/src/shared/components/data-entry/input.tsx +++ b/packages/apps/human-app/frontend/src/shared/components/data-entry/input.tsx @@ -5,8 +5,8 @@ import { Typography } from '@mui/material'; import { type InputMask } from '@/shared/components/data-entry/input-masks'; import { useColorMode } from '@/shared/contexts/color-mode'; -export interface InputProps - extends Omit { +type OmittedProps = Omit; +export interface InputProps extends OmittedProps { name: string; label?: string; autoComplete?: string; From bc5ec8548ebaf37321c6a403267fa49465d8f04f Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Thu, 18 Dec 2025 18:18:41 +0300 Subject: [PATCH 10/17] chore: invalidate staking-summary request on adding/deleting api key --- .../frontend/src/modules/worker/hooks/use-exchange-api-keys.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/apps/human-app/frontend/src/modules/worker/hooks/use-exchange-api-keys.ts b/packages/apps/human-app/frontend/src/modules/worker/hooks/use-exchange-api-keys.ts index a7077d1939..c958791ca7 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/hooks/use-exchange-api-keys.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/hooks/use-exchange-api-keys.ts @@ -32,6 +32,7 @@ function useEnrollExchangeApiKeys() { }) => enrollExchangeApiKeys(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['exchange-api-keys'] }); + queryClient.invalidateQueries({ queryKey: ['staking-summary'] }); }, }); } @@ -44,6 +45,7 @@ function useDeleteExchangeApiKeys() { mutationFn: () => deleteExchangeApiKeys(), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['exchange-api-keys'] }); + queryClient.invalidateQueries({ queryKey: ['staking-summary'] }); }, }); } From 25ad1ad6ef788ab1b796265ab2464345fb07186a Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Thu, 18 Dec 2025 18:31:15 +0300 Subject: [PATCH 11/17] fix: rename query key --- .../human-app/frontend/src/modules/worker/hooks/use-staking.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/apps/human-app/frontend/src/modules/worker/hooks/use-staking.ts b/packages/apps/human-app/frontend/src/modules/worker/hooks/use-staking.ts index 3d723d6644..1556c072d6 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/hooks/use-staking.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/hooks/use-staking.ts @@ -3,7 +3,7 @@ import { getStakingSummary } from '../services/staking.service'; function useGetStakingSummary() { return useQuery({ - queryKey: ['stake-summary'], + queryKey: ['staking-summary'], queryFn: () => getStakingSummary(), }); } From 923f63dabe3d7ab3958d74cb0b878002b2288dc6 Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Fri, 19 Dec 2025 15:11:57 +0300 Subject: [PATCH 12/17] chore: address feedback on ui --- .../profile/components/add-api-key-modal.tsx | 16 +++- .../profile/components/custom-text-field.tsx | 4 + .../components/delete-api-key-modal.tsx | 12 ++- .../profile/components/edit-api-key-modal.tsx | 15 ++- .../profile/components/staking-info.tsx | 92 +++++++++++++------ .../profile/hooks/use-api-key-modals.tsx | 27 ++++-- .../components/ui/modal/global-modal.tsx | 15 ++- .../src/shared/contexts/modal-context.tsx | 19 +++- .../frontend/src/shared/i18n/en.json | 5 +- .../frontend/src/shared/styles/theme.ts | 7 ++ 10 files changed, 166 insertions(+), 46 deletions(-) diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/add-api-key-modal.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/add-api-key-modal.tsx index ce9eb36b7f..f3b1ee6954 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/components/add-api-key-modal.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/add-api-key-modal.tsx @@ -22,9 +22,10 @@ import { useEffect } from 'react'; interface AddApiKeyModalProps { onClose: () => void; + disableClose: (disable: boolean) => void; } -export function AddApiKeyModal({ onClose }: AddApiKeyModalProps) { +export function AddApiKeyModal({ onClose, disableClose }: AddApiKeyModalProps) { const { t } = useTranslation(); const { mutate: enrollExchangeApiKey, @@ -38,6 +39,10 @@ export function AddApiKeyModal({ onClose }: AddApiKeyModalProps) { const isMobile = useIsMobile(); const { data: supportedExchanges } = useGetSupportedExchanges(); + useEffect(() => { + disableClose(isPending); + }, [isPending, disableClose]); + const { control, handleSubmit, @@ -52,8 +57,8 @@ export function AddApiKeyModal({ onClose }: AddApiKeyModalProps) { resolver: zodResolver( z.object({ exchange: z.string().min(1, t('validation.required')), - apiKey: z.string().min(1, t('validation.required')), - secretKey: z.string().min(1, t('validation.required')), + apiKey: z.string().trim().min(1, t('validation.required')), + secretKey: z.string().trim().min(1, t('validation.required')), }) ), }); @@ -182,6 +187,11 @@ export function AddApiKeyModal({ onClose }: AddApiKeyModalProps) { /> )} /> + {errors.secretKey && ( + + {errors.secretKey.message} + + )} )} diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/custom-text-field.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/custom-text-field.tsx index b4ed47d39b..60c76c6df4 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/components/custom-text-field.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/custom-text-field.tsx @@ -16,6 +16,8 @@ const CustomTextField = styled(TextField)(() => ({ '& fieldset': { border: '1px dashed', borderColor: `${colorPalette.text.primary} !important`, + color: colorPalette.text.disabledSecondary, + WebkitTextFillColor: colorPalette.text.disabledSecondary, }, }, })); @@ -31,6 +33,8 @@ const CustomTextFieldDark = styled(TextField)(() => ({ '& fieldset': { border: '1px dashed', borderColor: `${onlyDarkModeColor.mainColorWithOpacity} !important`, + color: darkColorPalette.text.disabledSecondary, + WebkitTextFillColor: darkColorPalette.text.disabledSecondary, }, }, })); diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/delete-api-key-modal.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/delete-api-key-modal.tsx index 6ee603150b..ebeb62c625 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/components/delete-api-key-modal.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/delete-api-key-modal.tsx @@ -8,9 +8,13 @@ import { ModalError, ModalSuccess, ModalLoading } from './modal-states'; interface DeleteApiKeyModalProps { onClose: () => void; + disableClose: (disable: boolean) => void; } -export function DeleteApiKeyModal({ onClose }: DeleteApiKeyModalProps) { +export function DeleteApiKeyModal({ + onClose, + disableClose, +}: DeleteApiKeyModalProps) { const { t } = useTranslation(); const { mutate: deleteExchangeApiKey, @@ -26,6 +30,10 @@ export function DeleteApiKeyModal({ onClose }: DeleteApiKeyModalProps) { deleteExchangeApiKey(); }; + useEffect(() => { + disableClose(isPending); + }, [isPending, disableClose]); + useEffect(() => { return () => { resetMutation(); @@ -88,7 +96,7 @@ export function DeleteApiKeyModal({ onClose }: DeleteApiKeyModalProps) { + {t('worker.profile.stakingInfo.stakedAmount')} svg > path': { + fill: + isRefetching || isLoading + ? colorPalette.button.disabled + : 'primary.main', + }, + }} onClick={handleRefreshStakingInfo} > - - {stakedAmount} HMT - + {isLoading || isRefetching ? ( + + ) : ( + + {stakedAmount} HMT + + )} From c335147f0aaa01c0d9766a02cf6bd7b0b96be08f Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Mon, 22 Dec 2025 16:33:25 +0300 Subject: [PATCH 16/17] feat: add provider for ui config, add ff logic --- packages/apps/human-app/frontend/src/main.tsx | 44 +++++++++++-------- .../hooks/use-available-jobs-filter-modal.tsx | 6 +-- .../src/modules/worker/jobs/jobs.page.tsx | 16 +++---- .../hooks/use-my-jobs-filter-modal.tsx | 6 +-- .../worker/profile/views/profile.page.tsx | 11 ++++- .../worker/providers/require-stake.tsx | 8 +++- .../drawer-menu-items-worker.tsx | 8 +++- .../human-app/frontend/src/router/router.tsx | 4 +- .../shared/providers/ui-config-provider.tsx | 44 +++++++++++++++++++ .../src/shared/services/ui-config.service.ts | 3 +- 10 files changed, 109 insertions(+), 41 deletions(-) create mode 100644 packages/apps/human-app/frontend/src/shared/providers/ui-config-provider.tsx diff --git a/packages/apps/human-app/frontend/src/main.tsx b/packages/apps/human-app/frontend/src/main.tsx index ccf792a2b6..bf9aaa190f 100644 --- a/packages/apps/human-app/frontend/src/main.tsx +++ b/packages/apps/human-app/frontend/src/main.tsx @@ -23,6 +23,7 @@ import { HomePageStateProvider } from '@/shared/contexts/homepage-state'; import { NotificationProvider } from '@/shared/providers/notifications-provider'; import { ModalProvider } from './shared/contexts/modal-context'; import { GlobalModal } from './shared/components/ui/modal/global-modal'; +import { UiConfigProvider } from './shared/providers/ui-config-provider'; const root = document.getElementById('root'); if (!root) throw Error('root element is undefined'); @@ -39,25 +40,30 @@ createRoot(root).render( - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/use-available-jobs-filter-modal.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/use-available-jobs-filter-modal.tsx index f90e919961..6ce55c5c48 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/use-available-jobs-filter-modal.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/use-available-jobs-filter-modal.tsx @@ -1,17 +1,17 @@ import { useModal } from '@/shared/contexts/modal-context'; -import { useGetUiConfig } from '@/shared/hooks'; +import { useUiConfig } from '@/shared/providers/ui-config-provider'; import { AvailableJobsFilterModal } from '../available-jobs-filter-modal'; export function useAvailableJobsFilterModal() { const { openModal, closeModal } = useModal(); - const { data: uiConfigData } = useGetUiConfig(); + const { uiConfig } = useUiConfig(); return { openModal: () => { openModal({ content: ( ), diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/jobs.page.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/jobs.page.tsx index 5e65b627ae..21364ccedf 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs/jobs.page.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/jobs.page.tsx @@ -7,7 +7,7 @@ import { useIsMobile } from '@/shared/hooks/use-is-mobile'; import { useColorMode } from '@/shared/contexts/color-mode'; import { NoRecords } from '@/shared/components/ui/no-records'; import { PageCardLoader } from '@/shared/components/ui/page-card'; -import { useGetUiConfig } from '@/shared/hooks'; +import { useUiConfig } from '@/shared/providers/ui-config-provider'; import { useGetOracles } from '../hooks'; import { useGetOraclesNotifications } from '../hooks/use-get-oracles-notifications'; import { TabPanel } from './components'; @@ -30,19 +30,15 @@ export function JobsPage() { error, } = useGetOracles(); - const { - data: uiConfigData, - isPending: isPendingUiConfig, - isError: isErrorUiConfig, - } = useGetUiConfig(); + const { uiConfig, isUiConfigLoading, isUiConfigError } = useUiConfig(); const { address: oracle_address } = useParams<{ address: string }>(); const { t } = useTranslation(); const [activeTab, setActiveTab] = useState(0); const isMobile = useIsMobile(); - const isError = isErrorGetOracles || isErrorUiConfig; - const isPending = isPendingGetOracles || isPendingUiConfig; + const isError = isErrorGetOracles || isUiConfigError; + const isPending = isPendingGetOracles || isUiConfigLoading; const { onError } = useGetOraclesNotifications(); const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { @@ -119,7 +115,7 @@ export function JobsPage() { ) : ( )} @@ -128,7 +124,7 @@ export function JobsPage() { ) : ( )} diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/hooks/use-my-jobs-filter-modal.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/hooks/use-my-jobs-filter-modal.tsx index 40a0c9c1be..a41ca0c491 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/hooks/use-my-jobs-filter-modal.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/hooks/use-my-jobs-filter-modal.tsx @@ -1,17 +1,17 @@ import { useModal } from '@/shared/contexts/modal-context'; -import { useGetUiConfig } from '@/shared/hooks'; import { MyJobsFilterModal } from '../components/mobile/my-jobs-filter-modal'; +import { useUiConfig } from '@/shared/providers/ui-config-provider'; export function useMyJobFilterModal() { const { openModal, closeModal } = useModal(); - const { data: uiConfigData } = useGetUiConfig(); + const { uiConfig } = useUiConfig(); return { openModal: () => { openModal({ content: ( ), diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/views/profile.page.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/views/profile.page.tsx index 577cc26a86..3fa42e64b9 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/views/profile.page.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/views/profile.page.tsx @@ -14,6 +14,8 @@ import { WalletConnectionControl, StakingInfo, } from '../components'; +import { PageCardLoader } from '@/shared/components/ui/page-card/page-card-loader'; +import { useUiConfig } from '@/shared/providers/ui-config-provider'; export function WorkerProfilePage() { const { user } = useAuthenticatedUser(); @@ -21,6 +23,7 @@ export function WorkerProfilePage() { const { isConnected, initializing, web3ProviderMutation } = useWalletConnect(); const { showNotification } = useNotification(); + const { uiConfig, isUiConfigLoading } = useUiConfig(); useEffect(() => { if (initializing) return; @@ -47,6 +50,10 @@ export function WorkerProfilePage() { showNotification, ]); + if (isUiConfigLoading) { + return ; + } + return ( - {!!user.wallet_address && } + {!!user.wallet_address && uiConfig?.stakingEligibilityEnabled && ( + + )} ); diff --git a/packages/apps/human-app/frontend/src/modules/worker/providers/require-stake.tsx b/packages/apps/human-app/frontend/src/modules/worker/providers/require-stake.tsx index 1eff2ddd2e..c9e9843718 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/providers/require-stake.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/providers/require-stake.tsx @@ -1,6 +1,7 @@ import { useAuthenticatedUser } from '@/modules/auth/hooks/use-authenticated-user'; import { routerPaths } from '@/router/router-paths'; import { protectedRoutes } from '@/router/routes'; +import { useUiConfig } from '@/shared/providers/ui-config-provider'; import { Navigate, useLocation, matchPath } from 'react-router-dom'; const stakeProtectedPaths = protectedRoutes @@ -12,12 +13,17 @@ export function RequireStake({ }: Readonly<{ children: JSX.Element }>) { const { user } = useAuthenticatedUser(); const location = useLocation(); + const { uiConfig } = useUiConfig(); const isStakeProtectedRoute = stakeProtectedPaths.some( (path) => path && matchPath(path, location.pathname) ); - if (!user?.is_stake_eligible && isStakeProtectedRoute) { + if ( + uiConfig?.stakingEligibilityEnabled && + !user?.is_stake_eligible && + isStakeProtectedRoute + ) { return ; } diff --git a/packages/apps/human-app/frontend/src/router/components/drawer-menu-items/drawer-menu-items-worker.tsx b/packages/apps/human-app/frontend/src/router/components/drawer-menu-items/drawer-menu-items-worker.tsx index a8d8c137b4..5c39359e16 100644 --- a/packages/apps/human-app/frontend/src/router/components/drawer-menu-items/drawer-menu-items-worker.tsx +++ b/packages/apps/human-app/frontend/src/router/components/drawer-menu-items/drawer-menu-items-worker.tsx @@ -9,8 +9,12 @@ import type { UserData } from '@/modules/auth/context/auth-context'; import { routerPaths } from '@/router/router-paths'; import { KycStatus } from '@/modules/worker/profile/types'; import { type MenuItem } from '../layout/protected'; +import { UiConfig } from '@/shared/services/ui-config.service'; -export const workerDrawerTopMenuItems = (user: UserData | null): MenuItem[] => { +export const workerDrawerTopMenuItems = ( + user: UserData | null, + uiConfig: UiConfig | undefined +): MenuItem[] => { return [ { label: t('components.DrawerNavigation.jobs'), @@ -18,7 +22,7 @@ export const workerDrawerTopMenuItems = (user: UserData | null): MenuItem[] => { link: routerPaths.worker.jobsDiscovery, disabled: !user?.wallet_address || - !user?.is_stake_eligible || + (uiConfig?.stakingEligibilityEnabled && !user?.is_stake_eligible) || user.kyc_status !== KycStatus.APPROVED, }, ]; diff --git a/packages/apps/human-app/frontend/src/router/router.tsx b/packages/apps/human-app/frontend/src/router/router.tsx index 2d53f9be63..0d27ce4c46 100644 --- a/packages/apps/human-app/frontend/src/router/router.tsx +++ b/packages/apps/human-app/frontend/src/router/router.tsx @@ -21,9 +21,11 @@ import { workerDrawerTopMenuItems, } from './components'; import { RequireStake } from '@/modules/worker/providers/require-stake'; +import { useUiConfig } from '@/shared/providers/ui-config-provider'; export function Router() { const { user } = useAuth(); + const { uiConfig } = useUiConfig(); const handleSignOut = () => { browserAuthProvider.signOut({ @@ -67,7 +69,7 @@ export function Router() { open={open} setDrawerOpen={setDrawerOpen} signOut={handleSignOut} - topMenuItems={workerDrawerTopMenuItems(user)} + topMenuItems={workerDrawerTopMenuItems(user, uiConfig)} /> )} renderHCaptchaStatisticsDrawer={(isOpen) => ( diff --git a/packages/apps/human-app/frontend/src/shared/providers/ui-config-provider.tsx b/packages/apps/human-app/frontend/src/shared/providers/ui-config-provider.tsx new file mode 100644 index 0000000000..5f23cd7d49 --- /dev/null +++ b/packages/apps/human-app/frontend/src/shared/providers/ui-config-provider.tsx @@ -0,0 +1,44 @@ +import React, { createContext, useContext, useMemo } from 'react'; +import { useGetUiConfig } from '../hooks/use-get-ui-config'; +import { UiConfig } from '../services/ui-config.service'; + +interface UiConfigContextType { + uiConfig: UiConfig | undefined; + isUiConfigLoading: boolean; + isUiConfigError: boolean; + error: Error | null; +} + +const UiConfigContext = createContext( + undefined +); + +export function UiConfigProvider({ + children, +}: Readonly<{ children: React.ReactNode }>) { + const { data, isLoading, error, isError } = useGetUiConfig(); + + const contextValue = useMemo( + () => ({ + uiConfig: data, + isUiConfigLoading: isLoading, + isUiConfigError: isError, + error, + }), + [data, isLoading, isError, error] + ); + + return ( + + {children} + + ); +} + +export const useUiConfig = () => { + const context = useContext(UiConfigContext); + if (!context) { + throw new Error('useUiConfig must be used within a UiConfigProvider'); + } + return context; +}; diff --git a/packages/apps/human-app/frontend/src/shared/services/ui-config.service.ts b/packages/apps/human-app/frontend/src/shared/services/ui-config.service.ts index 4343540456..97d418ec9b 100644 --- a/packages/apps/human-app/frontend/src/shared/services/ui-config.service.ts +++ b/packages/apps/human-app/frontend/src/shared/services/ui-config.service.ts @@ -7,9 +7,10 @@ const apiPaths = { const uiConfigSchema = z.object({ chainIdsEnabled: z.array(z.number()), + stakingEligibilityEnabled: z.boolean().prefault(false), }); -type UiConfig = z.infer; +export type UiConfig = z.infer; async function getUiConfig() { try { From bf074ff8cbcbd498725a576dcbc93b91ded9d9d1 Mon Sep 17 00:00:00 2001 From: Dmitry Nechay Date: Mon, 22 Dec 2025 18:03:08 +0300 Subject: [PATCH 17/17] feat: staking eligibility in ui config --- .../reputation-oracle.gateway.ts | 3 +- .../job-assignment.controller.ts | 11 +++++-- .../modules/staking/model/staking.model.ts | 29 +++++++------------ .../staking/spec/staking.controller.spec.ts | 9 ------ .../modules/staking/spec/staking.fixtures.ts | 8 ++--- .../staking/spec/staking.service.spec.ts | 4 +-- .../src/modules/staking/staking.controller.ts | 13 +-------- .../src/modules/staking/staking.service.ts | 4 +-- .../ui-configuration.controller.spec.ts | 25 ++++++++++++++-- .../ui-configuration.controller.ts | 7 ++++- .../ui-configuration/ui-configuration.dto.ts | 6 ++++ .../ui-configuration.module.ts | 2 ++ .../src/modules/staking/staking.controller.ts | 4 ++- 13 files changed, 68 insertions(+), 57 deletions(-) diff --git a/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.gateway.ts b/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.gateway.ts index 2d9b643fc2..618833409a 100644 --- a/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.gateway.ts +++ b/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.gateway.ts @@ -197,11 +197,10 @@ export class ReputationOracleGateway { return this.handleRequestToReputationOracle(options); } - async getStakeConfig(token: string): Promise { + async getStakeConfig(): Promise { const options = this.getEndpointOptions( ReputationOracleEndpoints.STAKE_CONFIG, undefined, - token, ); return this.handleRequestToReputationOracle(options); } diff --git a/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.controller.ts b/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.controller.ts index 7eccf4556c..bbfa432d1c 100644 --- a/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.controller.ts +++ b/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.controller.ts @@ -28,11 +28,16 @@ import { import { ChainId } from '@human-protocol/sdk'; import axios from 'axios'; import { JobStatus } from '../../common/enums/global-common'; +import logger from '../../logger'; @ApiTags('Job-Assignment') @ApiBearerAuth() @Controller('/assignment') export class JobAssignmentController { + private readonly logger = logger.child({ + context: JobAssignmentController.name, + }); + constructor( private readonly service: JobAssignmentService, @InjectMapper() private readonly mapper: Mapper, @@ -85,8 +90,10 @@ export class JobAssignmentController { expires_at: process.env.THIRSTYFI_TASK_EXPIRATION_DATE ?? '', }; } catch (error) { - // eslint-disable-next-line no-console - console.error(error); + this.logger.error('Failed to assign thirstyfi job', { + userId: req.user.user_id, + error, + }); throw new BadRequestException(error.response.data.error); } } diff --git a/packages/apps/human-app/server/src/modules/staking/model/staking.model.ts b/packages/apps/human-app/server/src/modules/staking/model/staking.model.ts index 9137e68931..4bfe201ea6 100644 --- a/packages/apps/human-app/server/src/modules/staking/model/staking.model.ts +++ b/packages/apps/human-app/server/src/modules/staking/model/staking.model.ts @@ -1,30 +1,21 @@ -import { AutoMap } from '@automapper/classes'; -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class StakeSummaryResponse { - @ApiProperty({ name: 'exchange_stake' }) - @AutoMap() - exchangeStake: number; + @ApiProperty() + exchange_stake: number; @ApiProperty({ name: 'on_chain_stake' }) - @AutoMap() - onChainStake: number; + on_chain_stake: number; - @ApiProperty({ name: 'exchange_error', required: false, nullable: true }) - @AutoMap() - exchangeError?: string; + @ApiPropertyOptional() + exchange_error?: string | null; - @ApiProperty({ name: 'on_chain_error', required: false, nullable: true }) - @AutoMap() - onChainError?: string; + @ApiPropertyOptional() + on_chain_error?: string | null; } export class StakeConfigResponse { - @ApiProperty({ name: 'min_threshold' }) - @AutoMap() - minThreshold: number; + min_threshold: number; - @ApiProperty({ name: 'eligibility_enabled' }) - @AutoMap() - eligibilityEnabled: boolean; + eligibility_enabled: boolean; } diff --git a/packages/apps/human-app/server/src/modules/staking/spec/staking.controller.spec.ts b/packages/apps/human-app/server/src/modules/staking/spec/staking.controller.spec.ts index db3a445fa4..f92224d522 100644 --- a/packages/apps/human-app/server/src/modules/staking/spec/staking.controller.spec.ts +++ b/packages/apps/human-app/server/src/modules/staking/spec/staking.controller.spec.ts @@ -47,13 +47,4 @@ describe('StakingController', () => { expect(result).toEqual(stakingServiceMock.getStakeSummary(TOKEN)); }); }); - - describe('getStakeConfig', () => { - it('should call service.getStakeConfig with token and return response', async () => { - const req: RequestWithUser = { token: TOKEN } as RequestWithUser; - const result = await controller.getStakeConfig(req); - expect(service.getStakeConfig).toHaveBeenCalledWith(TOKEN); - expect(result).toEqual(stakingServiceMock.getStakeConfig(TOKEN)); - }); - }); }); diff --git a/packages/apps/human-app/server/src/modules/staking/spec/staking.fixtures.ts b/packages/apps/human-app/server/src/modules/staking/spec/staking.fixtures.ts index 00e0298a49..93699eef96 100644 --- a/packages/apps/human-app/server/src/modules/staking/spec/staking.fixtures.ts +++ b/packages/apps/human-app/server/src/modules/staking/spec/staking.fixtures.ts @@ -1,11 +1,11 @@ export const TOKEN = 'test_user_token'; export const stakeSummaryResponseFixture = { - exchangeStake: '1000', - onChainStake: '500', + exchange_stake: '1000', + on_chain_stake: '500', }; export const stakeConfigResponseFixture = { - minThreshold: '1000', - eligibilityEnabled: true, + min_threshold: '1000', + eligibility_enabled: true, }; diff --git a/packages/apps/human-app/server/src/modules/staking/spec/staking.service.spec.ts b/packages/apps/human-app/server/src/modules/staking/spec/staking.service.spec.ts index f01058e6a9..ad97851205 100644 --- a/packages/apps/human-app/server/src/modules/staking/spec/staking.service.spec.ts +++ b/packages/apps/human-app/server/src/modules/staking/spec/staking.service.spec.ts @@ -47,8 +47,8 @@ describe('StakingService', () => { (reputationOracleMock.getStakeConfig as jest.Mock).mockResolvedValue( stakeConfigResponseFixture, ); - const result = await service.getStakeConfig(TOKEN); - expect(reputationOracleMock.getStakeConfig).toHaveBeenCalledWith(TOKEN); + const result = await service.getStakeConfig(); + expect(reputationOracleMock.getStakeConfig).toHaveBeenCalledWith(); expect(result).toEqual(stakeConfigResponseFixture); }); }); diff --git a/packages/apps/human-app/server/src/modules/staking/staking.controller.ts b/packages/apps/human-app/server/src/modules/staking/staking.controller.ts index 62d7a7fc14..f8bcdae90f 100644 --- a/packages/apps/human-app/server/src/modules/staking/staking.controller.ts +++ b/packages/apps/human-app/server/src/modules/staking/staking.controller.ts @@ -4,10 +4,7 @@ import { Controller, Get, Request } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { RequestWithUser } from '../../common/interfaces/jwt'; import { StakingService } from './staking.service'; -import { - StakeConfigResponse, - StakeSummaryResponse, -} from './model/staking.model'; +import { StakeSummaryResponse } from './model/staking.model'; @ApiTags('Staking') @ApiBearerAuth() @@ -25,12 +22,4 @@ export class StakingController { ): Promise { return this.service.getStakeSummary(req.token); } - - @ApiOperation({ summary: 'Get staking configuration' }) - @Get('/config') - async getStakeConfig( - @Request() req: RequestWithUser, - ): Promise { - return this.service.getStakeConfig(req.token); - } } diff --git a/packages/apps/human-app/server/src/modules/staking/staking.service.ts b/packages/apps/human-app/server/src/modules/staking/staking.service.ts index d30c6e14a5..820c8f9361 100644 --- a/packages/apps/human-app/server/src/modules/staking/staking.service.ts +++ b/packages/apps/human-app/server/src/modules/staking/staking.service.ts @@ -13,7 +13,7 @@ export class StakingService { return this.reputationOracle.getStakeSummary(token); } - getStakeConfig(token: string): Promise { - return this.reputationOracle.getStakeConfig(token); + getStakeConfig(): Promise { + return this.reputationOracle.getStakeConfig(); } } diff --git a/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.controller.spec.ts b/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.controller.spec.ts index 95e4889355..c8df57939b 100644 --- a/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.controller.spec.ts +++ b/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.controller.spec.ts @@ -1,15 +1,22 @@ +import { ChainId } from '@human-protocol/sdk'; import { ConfigModule } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { UiConfigurationController } from './ui-configuration.controller'; + import { EnvironmentConfigService } from '../../common/config/environment-config.service'; -import { ChainId } from '@human-protocol/sdk'; +import { StakingService } from '../staking/staking.service'; +import { UiConfigurationController } from './ui-configuration.controller'; process.env.CHAIN_IDS_ENABLED = '80002,11155111'; describe('UiConfigurationController', () => { let controller: UiConfigurationController; + let stakingServiceMock: Pick, 'getStakeConfig'>; beforeAll(async () => { + stakingServiceMock = { + getStakeConfig: jest.fn(), + }; + const module: TestingModule = await Test.createTestingModule({ imports: [ ConfigModule.forRoot({ @@ -17,7 +24,13 @@ describe('UiConfigurationController', () => { isGlobal: true, }), ], - providers: [EnvironmentConfigService], + providers: [ + EnvironmentConfigService, + { + provide: StakingService, + useValue: stakingServiceMock, + }, + ], controllers: [UiConfigurationController], }).compile(); @@ -27,10 +40,16 @@ describe('UiConfigurationController', () => { }); it('should return proper config', async () => { + stakingServiceMock.getStakeConfig.mockResolvedValueOnce({ + eligibility_enabled: true, + min_threshold: Math.random(), + }); + const result = await controller.getConfig(); expect(result.chainIdsEnabled).toEqual([ ChainId.POLYGON_AMOY, ChainId.SEPOLIA, ]); + expect(result.stakingEligibilityEnabled).toBe(true); }); }); diff --git a/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.controller.ts b/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.controller.ts index 0ed982fe27..f48f56f08f 100644 --- a/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.controller.ts +++ b/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.controller.ts @@ -3,6 +3,7 @@ import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { EnvironmentConfigService } from '../../common/config/environment-config.service'; import { Public } from '../../common/decorators'; import { UiConfigResponseDto } from './ui-configuration.dto'; +import { StakingService } from '../staking/staking.service'; @Controller() @Public() @@ -10,6 +11,7 @@ import { UiConfigResponseDto } from './ui-configuration.dto'; export class UiConfigurationController { constructor( private readonly environmentConfigService: EnvironmentConfigService, + private readonly stakingService: StakingService, ) {} @ApiOperation({ summary: 'Retrieve UI configuration' }) @@ -17,11 +19,14 @@ export class UiConfigurationController { type: UiConfigResponseDto, description: 'UI Configuration object', }) - @Header('Cache-Control', 'public, max-age=3600') + @Header('Cache-Control', 'public, max-age=600') @Get('/ui-config') public async getConfig(): Promise { + const stakingRequirementConfig = await this.stakingService.getStakeConfig(); + return { chainIdsEnabled: this.environmentConfigService.chainIdsEnabled, + stakingEligibilityEnabled: stakingRequirementConfig.eligibility_enabled, }; } } diff --git a/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.dto.ts b/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.dto.ts index 864757461b..3d53be6747 100644 --- a/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.dto.ts +++ b/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.dto.ts @@ -9,4 +9,10 @@ export class UiConfigResponseDto { enumName: 'ChainId', }) chainIdsEnabled: ChainId[]; + + @ApiProperty({ + description: + 'Indicated if stake eligibility check enabled on app & oracles', + }) + stakingEligibilityEnabled: boolean; } diff --git a/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.module.ts b/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.module.ts index d14d2d39bd..2e99816674 100644 --- a/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.module.ts +++ b/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.module.ts @@ -1,7 +1,9 @@ import { Module } from '@nestjs/common'; import { UiConfigurationController } from './ui-configuration.controller'; +import { StakingModule } from '../staking/staking.module'; @Module({ + imports: [StakingModule], controllers: [UiConfigurationController], }) export class UiConfigurationModule {} diff --git a/packages/apps/reputation-oracle/server/src/modules/staking/staking.controller.ts b/packages/apps/reputation-oracle/server/src/modules/staking/staking.controller.ts index ebad148aa5..c4f711281d 100644 --- a/packages/apps/reputation-oracle/server/src/modules/staking/staking.controller.ts +++ b/packages/apps/reputation-oracle/server/src/modules/staking/staking.controller.ts @@ -6,6 +6,7 @@ import { ApiTags, } from '@nestjs/swagger'; +import { Public } from '@/common/decorators'; import type { RequestWithUser } from '@/common/types'; import { StakeConfigResponseDto, StakeSummaryResponseDto } from './staking.dto'; @@ -13,12 +14,12 @@ import { StakingControllerErrorsFilter } from './staking.error-filter'; import { StakingService } from './staking.service'; @ApiTags('Staking') -@ApiBearerAuth() @UseFilters(StakingControllerErrorsFilter) @Controller('staking') export class StakingController { constructor(private readonly stakingService: StakingService) {} + @ApiBearerAuth() @ApiOperation({ summary: 'Retrieve aggregated staking info' }) @ApiResponse({ status: 200, type: StakeSummaryResponseDto }) @Get('/summary') @@ -28,6 +29,7 @@ export class StakingController { return this.stakingService.getStakeSummary(request.user.id); } + @Public() @ApiOperation({ summary: 'Retrieve staking configuration' }) @ApiResponse({ status: 200, type: StakeConfigResponseDto }) @Get('/config')