diff --git a/packages/apps/human-app/frontend/.env.example b/packages/apps/human-app/frontend/.env.example index c711c9510f..56f408dd75 100644 --- a/packages/apps/human-app/frontend/.env.example +++ b/packages/apps/human-app/frontend/.env.example @@ -4,6 +4,10 @@ VITE_TERMS_OF_SERVICE_URL=https://humanprotocol.org/app/terms-and-conditions/ VITE_HUMAN_PROTOCOL_HELP_URL=https://humanprotocol.org/ VITE_HUMAN_PROTOCOL_URL=https://app.humanprotocol.org/ VITE_H_CAPTCHA_SITE_KEY=key +VITE_HMT_DAILY_SPENT_LIMIT=22 +VITE_DAILY_SOLVED_CAPTCHA_LIMIT=22 +VITE_H_CAPTCHA_EXCHANGE_URL=https://foundation-exchange.hmt.ai +VITE_H_CAPTCHA_LABELING_BASE_URL=https://foundation-accounts.hmt.ai VITE_SYNAPS_KEY=key VITE_WALLET_CONNECT_PROJECT_ID=key VITE_DAPP_META_NAME=Human App diff --git a/packages/apps/human-app/frontend/package.json b/packages/apps/human-app/frontend/package.json index 4094d50ac4..f5a2b5469d 100644 --- a/packages/apps/human-app/frontend/package.json +++ b/packages/apps/human-app/frontend/package.json @@ -17,7 +17,7 @@ "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", "@fontsource/inter": "^5.0.17", - "@hcaptcha/react-hcaptcha": "^1.10.1", + "@hcaptcha/react-hcaptcha": "^0.3.6", "@hookform/resolvers": "^3.3.4", "@mui/icons-material": "^5.15.7", "@mui/material": "^5.15.7", diff --git a/packages/apps/human-app/frontend/src/api/api-paths.ts b/packages/apps/human-app/frontend/src/api/api-paths.ts index 5097616346..f37df1d55b 100644 --- a/packages/apps/human-app/frontend/src/api/api-paths.ts +++ b/packages/apps/human-app/frontend/src/api/api-paths.ts @@ -43,6 +43,10 @@ export const apiPaths = { registerAddress: { path: '/user/register-address', }, + enableHCaptchaLabeling: '/labeling/h-captcha/enable', + verifyHCaptchaLabeling: '/labeling/h-captcha/verify', + hCaptchaUserStats: '/labeling/h-captcha/user-stats', + dailyHmtSpend: '/labeling/h-captcha/daily-hmt-spent', }, operator: { web3Auth: { diff --git a/packages/apps/human-app/frontend/src/api/servieces/worker/daily-hmt-spent.ts b/packages/apps/human-app/frontend/src/api/servieces/worker/daily-hmt-spent.ts new file mode 100644 index 0000000000..d259f47ee6 --- /dev/null +++ b/packages/apps/human-app/frontend/src/api/servieces/worker/daily-hmt-spent.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; +import { useQuery } from '@tanstack/react-query'; +import { apiPaths } from '@/api/api-paths'; +import { apiClient } from '@/api/api-client'; + +const dailyHmtSpentSchema = z.object({ + spend: z.number(), +}); + +export type DailyHmtSpentSchemaSuccess = z.infer; + +export async function getDailyHmtSpent() { + return apiClient(apiPaths.worker.dailyHmtSpend, { + successSchema: dailyHmtSpentSchema, + authenticated: true, + options: { method: 'GET' }, + }); +} + +export function useDailyHmtSpent() { + return useQuery({ + queryFn: getDailyHmtSpent, + queryKey: ['getDailyHmtSpent'], + }); +} diff --git a/packages/apps/human-app/frontend/src/api/servieces/worker/enable-hcaptcha-labeling.ts b/packages/apps/human-app/frontend/src/api/servieces/worker/enable-hcaptcha-labeling.ts new file mode 100644 index 0000000000..9362e7f07a --- /dev/null +++ b/packages/apps/human-app/frontend/src/api/servieces/worker/enable-hcaptcha-labeling.ts @@ -0,0 +1,39 @@ +/* eslint-disable camelcase -- ...*/ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; +import { z } from 'zod'; +import { routerPaths } from '@/router/router-paths'; +import { apiClient } from '@/api/api-client'; +import { apiPaths } from '@/api/api-paths'; + +const enableHCaptchaLabelingSuccessSchema = z.object({ + site_key: z.string(), +}); + +export type EnableHCaptchaLabelingSuccessResponse = z.infer< + typeof enableHCaptchaLabelingSuccessSchema +>; + +async function enableHCaptchaLabeling() { + return apiClient(apiPaths.worker.enableHCaptchaLabeling, { + successSchema: enableHCaptchaLabelingSuccessSchema, + authenticated: true, + options: { method: 'POST' }, + }); +} + +export function useEnableHCaptchaLabelingMutation() { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + + return useMutation({ + mutationFn: enableHCaptchaLabeling, + onSuccess: async () => { + navigate(routerPaths.worker.HcaptchaLabeling); + await queryClient.invalidateQueries(); + }, + onError: async () => { + await queryClient.invalidateQueries(); + }, + }); +} diff --git a/packages/apps/human-app/frontend/src/api/servieces/worker/hcaptcha-user-stats.ts b/packages/apps/human-app/frontend/src/api/servieces/worker/hcaptcha-user-stats.ts new file mode 100644 index 0000000000..425e74cefb --- /dev/null +++ b/packages/apps/human-app/frontend/src/api/servieces/worker/hcaptcha-user-stats.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; +import { useQuery } from '@tanstack/react-query'; +import { apiPaths } from '@/api/api-paths'; +import { apiClient } from '@/api/api-client'; + +const hcaptchaUserStatsSchema = z.object({ + balance: z.object({ + available: z.number(), + estimated: z.number(), + recent: z.number(), + total: z.number(), + }), + served: z.number(), + solved: z.number(), + verified: z.number(), + // TODO verify response + currentDateStats: z.unknown(), + // TODO verify response + currentEarningsStats: z.object({ + hmtEarned: z.unknown(), + }), +}); + +export type HCaptchaUserStatsSuccess = z.infer; + +export async function getHCaptchaUsersStats() { + return apiClient(apiPaths.worker.hCaptchaUserStats, { + authenticated: true, + successSchema: hcaptchaUserStatsSchema, + options: { method: 'GET' }, + }); +} + +export function useHCaptchaUserStats() { + return useQuery({ + queryFn: getHCaptchaUsersStats, + queryKey: ['getHCaptchaUsersStats'], + }); +} diff --git a/packages/apps/human-app/frontend/src/api/servieces/worker/sign-in.ts b/packages/apps/human-app/frontend/src/api/servieces/worker/sign-in.ts index 61002558ff..a5e142e4f8 100644 --- a/packages/apps/human-app/frontend/src/api/servieces/worker/sign-in.ts +++ b/packages/apps/human-app/frontend/src/api/servieces/worker/sign-in.ts @@ -49,6 +49,7 @@ export function useSignInMutation() { onSuccess: async (data) => { signIn(data); navigate(routerPaths.worker.profile); + window.location.reload(); await queryClient.invalidateQueries(); }, onError: async () => { diff --git a/packages/apps/human-app/frontend/src/api/servieces/worker/solve-hcaptcha.ts b/packages/apps/human-app/frontend/src/api/servieces/worker/solve-hcaptcha.ts new file mode 100644 index 0000000000..f51da2da50 --- /dev/null +++ b/packages/apps/human-app/frontend/src/api/servieces/worker/solve-hcaptcha.ts @@ -0,0 +1,38 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { z } from 'zod'; +import { apiClient } from '@/api/api-client'; +import { apiPaths } from '@/api/api-paths'; +import type { ResponseError } from '@/shared/types/global.type'; + +function solveHCaptcha({ token }: { token: string }) { + return apiClient(apiPaths.worker.verifyHCaptchaLabeling, { + successSchema: z.unknown(), + authenticated: true, + options: { method: 'POST', body: JSON.stringify({ token }) }, + }); +} + +export function useSolveHCaptchaMutation(callbacks?: { + onSuccess?: (() => void) | (() => Promise); + onError?: + | ((error: ResponseError) => void) + | ((error: ResponseError) => Promise); +}) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: solveHCaptcha, + onSuccess: async () => { + if (callbacks?.onSuccess) { + await callbacks.onSuccess(); + } + await queryClient.invalidateQueries(); + }, + onError: async (error) => { + if (callbacks?.onError) { + await callbacks.onError(error); + } + await queryClient.invalidateQueries(); + }, + }); +} diff --git a/packages/apps/human-app/frontend/src/auth/auth-context.tsx b/packages/apps/human-app/frontend/src/auth/auth-context.tsx index 62bff66cd0..ce112201a0 100644 --- a/packages/apps/human-app/frontend/src/auth/auth-context.tsx +++ b/packages/apps/human-app/frontend/src/auth/auth-context.tsx @@ -1,24 +1,27 @@ +/* eslint-disable camelcase -- ... */ import { useState, createContext, useEffect } from 'react'; import { jwtDecode } from 'jwt-decode'; import { z } from 'zod'; import type { SignInSuccessResponse } from '@/api/servieces/worker/sign-in'; import { browserAuthProvider } from '@/shared/helpers/browser-auth-provider'; -const userDataSchema = z.object({ - email: z.string(), - userId: z.number(), - address: z.string().nullable(), - // eslint-disable-next-line camelcase -- camel case defined by api - reputation_network: z.string(), - // eslint-disable-next-line camelcase -- camel case defined by api +const extendableUserDataSchema = z.object({ + site_key: z.string().optional().nullable(), kyc_status: z.string().optional().nullable(), - // eslint-disable-next-line camelcase -- camel case defined by api - kyc_added_on_chain: z.boolean().optional(), // TODO that should be verified when adding KYC info on chain feature is done - // eslint-disable-next-line camelcase -- camel case defined by api - email_notifications: z.boolean().optional(), // TODO that should be verified when email notifications feature is done + address: z.string().optional().nullable(), }); +const userDataSchema = z + .object({ + email: z.string(), + userId: z.number(), + reputation_network: z.string(), + email_notifications: z.boolean().optional(), // TODO that should be verified when email notifications feature is done + }) + .merge(extendableUserDataSchema); + export type UserData = z.infer; +export type UpdateUserDataPayload = z.infer; type AuthStatus = 'loading' | 'error' | 'success' | 'idle'; export interface AuthenticatedUserContextType { @@ -26,6 +29,7 @@ export interface AuthenticatedUserContextType { status: AuthStatus; signOut: () => void; signIn: (singIsSuccess: SignInSuccessResponse) => void; + updateUserData: (updateUserDataPayload: UpdateUserDataPayload) => void; } interface UnauthenticatedUserContextType { @@ -44,18 +48,41 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { user: UserData | null; status: AuthStatus; }>({ user: null, status: 'loading' }); + const updateUserData = (updateUserDataPayload: UpdateUserDataPayload) => { + setAuthState((state) => { + if (!state.user) { + return state; + } + + const newUserData = { + ...state.user, + ...updateUserDataPayload, + }; + browserAuthProvider.setUserData(newUserData); + + return { + ...state, + user: newUserData, + }; + }); + }; const handleSignIn = () => { try { const accessToken = browserAuthProvider.getAccessToken(); const authType = browserAuthProvider.getAuthType(); + const savedUserData = browserAuthProvider.getUserData(); if (!accessToken || authType !== 'web2') { setAuthState({ user: null, status: 'idle' }); return; } const userData = jwtDecode(accessToken); - const validUserData = userDataSchema.parse(userData); + const userDataWithSavedData = savedUserData.data + ? { ...userData, ...savedUserData } + : userData; + + const validUserData = userDataSchema.parse(userDataWithSavedData); setAuthState({ user: validUserData, status: 'success' }); } catch (e) { // eslint-disable-next-line no-console -- ... @@ -84,12 +111,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { value={ authState.user && authState.status === 'success' ? { - ...authState, + user: authState.user, + status: authState.status, signOut, signIn, + updateUserData, } : { - ...authState, + user: null, + status: authState.status, signOut, signIn, } diff --git a/packages/apps/human-app/frontend/src/components/h-captcha.tsx b/packages/apps/human-app/frontend/src/components/h-captcha.tsx index 438db53e37..ff56482913 100644 --- a/packages/apps/human-app/frontend/src/components/h-captcha.tsx +++ b/packages/apps/human-app/frontend/src/components/h-captcha.tsx @@ -12,19 +12,8 @@ interface CaptchaProps { export function Captcha({ setCaptchaToken }: CaptchaProps) { const captchaRef = useRef(null); - const onLoad = () => { - // this reaches out to the hCaptcha JS API and runs the - // execute function on it. you can use other functions as - // documented here: - // https://docs.hcaptcha.com/configuration#jsapi - if (captchaRef.current) { - captchaRef.current.execute(); - } - }; - return ( , - { label: t('components.DrawerNavigation.captchaLabelling') }, + { + label: t('components.DrawerNavigation.captchaLabelling'), + link: routerPaths.worker.enableLabeler, + }, { label: t('components.DrawerNavigation.jobsDiscovery'), link: routerPaths.worker.jobsDiscovery, diff --git a/packages/apps/human-app/frontend/src/components/layout/protected/layout.tsx b/packages/apps/human-app/frontend/src/components/layout/protected/layout.tsx index 283b3d618c..bce3e6e4ae 100644 --- a/packages/apps/human-app/frontend/src/components/layout/protected/layout.tsx +++ b/packages/apps/human-app/frontend/src/components/layout/protected/layout.tsx @@ -10,6 +10,7 @@ import { breakpoints } from '@/styles/theme'; import { TopNotification } from '@/components/ui/top-notification'; import type { TopNotificationPayload } from '@/components/layout/protected/layout-notification-context'; import { ProtectedLayoutContext } from '@/components/layout/protected/layout-notification-context'; +import { useIsHCaptchaLabelingPage } from '@/hooks/use-is-hcaptcha-labeling-page'; import { Footer } from '../footer'; import { Navbar } from './navbar'; @@ -20,6 +21,7 @@ const Main = styled('main', { isMobile?: boolean; }>(({ theme, open, isMobile }) => ({ width: '100%', + display: 'flex', flex: '1', transition: theme.transitions.create('margin', { easing: theme.transitions.easing.sharp, @@ -37,20 +39,31 @@ const Main = styled('main', { export function Layout({ pageHeaderProps, renderDrawer, + renderHCaptchaStatisticsDrawer, }: { pageHeaderProps: PageHeaderProps; renderDrawer: (open: boolean) => JSX.Element; + renderHCaptchaStatisticsDrawer?: (isOpen: boolean) => JSX.Element; }) { + const isHCaptchaLabelingPage = useIsHCaptchaLabelingPage(); const [notification, setNotification] = useState(null); const isMobile = useIsMobile(); const [drawerOpen, setDrawerOpen] = useState(!isMobile); + const [hcaptchaDrawerOpen, setHcaptchaDrawerOpen] = useState(false); const { backgroundColor } = useBackgroundColorStore(); + const toggleUserStatsDrawer = isHCaptchaLabelingPage + ? () => { + setHcaptchaDrawerOpen((state) => !state); + } + : undefined; useEffect(() => { if (isMobile) { + setHcaptchaDrawerOpen(false); setDrawerOpen(false); } else { + setHcaptchaDrawerOpen(false); setDrawerOpen(true); } }, [isMobile]); @@ -84,8 +97,16 @@ export function Layout({ backgroundColor, }} > - + {renderDrawer(drawerOpen)} + {isHCaptchaLabelingPage && renderHCaptchaStatisticsDrawer + ? renderHCaptchaStatisticsDrawer(hcaptchaDrawerOpen) + : null}
- + + +