diff --git a/govtool/backend/sql/get-proposal-survey-tally.sql b/govtool/backend/sql/get-proposal-survey-tally.sql new file mode 100644 index 000000000..2a7f57ead --- /dev/null +++ b/govtool/backend/sql/get-proposal-survey-tally.sql @@ -0,0 +1,141 @@ +WITH context AS ( + SELECT + gov_action_proposal.id AS proposal_db_id, + off_chain_vote_data.json->>'surveyTxId' AS survey_tx_id, + survey_meta.json->'surveyDetails' AS survey_details + FROM gov_action_proposal + JOIN tx AS creator_tx ON creator_tx.id = gov_action_proposal.tx_id + LEFT JOIN voting_anchor ON voting_anchor.id = gov_action_proposal.voting_anchor_id + LEFT JOIN off_chain_vote_data ON off_chain_vote_data.voting_anchor_id = voting_anchor.id + LEFT JOIN tx survey_tx + ON encode(survey_tx.hash, 'hex') = LOWER(off_chain_vote_data.json->>'surveyTxId') + LEFT JOIN tx_metadata survey_meta + ON survey_meta.tx_id = survey_tx.id + AND survey_meta.key = 17 + AND survey_meta.json ? 'surveyDetails' + WHERE encode(creator_tx.hash, 'hex') = ? + AND gov_action_proposal.index = ? + LIMIT 1 +), +LatestDrepDistr AS ( + SELECT + *, + ROW_NUMBER() OVER (PARTITION BY hash_id ORDER BY epoch_no DESC) AS rn + FROM drep_distr +), +responses AS ( + SELECT + encode(drep_hash.raw, 'hex') AS response_credential, + tx_metadata.id AS metadata_id, + block.slot_no AS slot_no, + tx.block_index AS tx_index, + tx_metadata.json->'surveyResponse' AS survey_response, + COALESCE(ldd.amount, 0) AS drep_voting_power + FROM context + JOIN tx_metadata + ON tx_metadata.key = 17 + AND tx_metadata.json ? 'surveyResponse' + AND LOWER(tx_metadata.json->'surveyResponse'->>'surveyTxId') = LOWER(context.survey_tx_id) + JOIN tx ON tx.id = tx_metadata.tx_id + JOIN block ON block.id = tx.block_id + JOIN voting_procedure + ON voting_procedure.tx_id = tx.id + AND voting_procedure.gov_action_proposal_id = context.proposal_db_id + AND voting_procedure.drep_voter IS NOT NULL + JOIN drep_hash ON drep_hash.id = voting_procedure.drep_voter + LEFT JOIN LatestDrepDistr ldd + ON ldd.hash_id = voting_procedure.drep_voter + AND ldd.rn = 1 +), +latest AS ( + SELECT DISTINCT ON (response_credential) + * + FROM responses + ORDER BY + response_credential, + slot_no DESC, + tx_index DESC, + metadata_id DESC +), +question_counts AS ( + SELECT + answer->>'questionId' AS question_id, + COUNT(*)::bigint AS answers_count, + SUM( + CASE + WHEN ? = 'StakeBased' THEN drep_voting_power + ELSE 1 + END + )::bigint AS weighted_count + FROM latest + JOIN LATERAL jsonb_array_elements( + CASE + WHEN jsonb_typeof(latest.survey_response->'answers') = 'array' + THEN latest.survey_response->'answers' + ELSE '[]'::jsonb + END + ) AS answer ON true + GROUP BY answer->>'questionId' +), +method_results AS ( + SELECT + COALESCE( + jsonb_agg( + jsonb_build_object( + 'questionId', q->>'questionId', + 'question', q->>'question', + 'methodType', q->>'methodType', + 'count', COALESCE(question_counts.answers_count, 0), + 'weightedCount', COALESCE(question_counts.weighted_count, 0) + ) + ), + '[]'::jsonb + ) AS results + FROM context + LEFT JOIN LATERAL jsonb_array_elements(context.survey_details->'questions') AS q ON true + LEFT JOIN question_counts ON question_counts.question_id = q->>'questionId' +) +SELECT + jsonb_build_object( + 'surveyTxId', context.survey_tx_id, + 'totals', jsonb_build_object( + 'totalSeen', COALESCE((SELECT COUNT(*) FROM responses), 0), + 'valid', COALESCE((SELECT COUNT(*) FROM latest), 0), + 'invalid', GREATEST( + COALESCE((SELECT COUNT(*) FROM responses), 0) + - COALESCE((SELECT COUNT(*) FROM latest), 0), + 0 + ), + 'deduped', GREATEST( + COALESCE((SELECT COUNT(*) FROM responses), 0) + - COALESCE((SELECT COUNT(*) FROM latest), 0), + 0 + ), + 'uniqueResponders', COALESCE((SELECT COUNT(*) FROM latest), 0) + ), + 'roleResults', jsonb_build_array( + jsonb_build_object( + 'responderRole', 'DRep', + 'weightingMode', ?, + 'totals', jsonb_build_object( + 'totalSeen', COALESCE((SELECT COUNT(*) FROM responses), 0), + 'valid', COALESCE((SELECT COUNT(*) FROM latest), 0), + 'invalid', GREATEST( + COALESCE((SELECT COUNT(*) FROM responses), 0) + - COALESCE((SELECT COUNT(*) FROM latest), 0), + 0 + ), + 'deduped', GREATEST( + COALESCE((SELECT COUNT(*) FROM responses), 0) + - COALESCE((SELECT COUNT(*) FROM latest), 0), + 0 + ), + 'uniqueResponders', COALESCE((SELECT COUNT(*) FROM latest), 0) + ), + 'methodResults', COALESCE(method_results.results, '[]'::jsonb) + ) + ), + 'errors', '[]'::jsonb + ) AS survey_tally +FROM context +CROSS JOIN method_results; diff --git a/govtool/backend/sql/get-proposal-survey.sql b/govtool/backend/sql/get-proposal-survey.sql new file mode 100644 index 000000000..4ad7b308a --- /dev/null +++ b/govtool/backend/sql/get-proposal-survey.sql @@ -0,0 +1,122 @@ +WITH proposal_data AS ( + SELECT + gov_action_proposal.type::text AS proposal_type, + encode(creator_tx.hash, 'hex') AS proposal_tx_id, + gov_action_proposal.index AS proposal_index, + gov_action_proposal.expiration AS expiration_epoch, + off_chain_vote_data.json AS anchor_json, + off_chain_vote_data.json->>'kind' AS kind, + off_chain_vote_data.json->>'surveyTxId' AS survey_tx_id + FROM gov_action_proposal + JOIN tx AS creator_tx ON creator_tx.id = gov_action_proposal.tx_id + LEFT JOIN voting_anchor ON voting_anchor.id = gov_action_proposal.voting_anchor_id + LEFT JOIN off_chain_vote_data ON off_chain_vote_data.voting_anchor_id = voting_anchor.id + WHERE encode(creator_tx.hash, 'hex') = ? + AND gov_action_proposal.index = ? + LIMIT 1 +), +survey_payload AS ( + SELECT + proposal_data.*, + survey_meta.json->'surveyDetails' AS survey_details, + CASE + WHEN (survey_meta.json->'surveyDetails'->>'endEpoch') ~ '^[0-9]+$' + THEN (survey_meta.json->'surveyDetails'->>'endEpoch')::bigint + ELSE NULL + END AS survey_end_epoch, + survey_meta.json->'surveyDetails'->'roleWeighting' AS role_weighting, + CASE + WHEN proposal_type IN ('TreasuryWithdrawals', 'NewConstitution') + THEN '["DRep","CC"]'::jsonb + ELSE '["DRep","SPO","CC"]'::jsonb + END AS action_eligibility + FROM proposal_data + LEFT JOIN tx survey_tx + ON encode(survey_tx.hash, 'hex') = LOWER(proposal_data.survey_tx_id) + LEFT JOIN tx_metadata survey_meta + ON survey_meta.tx_id = survey_tx.id + AND survey_meta.key = 17 + AND survey_meta.json ? 'surveyDetails' +) +SELECT + jsonb_build_object( + 'linked', survey_tx_id IS NOT NULL, + 'surveyTxId', survey_tx_id, + 'linkValidation', jsonb_build_object( + 'valid', + ( + kind = 'cardano-governance-survey-link' + AND survey_tx_id IS NOT NULL + AND survey_details IS NOT NULL + ) + AND ( + role_weighting IS NOT NULL + AND ( + SELECT COUNT(*) + FROM jsonb_object_keys(role_weighting) AS role_key + WHERE action_eligibility ? role_key + ) > 0 + ), + 'errors', + to_jsonb(array_remove(ARRAY[ + CASE WHEN kind IS DISTINCT FROM 'cardano-governance-survey-link' + THEN 'Anchor metadata kind is not cardano-governance-survey-link.' END, + CASE WHEN survey_tx_id IS NULL + THEN 'Missing surveyTxId in anchor metadata.' END, + CASE WHEN survey_tx_id IS NOT NULL AND survey_details IS NULL + THEN 'Referenced surveyTxId has no label 17 surveyDetails payload.' END, + CASE WHEN role_weighting IS NOT NULL + AND ( + SELECT COUNT(*) + FROM jsonb_object_keys(role_weighting) AS role_key + WHERE action_eligibility ? role_key + ) = 0 + THEN 'Linked survey has no eligible roles after governance action filtering.' END + ], NULL)), + 'actionEligibility', action_eligibility, + 'linkedRoleWeighting', + COALESCE( + ( + SELECT jsonb_object_agg(role_key, role_weighting->role_key) + FROM jsonb_object_keys(role_weighting) AS role_key + WHERE action_eligibility ? role_key + ), + 'null'::jsonb + ), + 'linkedActionId', jsonb_build_object( + 'txId', proposal_tx_id, + 'govActionIx', proposal_index + ) + ), + 'surveyDetails', survey_details, + 'surveyDetailsValidation', jsonb_build_object( + 'valid', + ( + survey_details IS NOT NULL + AND role_weighting IS NOT NULL + AND jsonb_typeof(role_weighting) = 'object' + AND jsonb_object_length(role_weighting) > 0 + AND survey_end_epoch IS NOT NULL + AND survey_end_epoch = expiration_epoch + ), + 'errors', + to_jsonb(array_remove(ARRAY[ + CASE WHEN survey_details IS NULL + THEN 'Missing surveyDetails payload.' END, + CASE WHEN survey_details IS NOT NULL + AND ( + role_weighting IS NULL + OR jsonb_typeof(role_weighting) <> 'object' + OR jsonb_object_length(role_weighting) = 0 + ) + THEN 'surveyDetails.roleWeighting must be a non-empty object.' END, + CASE WHEN survey_details IS NOT NULL + AND survey_end_epoch IS NULL + THEN 'surveyDetails.endEpoch is required.' END, + CASE WHEN survey_end_epoch IS NOT NULL + AND survey_end_epoch <> expiration_epoch + THEN 'surveyDetails.endEpoch must exactly match the governance action expiration epoch.' END + ], NULL)) + ) + ) AS survey_payload +FROM survey_payload; diff --git a/govtool/backend/src/VVA/API.hs b/govtool/backend/src/VVA/API.hs index c0a594f7a..331d5542b 100644 --- a/govtool/backend/src/VVA/API.hs +++ b/govtool/backend/src/VVA/API.hs @@ -26,7 +26,7 @@ import qualified Data.Text as Text import qualified Data.Text.Lazy as TL import qualified Data.Text.Lazy.Encoding as TL import Data.Time (TimeZone, localTimeToUTC) -import Data.Time.LocalTime (TimeZone, getCurrentTimeZone) +import Data.Time.LocalTime (getCurrentTimeZone) import qualified Data.Vector as V import Data.Hashable (hash, hashWithSalt) @@ -50,6 +50,7 @@ import qualified VVA.Epoch as Epoch import qualified VVA.Ipfs as Ipfs import VVA.Network as Network import qualified VVA.Proposal as Proposal +import qualified VVA.Survey as Survey import qualified VVA.Transaction as Transaction import qualified VVA.Types as Types import VVA.Types (App, AppEnv (..), @@ -89,6 +90,8 @@ type VVAApi = :> QueryParam "search" Text :> Get '[JSON] ListProposalsResponse :<|> "proposal" :> "get" :> Capture "proposalId" GovActionId :> QueryParam "drepId" HexText :> Get '[JSON] GetProposalResponse + :<|> "proposal" :> "survey" :> Capture "proposalId" GovActionId :> Get '[JSON] AnyValue + :<|> "proposal" :> "survey" :> Capture "proposalId" GovActionId :> "tally" :> QueryParam "weighting" Text :> Get '[JSON] AnyValue :<|> "proposal" :> "enacted-details" :> QueryParam "type" GovernanceActionType :> Get '[JSON] (Maybe EnactedProposalDetailsResponse) :<|> "epoch" :> "params" :> Get '[JSON] GetCurrentEpochParamsResponse :<|> "transaction" :> "status" :> Capture "transactionId" HexText :> Get '[JSON] GetTransactionStatusResponse @@ -109,6 +112,8 @@ server = upload :<|> getStakeKeyVotingPower :<|> listProposals :<|> getProposal + :<|> getProposalSurvey + :<|> getProposalSurveyTally :<|> getEnactedProposalDetails :<|> getCurrentEpochParams :<|> getTransactionStatus @@ -485,6 +490,20 @@ getProposal g@(GovActionId govActionTxHash govActionIndex) mDrepId' = do , getProposalResponseVote = voteResponse } +getProposalSurvey :: App m => GovActionId -> m AnyValue +getProposalSurvey (GovActionId govActionTxHash govActionIndex) = do + payload <- Survey.getProposalSurvey (unHexText govActionTxHash) govActionIndex + pure $ AnyValue (Just payload) + +getProposalSurveyTally :: App m => GovActionId -> Maybe Text -> m AnyValue +getProposalSurveyTally (GovActionId govActionTxHash govActionIndex) mWeighting = do + let weighting = fromMaybe "CredentialBased" mWeighting + payload <- Survey.getProposalSurveyTally + (unHexText govActionTxHash) + govActionIndex + weighting + pure $ AnyValue (Just payload) + getEnactedProposalDetails :: App m => Maybe GovernanceActionType -> m (Maybe EnactedProposalDetailsResponse) getEnactedProposalDetails maybeType = do let proposalType = maybe "HardForkInitiation" governanceActionTypeToText maybeType diff --git a/govtool/backend/src/VVA/Survey.hs b/govtool/backend/src/VVA/Survey.hs new file mode 100644 index 000000000..99d2b05ef --- /dev/null +++ b/govtool/backend/src/VVA/Survey.hs @@ -0,0 +1,91 @@ +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} + +module VVA.Survey where + +import Control.Monad.Reader + +import Data.Aeson (Value, object, (.=)) +import Data.ByteString (ByteString) +import Data.FileEmbed (embedFile) +import Data.Has (Has) +import Data.String (fromString) +import Data.Text (Text, unpack) +import qualified Data.Text.Encoding as Text + +import qualified Database.PostgreSQL.Simple as SQL +import VVA.Config +import VVA.Pool (ConnectionPool, withPool) + +sqlFrom :: ByteString -> SQL.Query +sqlFrom bs = fromString $ unpack $ Text.decodeUtf8 bs + +getProposalSurveySql :: SQL.Query +getProposalSurveySql = sqlFrom $(embedFile "sql/get-proposal-survey.sql") + +getProposalSurveyTallySql :: SQL.Query +getProposalSurveyTallySql = sqlFrom $(embedFile "sql/get-proposal-survey-tally.sql") + +emptySurveyPayload :: Value +emptySurveyPayload = + object + [ "linked" .= False + , "surveyTxId" .= (Nothing :: Maybe Text) + , "linkValidation" .= object ["valid" .= False, "errors" .= ["No survey link found for this proposal."]] + , "surveyDetails" .= (Nothing :: Maybe Value) + , "surveyDetailsValidation" .= object ["valid" .= False, "errors" .= ([] :: [Text])] + ] + +emptySurveyTallyPayload :: Text -> Value +emptySurveyTallyPayload weighting = + object + [ "surveyTxId" .= (Nothing :: Maybe Text) + , "totals" .= object + [ "totalSeen" .= (0 :: Integer) + , "valid" .= (0 :: Integer) + , "invalid" .= (0 :: Integer) + , "deduped" .= (0 :: Integer) + , "uniqueResponders" .= (0 :: Integer) + ] + , "roleResults" .= + [ object + [ "responderRole" .= ("DRep" :: Text) + , "weightingMode" .= weighting + , "totals" .= object + [ "totalSeen" .= (0 :: Integer) + , "valid" .= (0 :: Integer) + , "invalid" .= (0 :: Integer) + , "deduped" .= (0 :: Integer) + , "uniqueResponders" .= (0 :: Integer) + ] + , "methodResults" .= ([] :: [Value]) + ] + ] + , "errors" .= ["No survey tally available for this proposal."] + ] + +getProposalSurvey :: + (Has ConnectionPool r, Has VVAConfig r, MonadReader r m, MonadIO m) => + Text -> + Integer -> + m Value +getProposalSurvey txHash index = withPool $ \conn -> do + result <- liftIO $ + SQL.query conn getProposalSurveySql (txHash, index) :: IO [SQL.Only Value] + case result of + [SQL.Only payload] -> pure payload + _ -> pure emptySurveyPayload + +getProposalSurveyTally :: + (Has ConnectionPool r, Has VVAConfig r, MonadReader r m, MonadIO m) => + Text -> + Integer -> + Text -> + m Value +getProposalSurveyTally txHash index weighting = withPool $ \conn -> do + result <- liftIO $ + SQL.query conn getProposalSurveyTallySql (txHash, index, weighting, weighting) :: IO [SQL.Only Value] + case result of + [SQL.Only payload] -> pure payload + _ -> pure (emptySurveyTallyPayload weighting) diff --git a/govtool/backend/vva-be.cabal b/govtool/backend/vva-be.cabal index 8eb0de86d..781acc327 100644 --- a/govtool/backend/vva-be.cabal +++ b/govtool/backend/vva-be.cabal @@ -38,6 +38,8 @@ extra-source-files: sql/get-filtered-dreps-voting-power.sql sql/get-previous-enacted-governance-action-proposal-details.sql sql/get-account-info.sql + sql/get-proposal-survey.sql + sql/get-proposal-survey-tally.sql executable vva-be main-is: Main.hs @@ -131,4 +133,5 @@ library , VVA.Network , VVA.Account , VVA.Ipfs + , VVA.Survey ghc-options: -threaded diff --git a/govtool/frontend/src/components/molecules/VoteActionForm.tsx b/govtool/frontend/src/components/molecules/VoteActionForm.tsx index 6a49ac90b..7b6b712c3 100644 --- a/govtool/frontend/src/components/molecules/VoteActionForm.tsx +++ b/govtool/frontend/src/components/molecules/VoteActionForm.tsx @@ -5,13 +5,15 @@ import { Trans } from "react-i18next"; import { Button, Radio, Typography } from "@atoms"; import { useModal } from "@context"; import { + SurveyResponsePayload, useScreenDimension, useVoteActionForm, useTranslation, useGetVoterInfo, useGetVoteContextTextFromFile, + useGetProposalSurveyQuery, } from "@hooks"; -import { formatDisplayDate } from "@utils"; +import { formatDisplayDate, getFullGovActionId } from "@utils"; import { errorRed, fadedPurple } from "@/consts"; import { ProposalData, ProposalVote, Vote } from "@/models"; import { VoteContextModalState, SubmittedVotesModalState } from "../organisms"; @@ -30,19 +32,35 @@ export const VoteActionForm = ({ proposal, proposal: { expiryDate, expiryEpochNo }, }: VoteActionFormProps) => { + const [surveyAnswers, setSurveyAnswers] = useState< + Record< + string, + { + selection?: number[]; + numericValue?: number; + customValue?: string; + } + > + >({}); + const [surveyError, setSurveyError] = useState(null); const [voteContextHash, setVoteContextHash] = useState(); const [voteContextUrl, setVoteContextUrl] = useState(); const [showWholeVoteContext, setShowWholeVoteContext] = useState(false); const { voter } = useGetVoterInfo(); + const fullProposalId = getFullGovActionId(proposal.txHash, proposal.index); + const { data: proposalSurvey } = useGetProposalSurveyQuery( + fullProposalId, + !!proposal.type, + ); const { voteContextText, valid: voteContextValid = true } = useGetVoteContextTextFromFile(voteContextUrl, voteContextHash) || {}; const finalVoteContextText = - ((previousVote != null || undefined) && !voteContextUrl && !voteContextHash) - ? "" - : voteContextText; + previousVote && !voteContextUrl && !voteContextHash + ? "" + : voteContextText; const { isMobile } = useScreenDimension(); const { openModal, closeModal } = useModal(); @@ -56,21 +74,103 @@ export const VoteActionForm = ({ setValue, vote, canVote, - } = useVoteActionForm({ previousVote, voteContextHash, voteContextUrl, closeModal }); + } = useVoteActionForm({ + previousVote, + voteContextHash, + voteContextUrl, + closeModal, + }); + + const handleVoteClick = (isVoteChanged: boolean) => { + let surveyResponsePayload: SurveyResponsePayload | undefined; + const linkedSurvey = + proposalSurvey?.linked && + proposalSurvey?.linkValidation?.valid && + proposalSurvey?.surveyDetailsValidation?.valid && + proposalSurvey?.surveyTxId && + proposalSurvey?.surveyDetails + ? proposalSurvey + : null; + + const linkedSurveyDetails = linkedSurvey?.surveyDetails; + const linkedSurveyTxId = linkedSurvey?.surveyTxId; + + if (linkedSurveyDetails && linkedSurveyTxId) { + const surveyDetails = linkedSurveyDetails; + const surveyTxId = linkedSurveyTxId; + let hasInvalidSurveyAnswer = false; + const answers: SurveyResponsePayload["answers"] = []; + + for (const question of surveyDetails.questions) { + const answer = surveyAnswers[question.questionId]; + if (!answer) { + continue; + } + + if (Array.isArray(answer.selection)) { + answers.push({ + questionId: question.questionId, + selection: answer.selection, + }); + continue; + } + + if ( + typeof answer.numericValue === "number" && + Number.isFinite(answer.numericValue) + ) { + answers.push({ + questionId: question.questionId, + numericValue: answer.numericValue, + }); + continue; + } + + if ( + typeof answer.customValue === "string" && + answer.customValue.trim() + ) { + try { + const customValue = JSON.parse(answer.customValue); + answers.push({ + questionId: question.questionId, + customValue, + }); + } catch (_error) { + setSurveyError("Invalid custom survey answer JSON."); + hasInvalidSurveyAnswer = true; + break; + } + } + } + + if (hasInvalidSurveyAnswer) { + return; + } + + if (answers.length) { + surveyResponsePayload = { + specVersion: "1.0.0", + surveyTxId, + responderRole: "DRep", + answers, + }; + } + } - const handleVoteClick = (isVoteChanged:boolean) => { + setSurveyError(null); openModal({ type: "voteContext", state: { onSubmit: (url, hash) => { setVoteContextUrl(url); setVoteContextHash(hash ?? undefined); - confirmVote(vote as Vote, url, hash); + confirmVote(vote as Vote, url, hash, surveyResponsePayload); setVoteContextData(url, hash); }, vote: vote as Vote, confirmVote, - previousRationale: isVoteChanged ? undefined : finalVoteContextText + previousRationale: isVoteChanged ? undefined : finalVoteContextText, } satisfies VoteContextModalState, }); }; @@ -91,10 +191,10 @@ export const VoteActionForm = ({ if (previousVote?.url) { setVoteContextUrl(previousVote.url); } - if (previousVote?.metadataHash) { + if (previousVote?.metadataHash) { setVoteContextHash(previousVote.metadataHash); } - }, [previousVote?.url, setVoteContextUrl]); + }, [previousVote?.metadataHash, previousVote?.url]); const renderCancelButton = useMemo( () => ( @@ -131,7 +231,7 @@ export const VoteActionForm = ({ {t("govActions.changeVote")} ), - [confirmVote, areFormErrors, vote, isVoteLoading], + [canVote, handleVoteClick, isVoteLoading, t], ); return ( @@ -223,6 +323,173 @@ export const VoteActionForm = ({ disabled={isInProgress} /> + {proposalSurvey?.linked && + proposalSurvey?.linkValidation?.valid && + proposalSurvey?.surveyDetailsValidation?.valid && + proposalSurvey?.surveyDetails && ( + + + Survey response + + + {proposalSurvey.surveyDetails.title} + + {proposalSurvey.surveyDetails.questions.map((question) => { + const localAnswer = surveyAnswers[question.questionId]; + const methodType = question.methodType; + const selection = localAnswer?.selection ?? []; + + return ( + + {question.question} + {(methodType === + "urn:cardano:poll-method:single-choice:v1" || + methodType === "urn:cardano:poll-method:multi-select:v1") && + (question.options ?? []).map((option, optionIndex) => { + const isSingle = + methodType === + "urn:cardano:poll-method:single-choice:v1"; + const checked = selection.includes(optionIndex); + + return ( + + ); + })} + {methodType === "urn:cardano:poll-method:numeric-range:v1" && ( + { + setSurveyError(null); + const numericValue = Number(event.target.value); + setSurveyAnswers((prev) => ({ + ...prev, + [question.questionId]: { numericValue }, + })); + }} + /> + )} + {![ + "urn:cardano:poll-method:single-choice:v1", + "urn:cardano:poll-method:multi-select:v1", + "urn:cardano:poll-method:numeric-range:v1", + ].includes(methodType) && ( +