diff --git a/ui/cip-1694/.env.example b/ui/cip-1694/.env.example index 8b147ba14..f14046a01 100644 --- a/ui/cip-1694/.env.example +++ b/ui/cip-1694/.env.example @@ -7,6 +7,7 @@ REACT_APP_CATEGORY_ID=CIP-1694_Pre_Ratification_4619 REACT_APP_EVENT_BY_ID_REFERENCE_URL=${REACT_APP_SERVER_URL}/api/reference/event REACT_APP_CAST_VOTE_URL=${REACT_APP_SERVER_URL}/api/vote/cast +REACT_APP_VOTE_RECEIPT_URL=${REACT_APP_SERVER_URL}/api/vote/receipt REACT_APP_BLOCKCHAIN_TIP_URL=${REACT_APP_SERVER_URL}/api/blockchain/tip REACT_APP_VOTING_POWER_URL=${REACT_APP_SERVER_URL}/api/account diff --git a/ui/cip-1694/src/common/api/voteService.ts b/ui/cip-1694/src/common/api/voteService.ts index 0c9994586..582a8913f 100644 --- a/ui/cip-1694/src/common/api/voteService.ts +++ b/ui/cip-1694/src/common/api/voteService.ts @@ -2,38 +2,51 @@ import { DEFAULT_CONTENT_TYPE_HEADERS, doRequest, HttpMethods } from '../handler import { EVENT_BY_ID_REFERENCE_URL, CAST_VOTE_URL, + VOTE_RECEIPT_URL, BLOCKCHAIN_TIP_URL, VOTING_POWER_URL, } from '../constants/appConstants'; -import { Problem, SignedWeb3Request, Vote, Event, ChainTip, Account } from '../../types/backend-services-types'; +import { + Problem, + SignedWeb3Request, + Vote, + Event, + ChainTip, + Account, + VoteReceipt, +} from '../../types/backend-services-types'; -const getEventById = async (eventId: Event['id']) => +export const getEventById = async (eventId: Event['id']) => await doRequest(HttpMethods.GET, `${EVENT_BY_ID_REFERENCE_URL}/${eventId}`, { ...DEFAULT_CONTENT_TYPE_HEADERS, }); -const castAVoteWithDigitalSignature = async (jsonRequest: SignedWeb3Request) => +export const castAVoteWithDigitalSignature = async (jsonRequest: SignedWeb3Request) => await doRequest( HttpMethods.POST, - `${CAST_VOTE_URL}`, - { ...DEFAULT_CONTENT_TYPE_HEADERS }, + CAST_VOTE_URL, + DEFAULT_CONTENT_TYPE_HEADERS, JSON.stringify(jsonRequest), false ); -const getSlotNumber = async () => { - return await doRequest(HttpMethods.GET, `${BLOCKCHAIN_TIP_URL}`, { ...DEFAULT_CONTENT_TYPE_HEADERS }); +export const getSlotNumber = async () => { + return await doRequest(HttpMethods.GET, BLOCKCHAIN_TIP_URL, DEFAULT_CONTENT_TYPE_HEADERS); }; -const getVotingPower = async (eventId: Event['id'], stakeAddress: string) => { - return await doRequest(HttpMethods.GET, `${VOTING_POWER_URL}/${eventId}/${stakeAddress}`, { - ...DEFAULT_CONTENT_TYPE_HEADERS, - }); +export const getVoteReceipt = async (jsonRequest: SignedWeb3Request) => { + return await doRequest( + HttpMethods.POST, + VOTE_RECEIPT_URL, + DEFAULT_CONTENT_TYPE_HEADERS, + JSON.stringify(jsonRequest) + ); }; -export const voteService = { - getEventById: getEventById, - castAVoteWithDigitalSignature: castAVoteWithDigitalSignature, - getSlotNumber: getSlotNumber, - getVotingPower: getVotingPower, +export const getVotingPower = async (eventId: Event['id'], stakeAddress: string) => { + return await doRequest( + HttpMethods.POST, + `${VOTING_POWER_URL}/${eventId}/${stakeAddress}`, + DEFAULT_CONTENT_TYPE_HEADERS + ); }; diff --git a/ui/cip-1694/src/common/constants/appConstants.ts b/ui/cip-1694/src/common/constants/appConstants.ts index 2c629079a..7f13baf91 100644 --- a/ui/cip-1694/src/common/constants/appConstants.ts +++ b/ui/cip-1694/src/common/constants/appConstants.ts @@ -1,6 +1,7 @@ // Services URLs export const EVENT_BY_ID_REFERENCE_URL = process.env.EVENT_BY_ID_REFERENCE_URL; export const CAST_VOTE_URL = process.env.REACT_APP_CAST_VOTE_URL; +export const VOTE_RECEIPT_URL = process.env.REACT_APP_VOTE_RECEIPT_URL; export const BLOCKCHAIN_TIP_URL = process.env.REACT_APP_BLOCKCHAIN_TIP_URL; export const VOTING_POWER_URL = process.env.REACT_APP_VOTING_POWER_URL; diff --git a/ui/cip-1694/src/common/handlers/httpHandler.ts b/ui/cip-1694/src/common/handlers/httpHandler.ts index 7a0ac2666..715ae617e 100644 --- a/ui/cip-1694/src/common/handlers/httpHandler.ts +++ b/ui/cip-1694/src/common/handlers/httpHandler.ts @@ -54,8 +54,10 @@ type AnuthorizedResponse = { fault?: { faultstring?: string }; message?: string; Error?: string | Error; + title?: string; } & Response; -async function getErrorMessage(response: AnuthorizedResponse): Promise { + +const getErrorMessage = (response: AnuthorizedResponse): string => { if (response.error) { return response.error_description ? response.error_description : response.error; } else if (response.errors && response.errors.length > 0) { @@ -68,6 +70,8 @@ async function getErrorMessage(response: AnuthorizedResponse): Promise { return messages.toString(); } else if (response.fault && response.fault.faultstring) { return response.fault.faultstring; + } else if (response.title) { + return response.title; } else if (response.message) { try { const errors = JSON.parse(response.message); @@ -84,7 +88,7 @@ async function getErrorMessage(response: AnuthorizedResponse): Promise { } else { return '' + response; } -} +}; export function responseErrorsHandler() { return { @@ -108,20 +112,23 @@ export function responseHandlerDelegate() { return { async parse(response: Response | AnuthorizedResponse): Promise { - let json!: T & Errors; + let parsedResponse!: T & Errors; + const contentType = response.headers.get(Headers.CONTENT_TYPE.toLowerCase()); + const isJson = contentType && contentType.indexOf(MediaTypes.APPLICATION_JSON) !== -1; try { - json = await response.json(); - } catch (err) { + parsedResponse = await response[isJson ? 'json' : 'text'](); if (response.status !== 200) { - throw new HttpError(401, response.url, await getErrorMessage(response)); + throw new HttpError(401, response.url, getErrorMessage(parsedResponse)); } + } catch (error) { + throw new Error(error); } - if (typeof json === 'object' && 'errors' in json && json.errors.length >= 1) { - throw new HttpError(400, response.url, errorsHandler.parse(json.errors)); + if (parsedResponse?.errors?.length >= 1) { + throw new HttpError(400, response.url, errorsHandler.parse(parsedResponse.errors)); } else { - return json; + return parsedResponse; } }, }; diff --git a/ui/cip-1694/src/common/store/types.ts b/ui/cip-1694/src/common/store/types.ts index cf1efec1c..dea97ef24 100644 --- a/ui/cip-1694/src/common/store/types.ts +++ b/ui/cip-1694/src/common/store/types.ts @@ -1,7 +1,11 @@ +import { VoteReceipt } from 'types/backend-services-types'; + export interface UserState { isConnectWalletModalVisible: boolean; isVoteSubmittedModalVisible: boolean; connectedWallet: string; + isReceiptFetched: boolean; + receipt: VoteReceipt | null; } export interface State { diff --git a/ui/cip-1694/src/common/store/userSlice.ts b/ui/cip-1694/src/common/store/userSlice.ts index 554b80e9b..ae88b770b 100644 --- a/ui/cip-1694/src/common/store/userSlice.ts +++ b/ui/cip-1694/src/common/store/userSlice.ts @@ -1,11 +1,14 @@ import { createSlice } from '@reduxjs/toolkit'; import type { PayloadAction } from '@reduxjs/toolkit'; +import { VoteReceipt } from 'types/backend-services-types'; import { UserState } from './types'; const initialState: UserState = { isConnectWalletModalVisible: false, isVoteSubmittedModalVisible: false, connectedWallet: '', + isReceiptFetched: false, + receipt: null, }; export const userSlice = createSlice({ @@ -21,8 +24,20 @@ export const userSlice = createSlice({ setConnectedWallet: (state, action: PayloadAction<{ wallet: string }>) => { state.connectedWallet = action.payload.wallet; }, + setVoteReceipt: (state, action: PayloadAction<{ receipt: VoteReceipt }>) => { + state.receipt = action.payload.receipt; + }, + setIsReceiptFetched: (state, action: PayloadAction<{ isFetched: boolean }>) => { + state.isReceiptFetched = action.payload.isFetched; + }, }, }); -export const { setIsConnectWalletModalVisible, setIsVoteSubmittedModalVisible, setConnectedWallet } = userSlice.actions; +export const { + setIsConnectWalletModalVisible, + setIsVoteSubmittedModalVisible, + setConnectedWallet, + setVoteReceipt, + setIsReceiptFetched, +} = userSlice.actions; export default userSlice.reducer; diff --git a/ui/cip-1694/src/components/OptionCard/OptionCard.cy.tsx b/ui/cip-1694/src/components/OptionCard/OptionCard.cy.tsx index d7cf3ece3..3672e9bb6 100644 --- a/ui/cip-1694/src/components/OptionCard/OptionCard.cy.tsx +++ b/ui/cip-1694/src/components/OptionCard/OptionCard.cy.tsx @@ -5,8 +5,9 @@ import DoneIcon from '@mui/icons-material/Done'; import CloseIcon from '@mui/icons-material/Close'; import DoDisturbIcon from '@mui/icons-material/DoDisturb'; import { OptionCard } from './OptionCard'; +import { OptionItem } from './OptionCard.types'; -const items = [ +const items: OptionItem[] = [ { label: 'Yes', icon: , diff --git a/ui/cip-1694/src/components/OptionCard/OptionCard.types.ts b/ui/cip-1694/src/components/OptionCard/OptionCard.types.ts index 821cf731f..481e7f476 100644 --- a/ui/cip-1694/src/components/OptionCard/OptionCard.types.ts +++ b/ui/cip-1694/src/components/OptionCard/OptionCard.types.ts @@ -1,7 +1,7 @@ import React from 'react'; interface OptionItem { - label: string; + label: 'Yes' | 'No' | 'Abstain'; icon: React.ReactElement | null; } diff --git a/ui/cip-1694/src/pages/Vote/Vote.tsx b/ui/cip-1694/src/pages/Vote/Vote.tsx index 7aae499f5..cbd8aafce 100644 --- a/ui/cip-1694/src/pages/Vote/Vote.tsx +++ b/ui/cip-1694/src/pages/Vote/Vote.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useState } from 'react'; -import { useDispatch } from 'react-redux'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { v4 as uuidv4 } from 'uuid'; import toast from 'react-hot-toast'; import cn from 'classnames'; @@ -10,13 +10,19 @@ import DoDisturbIcon from '@mui/icons-material/DoDisturb'; import ReceiptIcon from '@mui/icons-material/ReceiptLongOutlined'; import { useCardano } from '@cardano-foundation/cardano-connect-with-wallet'; import CountDownTimer from 'components/CountDownTimer/CountDownTimer'; -import { setIsConnectWalletModalVisible, setIsVoteSubmittedModalVisible } from 'common/store/userSlice'; -import { Account, ChainTip } from 'types/backend-services-types'; +import { + setIsConnectWalletModalVisible, + setIsReceiptFetched, + setIsVoteSubmittedModalVisible, + setVoteReceipt, +} from 'common/store/userSlice'; +import { Account, ChainTip, SignedWeb3Request } from 'types/backend-services-types'; +import { RootState } from 'common/store'; import { OptionCard } from '../../components/OptionCard/OptionCard'; import { OptionItem } from '../../components/OptionCard/OptionCard.types'; import SidePage from '../../components/common/SidePage/SidePage'; import { buildCanonicalVoteInputJson } from '../../common/utils/voteUtils'; -import { voteService } from '../../common/api/voteService'; +import * as voteService from '../../common/api/voteService'; import { EVENT_ID } from '../../common/constants/appConstants'; import { useToggle } from '../../common/hooks/useToggle'; import { HttpError } from '../../common/handlers/httpHandler'; @@ -46,31 +52,55 @@ const items: OptionItem[] = [ const Vote = () => { const { stakeAddress, isConnected, signMessage } = useCardano(); - const [isDisabled, setIsDisabled] = useState(false); - const [showVoteReceipt, setShowVoteReceipt] = useState(false); + const receipt = useSelector((state: RootState) => state.user.receipt); + const isReceiptFetched = useSelector((state: RootState) => state.user.isReceiptFetched); const [optionId, setOptionId] = useState(''); const [isToggledReceipt, toggleReceipt] = useToggle(false); const dispatch = useDispatch(); - const initialise = () => { - optionId === '' && setIsDisabled(true); - !isConnected && showVoteReceipt && setShowVoteReceipt(true); - }; + const getSignedMessagePromise = useCallback( + async (message: string): Promise => + new Promise((resolve, reject) => { + signMessage( + message, + (signature, key) => resolve({ coseSignature: signature, cosePublicKey: key || '' }), + (error: Error) => { + reject(error); + } + ); + }), + [signMessage] + ); + + const fetchReceipt = useCallback( + async (requestVoteObject?: SignedWeb3Request) => { + try { + const requestVoteObjectPayload = requestVoteObject || (await getSignedMessagePromise('')); + const receiptResponse = await voteService.getVoteReceipt(requestVoteObjectPayload); + if ('id' in receiptResponse) { + dispatch(setVoteReceipt({ receipt: receiptResponse })); + } else { + const message = `Failed to fetch receipt', ${receiptResponse?.title}, ${receiptResponse?.detail}`; + console.log(message); + } + dispatch(setIsReceiptFetched({ isFetched: true })); + } catch (error) { + const message = `Failed to fetch receipt', ${error?.message || error}`; + toast.error(message); + console.log(message); + } + }, + [dispatch, getSignedMessagePromise] + ); useEffect(() => { - initialise(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const notify = (message: string) => toast(message); - - const onChangeOption = (option: string) => { - if (option !== null) { - setOptionId(option); - setIsDisabled(false); - } else { - setIsDisabled(true); + if (isConnected) { + fetchReceipt(); } + }, [fetchReceipt, isConnected]); + + const onChangeOption = (option: string | null) => { + setOptionId(option); }; const handleSubmit = async () => { @@ -95,9 +125,12 @@ const Vote = () => { try { ({ votingPower } = await voteService.getVotingPower(EVENT_ID, stakeAddress)); } catch (error) { - if (error instanceof Error || error instanceof HttpError) { - console.log('Failed to fetch votingPower', error?.message); - } else console.log('Failed to fetch votingPower', error); + const message = `Failed to fetch votingPower ${ + error instanceof Error || error instanceof HttpError ? error?.message : error + }`; + + console.log(message); + toast.error(message); return; } @@ -108,28 +141,23 @@ const Vote = () => { slotNumber: absoluteSlot.toString(), votePower: votingPower, }); - signMessage(canonicalVoteInput, async (signature, key) => { - const requestVoteObject = { - cosePublicKey: key || '', - coseSignature: signature, - }; - try { - await voteService.castAVoteWithDigitalSignature(requestVoteObject); + try { + const requestVoteObject = await getSignedMessagePromise(canonicalVoteInput); + const vote = await voteService.castAVoteWithDigitalSignature(await getSignedMessagePromise(canonicalVoteInput)); + if ('coseSignature' in vote) { dispatch(setIsVoteSubmittedModalVisible({ isVisible: true })); + await fetchReceipt(requestVoteObject); + } + } catch (error) { + if (error instanceof HttpError && error.code === 400) { + toast.error(errorsMap[error?.message as keyof typeof errorsMap] || error?.message); setOptionId(''); - setShowVoteReceipt(true); - } catch (error) { - if (error instanceof HttpError && error.code === 400) { - notify(errorsMap[error?.message as keyof typeof errorsMap] || error?.message); - setOptionId(''); - setIsDisabled(true); - } else if (error instanceof Error) { - notify(error?.message); - console.log('Failed to cast e vote', error); - } + } else if (error instanceof Error) { + toast.error(error?.message || error.toString()); + console.log('Failed to cast e vote', error); } - }); + } }; return ( @@ -168,7 +196,7 @@ const Vote = () => { @@ -180,12 +208,12 @@ const Vote = () => { justifyContent={'center'} > - {!showVoteReceipt ? ( + {!receipt?.id ? (