diff --git a/package.json b/package.json index b0ba1ca..4459c4a 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "vocdoni-explorer-v3", + "name": "vocdoni-explorer", "private": true, "description": "Frontend to explore the Vocdoni voting blockchain", "license": "unlicense", @@ -19,7 +19,7 @@ "@emotion/styled": "^11.10.6", "@tanstack/react-query": "^5.40.0", "@vocdoni/chakra-components": "~0.8.1", - "@vocdoni/extended-sdk": "^0.0.1", + "@vocdoni/extended-sdk": "^0.0.2", "@vocdoni/sdk": "~0.8.1", "date-fns": "^2.29.3", "ethers": "^5.7.2", diff --git a/src/components/Organizations/Card.tsx b/src/components/Organizations/Card.tsx index 1623ae8..e9544e7 100644 --- a/src/components/Organizations/Card.tsx +++ b/src/components/Organizations/Card.tsx @@ -1,4 +1,4 @@ -import { Box, Card, CardBody, Text } from '@chakra-ui/react' +import { Box, Card, CardBody, Flex, Text } from '@chakra-ui/react' import { Trans, useTranslation } from 'react-i18next' import { ReducedTextAndCopy } from '~components/CopyBtn' import { OrganizationProvider, useOrganization } from '@vocdoni/react-providers' @@ -12,12 +12,12 @@ interface IOrganizationCardProps { const OrganizationCard = ({ id, ...rest }: IOrganizationCardProps) => { return ( - + ) } -const OrganizationCardContent = ({ id, electionCount }: IOrganizationCardProps) => { +const LargeOrganizationCard = ({ id, electionCount }: IOrganizationCardProps) => { const { organization, loading } = useOrganization() const { t } = useTranslation() @@ -53,4 +53,28 @@ const OrganizationCardContent = ({ id, electionCount }: IOrganizationCardProps) ) } +export const SmallOrganizationCard = ({ id }: IOrganizationCardProps) => { + const { organization } = useOrganization() + const { t } = useTranslation() + + const name = organization?.account.name.default || organization?.address + + return ( + + + + + + {name} + + + ) +} + export default OrganizationCard diff --git a/src/components/Organizations/OrganizationsList.tsx b/src/components/Organizations/OrganizationsList.tsx index a1064c7..d376da3 100644 --- a/src/components/Organizations/OrganizationsList.tsx +++ b/src/components/Organizations/OrganizationsList.tsx @@ -1,31 +1,26 @@ -import { ChangeEvent } from 'react' import { useTranslation } from 'react-i18next' +import { InputSearch } from '~src/layout/Inputs' +import { useOrganizationCount, useOrganizationList } from '~queries/organizations' import { generatePath, useNavigate, useParams } from 'react-router-dom' import OrganizationCard from '~components/Organizations/Card' import { RoutedPagination } from '~components/Pagination/Pagination' import { RoutedPaginationProvider } from '~components/Pagination/PaginationProvider' -import { useOrganizationCount, useOrganizationList } from '~queries/organizations' -import { InputSearch } from '~src/layout/Inputs' import { LoadingCards } from '~src/layout/Loading' import LoadingError from '~src/layout/LoadingError' import { organizationsListPath } from '~src/router' -import { debounce } from '~utils/debounce' export const OrganizationsFilter = () => { const { t } = useTranslation() const navigate = useNavigate() - const debouncedSearch = debounce((value) => { - navigate(generatePath(organizationsListPath, { page: '1', query: value as string })) - }, 1000) - return ( ) => { - debouncedSearch(event.target.value) + onChange={(value: string) => { + navigate(generatePath(organizationsListPath, { page: '0', query: value as string })) }} + debounceTime={500} /> ) } diff --git a/src/components/Organizations/StatusBadge.tsx b/src/components/Organizations/StatusBadge.tsx new file mode 100644 index 0000000..3a2747d --- /dev/null +++ b/src/components/Organizations/StatusBadge.tsx @@ -0,0 +1,20 @@ +import React from 'react' +import { ElectionStatus } from '@vocdoni/sdk' +import { ElectionStatusBadge as ComponentsStatusBadge } from '@vocdoni/chakra-components' + +export const ElectionStatusBadge = ({ status }: { status: ElectionStatus }) => { + switch (status) { + case ElectionStatus.ONGOING: + return + case ElectionStatus.RESULTS: + case ElectionStatus.ENDED: + return + case ElectionStatus.UPCOMING: + return + case ElectionStatus.PAUSED: + case ElectionStatus.CANCELED: + return + default: + return <> + } +} diff --git a/src/components/Process/Card.tsx b/src/components/Process/Card.tsx new file mode 100644 index 0000000..8034c41 --- /dev/null +++ b/src/components/Process/Card.tsx @@ -0,0 +1,51 @@ +import { Card, CardBody, Flex, HStack } from '@chakra-ui/react' +import { ElectionProvider, OrganizationProvider, useElection } from '@vocdoni/react-providers' +import { InvalidElection, PublishedElection } from '@vocdoni/sdk' +import { ElectionSchedule, ElectionTitle } from '@vocdoni/chakra-components' +import { ElectionStatusBadge } from '~components/Organizations/StatusBadge' +import { SmallOrganizationCard } from '~components/Organizations/Card' + +/** + * Show election card information + * @param id If id provided it will fetch the election from the API + * @param election already loaded election info to show + * @constructor + */ +const ElectionCard = ({ id, election }: { id?: string; election?: PublishedElection }) => { + return ( + + + + ) +} + +const ElectionCardContent = () => { + const { election } = useElection() + + if (election instanceof InvalidElection || !election) return null + + return ( + + + + + + + + + + + + + + + ) +} + +export default ElectionCard diff --git a/src/components/Process/ProcessList.tsx b/src/components/Process/ProcessList.tsx new file mode 100644 index 0000000..3cf637c --- /dev/null +++ b/src/components/Process/ProcessList.tsx @@ -0,0 +1,126 @@ +import { useParams } from 'react-router-dom' +import { RoutedPaginationProvider } from '~components/Pagination/PaginationProvider' +import { RoutedPagination } from '~components/Pagination/Pagination' +import LoadingError from '~src/layout/LoadingError' +import { LoadingCards } from '~src/layout/Loading' +import { useProcessesCount, useProcessList } from '~queries/processes' +import ElectionCard from './Card' +import { processListPath } from '~src/router' +import { Trans, useTranslation } from 'react-i18next' +import useQueryParams from '~src/router/use-query-params' +import { InputSearch } from '~src/layout/Inputs' +import { IElectionListFilter } from '@vocdoni/sdk' +import { Button, Checkbox, Flex } from '@chakra-ui/react' +import { isEmpty } from '~utils/objects' + +type FilterQueryParams = { + [K in keyof Omit]: string +} + +export const ProcessSearchBox = () => { + const { t } = useTranslation() + const { queryParams, setQueryParams } = useQueryParams() + + return ( + + setQueryParams({ ...queryParams, withResults: e.target.checked ? 'true' : undefined })} + > + Show only processes with results + + + { + setQueryParams({ ...queryParams, electionId: value }) + }} + debounceTime={500} + /> + + + ) +} + +export const ProcessByTypeFilter = () => { + const { t } = useTranslation() + const { queryParams, setQueryParams } = useQueryParams() + + const processStatusFilters = [ + { + label: t('process.by_status_all'), + value: undefined, + }, + { + label: t('process.by_status_all_active'), + value: 'READY', + }, + { + label: t('process.by_status_paused'), + value: 'PAUSED', + }, + { + label: t('process.by_status_ended'), + value: 'ENDED', + }, + ] + + return ( + + {processStatusFilters.map((filter, i) => ( + + ))} + + ) +} + +export const PaginatedProcessList = () => { + const { page }: { page?: number } = useParams() + const { data: processCount, isLoading: isLoadingCount } = useProcessesCount() + const count = processCount || 0 + const { queryParams: processFilters } = useQueryParams() + + // If no filters applied we can calculate the total pages using process total count + let totalPages: number | undefined = undefined + if (isEmpty(processFilters)) { + totalPages = Math.ceil(count / 10) + } + + const { + data: processes, + isLoading: isLoadingOrgs, + isError, + error, + } = useProcessList({ + page: Number(page || 0), + filters: { + electionId: processFilters.electionId, + // organizationId: processFilters.electionId, + status: processFilters.status as IElectionListFilter['status'], + withResults: processFilters.withResults ? processFilters.withResults === 'true' : undefined, + }, + }) + + const isLoading = isLoadingCount || isLoadingOrgs + + if (isLoading) { + return + } + + if (!processes || processes?.elections.length === 0 || isError) { + return + } + + return ( + + {processes?.elections.map((election, i) => )} + + + ) +} diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index a1ec800..628be9c 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next' import { RxHamburgerMenu } from 'react-icons/rx' import { generatePath } from 'react-router-dom' import { VocdoniEnvironment } from '~constants' -import { organizationsListPath } from '~src/router' +import { organizationsListPath, processListPath } from '~src/router' import logoUrl from '/images/logo-header.png' import logoStgUrl from '/images/logo-header-stg.png' @@ -52,7 +52,7 @@ export const TopBar = () => { }, { name: t('links.processes'), - url: '', + url: generatePath(processListPath, { page: null }), }, { name: t('links.blocks'), diff --git a/src/layout/Inputs.tsx b/src/layout/Inputs.tsx index 0d84c4f..21931ec 100644 --- a/src/layout/Inputs.tsx +++ b/src/layout/Inputs.tsx @@ -1,13 +1,31 @@ import { Input, InputGroup, InputLeftElement, InputProps } from '@chakra-ui/react' import { BiSearchAlt } from 'react-icons/bi' +import { debounce } from '~utils/debounce' +import { ChangeEvent } from 'react' + +export const InputSearch = ({ + debounceTime = 0, + onChange, + ...props +}: { + debounceTime?: number + onChange?: (event: string) => void +} & Omit) => { + const debouncedSearch = debounce((value: string) => { + if (onChange) onChange(value) + }, debounceTime) -export const InputSearch = (props: InputProps) => { return ( - + ) => { + debouncedSearch(event.target.value) + }} + /> ) } diff --git a/src/layout/ListPageLayout.tsx b/src/layout/ListPageLayout.tsx index 4670a18..a92aa63 100644 --- a/src/layout/ListPageLayout.tsx +++ b/src/layout/ListPageLayout.tsx @@ -1,4 +1,4 @@ -import { Box, Flex, Heading, Text } from '@chakra-ui/react' +import { Flex, Heading, Text } from '@chakra-ui/react' import { PropsWithChildren, ReactNode } from 'react' const ListPageLayout = ({ @@ -13,14 +13,18 @@ const ListPageLayout = ({ } & PropsWithChildren) => { return ( - + {title} {subtitle && {subtitle}} - {rightComponent && {rightComponent}} + {rightComponent && ( + + {rightComponent} + + )} {children} diff --git a/src/pages/Process/List.tsx b/src/pages/Process/List.tsx new file mode 100644 index 0000000..6ee2926 --- /dev/null +++ b/src/pages/Process/List.tsx @@ -0,0 +1,20 @@ +import ListPageLayout from '~src/layout/ListPageLayout' +import { useTranslation } from 'react-i18next' +import { useProcessesCount } from '~queries/processes' +import { PaginatedProcessList, ProcessByTypeFilter, ProcessSearchBox } from '~components/Process/ProcessList' + +const ProcessList = () => { + const { t } = useTranslation() + const { data, isLoading } = useProcessesCount() + + const subtitle = !isLoading ? t('process.process_count', { count: data || 0 }) : '' + + return ( + }> + + + + ) +} + +export default ProcessList diff --git a/src/queries/processes.ts b/src/queries/processes.ts new file mode 100644 index 0000000..010f019 --- /dev/null +++ b/src/queries/processes.ts @@ -0,0 +1,41 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { useClient } from '@vocdoni/react-providers' +import { ExtendedSDKClient } from '@vocdoni/extended-sdk' +import { Census, IElectionListFilter, IElectionListResponse, PublishedElection } from '@vocdoni/sdk' +import { useChainInfo, useChainInfoOptions } from '~queries/stats' + +export const useProcessList = ({ + page, + filters, + ...options +}: { + page: number + filters?: IElectionListFilter +} & Omit, 'queryKey'>) => { + const { client } = useClient() + return useQuery({ + queryKey: ['organizations', 'list', page, filters], + queryFn: () => client.electionList(page, { ...filters }), + select: (data) => { + const elections = data?.elections.map((election) => { + // @ts-ignore + return PublishedElection.build({ + ...election, + id: election.electionId, + title: election.electionId, + census: {} as Census, + startDate: new Date(election.startDate), + endDate: new Date(election.endDate), + }) + }) + return { elections } + }, + ...options, + }) +} + +export const useProcessesCount = (options?: useChainInfoOptions) => { + const { data, ...rest } = useChainInfo({ ...options }) + const count = data?.electionCount || 0 + return { data: count, ...rest } +} diff --git a/src/queries/stats.ts b/src/queries/stats.ts index 8d3c574..2b456ca 100644 --- a/src/queries/stats.ts +++ b/src/queries/stats.ts @@ -3,7 +3,9 @@ import { useClient } from '@vocdoni/react-providers' import { IChainGetInfoResponse } from '@vocdoni/sdk' import { ExtendedSDKClient } from '@vocdoni/extended-sdk' -export const useChainInfo = (options?: Omit, 'queryKey'>) => { +export type useChainInfoOptions = Omit, 'queryKey'> + +export const useChainInfo = (options?: useChainInfoOptions) => { const { client } = useClient() return useQuery({ diff --git a/src/router/index.tsx b/src/router/index.tsx index 6d536c2..3c43ed5 100644 --- a/src/router/index.tsx +++ b/src/router/index.tsx @@ -8,12 +8,14 @@ import Layout from '~src/layout/Default' export const basePath = '/' export const organizationsListPath = '/organizations/:page?/:query?' +export const processListPath = '/processs/:page?' export const processPath = '/process/:pid' export const organizationPath = '/organization/:pid' const Home = lazy(() => import('~pages/Home')) const Organization = lazy(() => import('~pages/Organization/Organization')) const OrganizationList = lazy(() => import('~pages/Organization/List')) +const ProcessList = lazy(() => import('~pages/Process/List')) const Vote = lazy(() => import('~pages/Vote')) export const RoutesProvider = () => { @@ -40,6 +42,14 @@ export const RoutesProvider = () => { ), }, + { + path: processListPath, + element: ( + + + + ), + }, { path: processPath, element: ( diff --git a/src/router/use-query-params.ts b/src/router/use-query-params.ts new file mode 100644 index 0000000..64cf202 --- /dev/null +++ b/src/router/use-query-params.ts @@ -0,0 +1,34 @@ +import { useMemo } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' + +const useQueryParams = >() => { + const { search } = useLocation() + const navigate = useNavigate() + const location = useLocation() + + const queryParams = useMemo(() => { + const params = new URLSearchParams(search) + const queryObj = {} as T + for (const [key, value] of params.entries()) { + // @ts-ignore + queryObj[key as keyof T] = value + } + return queryObj + }, [search]) + + const setQueryParams = (newParams: Partial) => { + const params = new URLSearchParams(search) + for (const key in newParams) { + if (newParams[key]) { + params.set(key, newParams[key] as string) + } else { + params.delete(key) + } + } + navigate(`${location.pathname}?${params.toString()}`) + } + + return { queryParams, setQueryParams } +} + +export default useQueryParams diff --git a/src/theme/components/Tag.tsx b/src/theme/components/Tag.tsx new file mode 100644 index 0000000..a1c1a8e --- /dev/null +++ b/src/theme/components/Tag.tsx @@ -0,0 +1,24 @@ +import { tagAnatomy } from '@chakra-ui/anatomy' +import { createMultiStyleConfigHelpers } from '@chakra-ui/react' + +const { definePartsStyle, defineMultiStyleConfig } = createMultiStyleConfigHelpers(tagAnatomy.keys) + +const vocdoniStatusBadge = definePartsStyle({ + container: { + padding: '4px 12px', + borderRadius: '4px', + height: '18px', + fontStyle: 'normal', + fontWeight: '700', + fontSize: '12px', + lineHeight: '150%', + color: '#fff', + bg: 'rgb(35, 114, 116)', + }, +}) + +export const tagTheme = defineMultiStyleConfig({ + variants: { + vocdoni: vocdoniStatusBadge, + }, +}) diff --git a/src/theme/index.ts b/src/theme/index.ts index bbe1742..ea591cf 100644 --- a/src/theme/index.ts +++ b/src/theme/index.ts @@ -5,6 +5,7 @@ import Questions from './questions' import Radio from './radio' import ElectionResults from './results' import { colors } from '~src/theme/colors' +import { tagTheme } from '~src/theme/components/Tag' const theme = extendTheme(vtheme, { components: { @@ -13,6 +14,7 @@ const theme = extendTheme(vtheme, { Questions, Radio, ElectionResults, + Tag: tagTheme, }, colors, }) diff --git a/src/utils/objects.ts b/src/utils/objects.ts new file mode 100644 index 0000000..2792a05 --- /dev/null +++ b/src/utils/objects.ts @@ -0,0 +1,3 @@ +export function isEmpty(obj?: object): boolean { + return !obj || Object.keys(obj).length === 0 +} diff --git a/yarn.lock b/yarn.lock index 4695f66..3874b70 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1959,16 +1959,16 @@ react-refresh "^0.14.0" "@vocdoni/chakra-components@~0.8.1": - version "0.8.3" - resolved "https://registry.yarnpkg.com/@vocdoni/chakra-components/-/chakra-components-0.8.3.tgz#b286d5b7313ac963cad59174bbe3792de1878629" - integrity sha512-iCCDXyHZ4i8rC3T5OmV9mtj8+aFjvBVn/G7icZKSyKLMRG1D4rEsUYqKMXH/az4XP8RVVeGCRIOrXtV/EZ3hLg== + version "0.8.5" + resolved "https://registry.yarnpkg.com/@vocdoni/chakra-components/-/chakra-components-0.8.5.tgz#c4ea39243f69420359ffef46d29a384abdd78949" + integrity sha512-qAxslKof1zQsrPqEZS894193f/rQi00MfYSoIYDyQZ8hfu6h25ocqTEfUr+bTz4hZ7i2cJwe4xac5Zqy0WPKGA== dependencies: - "@vocdoni/react-providers" "~0.4.1" + "@vocdoni/react-providers" "~0.4.3" -"@vocdoni/extended-sdk@^0.0.1": - version "0.0.1" - resolved "https://registry.yarnpkg.com/@vocdoni/extended-sdk/-/extended-sdk-0.0.1.tgz#224be07c05fafcfd4b863b8b01826496091ee62d" - integrity sha512-fuEqaPMzeiUr54LEg92yfAk6/dHsVgsSJDyKxNkg8iRqALK2kOw2L4N0PVTnLbC3iT7Kwaa9LrhDTopgr8kWQA== +"@vocdoni/extended-sdk@^0.0.2": + version "0.0.2" + resolved "https://registry.yarnpkg.com/@vocdoni/extended-sdk/-/extended-sdk-0.0.2.tgz#837563b282ca7ebebdd2748921eb495fda66174f" + integrity sha512-S34T8fd1UoEfbYHEhVLluOCl4b7pAOq36Krd53kBx2Rb+cOeUbqs7M+RrqM9wOc34AxEYNeMnMA2MVQM7napaA== "@vocdoni/proto@1.15.5": version "1.15.5" @@ -1978,10 +1978,10 @@ long "^5.2.1" protobufjs "^7.1.2" -"@vocdoni/react-providers@~0.4.1": - version "0.4.2" - resolved "https://registry.yarnpkg.com/@vocdoni/react-providers/-/react-providers-0.4.2.tgz#5d465b23914d3074b25e427d7afe7d5e33d82bdb" - integrity sha512-tFlmeLCQZjHrB+6Fm2IvZMIh6+JPr0iIu2ERe3DGzMmlmLf6ba1uYufXdgWT2QyFtW59rWDfXQrUm38MGwUOUw== +"@vocdoni/react-providers@~0.4.3": + version "0.4.3" + resolved "https://registry.yarnpkg.com/@vocdoni/react-providers/-/react-providers-0.4.3.tgz#0ce49356db26b75a9b1d09d1703bf0451a0aaaa5" + integrity sha512-wgrzodMdNwwe+PvZqxfe8r56KM1dhFUZ1p6RnCZ+RKqsoB1J4VzPH/AUG3iPC66bbzG17dwkr5PFgqkNsv/lvA== "@vocdoni/sdk@~0.8.1": version "0.8.1"