diff --git a/pages/ballot/[pid]/[[...tid]].tsx b/pages/ballot/[pid]/[[...tid]].tsx index 25a88ea..83e31ea 100644 --- a/pages/ballot/[pid]/[[...tid]].tsx +++ b/pages/ballot/[pid]/[[...tid]].tsx @@ -1,38 +1,51 @@ import {useState, useCallback, useEffect, MouseEvent} from 'react'; +import Image from 'next/image'; import Head from 'next/head'; import {useRouter} from 'next/router'; import {serverSideTranslations} from 'next-i18next/serverSideTranslations'; import {useTranslation} from 'next-i18next'; -import { - Button, - Col, - Container, - Row, -} from 'reactstrap'; +import {Col, Row, Container} from 'reactstrap'; // import {toast, ToastContainer} from "react-toastify"; import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; import {faCalendarDays, faCheck} from '@fortawesome/free-solid-svg-icons'; -import {getElection, castBallot, apiErrors, ElectionPayload} from '@services/api'; -import ErrorMessage from '@components/Error'; +import {getElection, castBallot, apiErrors, ElectionPayload, CandidatePayload, GradePayload} from '@services/api'; +import Button from '@components/Button'; import useEmblaCarousel from 'embla-carousel-react'; import {DotButton} from '@components/admin/EmblaCarouselButtons'; -import VoteButtonWithConfirm from '@components/admin/VoteButtonWithConfirm'; import {getGradeColor} from '@services/grades'; +import {useBallot, BallotTypes, BallotProvider} from '@services/BallotContext'; +import {getLocaleShort} from '@services/utils'; +import {ENDED_VOTE} from '@services/routes'; +import defaultAvatar from '../../../public/avatarBlue.svg'; + const shuffle = (array) => array.sort(() => Math.random() - 0.5); export async function getServerSideProps({query: {pid, tid}, locale}) { + if (!pid) { + return {notFound: true} + } + const [election, translations] = await Promise.all([ getElection(pid), serverSideTranslations(locale, ['resource']), ]); if (typeof election === 'string' || election instanceof String) { - return {props: {err: election, ...translations}}; + return {notFound: true} + } + + const dateEnd = new Date(election.date_end) + if (dateEnd.getDate() > new Date().getDate()) { + return { + redirect: ENDED_VOTE, + permanent: false + } } if (!election || !election.candidates || !Array.isArray(election.candidates)) { - return {props: {err: 'Unknown error', ...translations}}; + console.log(election); + return {notFound: true} } const description = JSON.parse(election.description); @@ -51,21 +64,140 @@ export async function getServerSideProps({query: {pid, tid}, locale}) { }; } +interface TitleBarInterface { + election: ElectionPayload; +} +const TitleBar = ({election}: TitleBarInterface) => { + const {t} = useTranslation(); + const router = useRouter(); + const locale = getLocaleShort(router); + + return ( +
+
+ +
+
+ {` ${t("vote.open-until")} ${new Date(election.date_end).toLocaleDateString(locale, {dateStyle: "long"})}`} +
+
+ ) +}; + +interface CandidateCardInterface { + candidate: CandidatePayload; +} +const CandidateCard = ({candidate}: CandidateCardInterface) => { + const {t} = useTranslation(); + return (
+ {t('common.thumbnail')} +
+ {candidate.name} +
+ {t("vote.more-details")} +
+
) +} + + +interface GradeInputInterface { + gradeId: number; + candidateId: number; +} +const GradeInput = ({gradeId, candidateId}: GradeInputInterface) => { + const [ballot, dispatch] = useBallot(); + if (!ballot) {throw Error("Ensure the election is loaded")} + + const grade = ballot.election.grades[gradeId]; + const numGrades = ballot.election.grades.length; + const color = getGradeColor(gradeId, numGrades); + + const handleClick = (event: MouseEvent) => { + dispatch({type: BallotTypes.VOTE, candidateId: candidateId, gradeId: gradeId}) + }; + + const active = ballot.votes.some(b => b.gradeId === gradeId && b.candidateId === candidateId) + + return ( +
+ + {grade.name} + + + { /**/} + {grade.name} + { /**/} + +
+ ) +} + interface VoteInterface { - election?: ElectionPayload; + election: ElectionPayload; err: string; token?: string; } - -const VoteBallot = ({election, err, token}: VoteInterface) => { +const VoteBallot = ({election, token}: VoteInterface) => { const {t} = useTranslation(); - if (err || !election) { - return ; + const router = useRouter(); + const [ballot, dispatch] = useBallot(); + + useEffect(() => { + dispatch({ + type: BallotTypes.ELECTION, + election: election, + }); + }, []); + + if (!ballot.election) { + return
"Loading..."
; } - const numGrades = election.grades.length; - const [judgments, setJudgments] = useState([]); + const numGrades = ballot.election.grades.length; + const disabled = ballot.votes.length !== ballot.election.candidates.length; const colSizeCandidateLg = 4; const colSizeCandidateMd = 6; const colSizeCandidateXs = 12; @@ -73,21 +205,6 @@ const VoteBallot = ({election, err, token}: VoteInterface) => { const colSizeGradeMd = Math.floor((12 - colSizeCandidateMd) / numGrades); const colSizeGradeXs = Math.floor((12 - colSizeCandidateXs) / numGrades); - const router = useRouter(); - - const handleGradeClick = (event: MouseEvent) => { - let data = { - id: parseInt(event.currentTarget.getAttribute('data-id')), - value: parseInt(event.currentTarget.value), - }; - //remove candidate - const newJudgments = judgments.filter( - (judgment) => judgment.id !== data.id - ); - newJudgments.push(data); - setJudgments(newJudgments); - }; - const handleSubmitWithoutAllRate = () => { alert(t('You have to judge every candidate/proposal!')); }; @@ -95,49 +212,49 @@ const VoteBallot = ({election, err, token}: VoteInterface) => { const handleSubmit = (event) => { event.preventDefault(); - const gradesById = {}; - judgments.forEach((c) => { - gradesById[c.id] = c.value; - }); - const gradesByCandidate = []; - Object.keys(gradesById).forEach((id) => { - gradesByCandidate.push(gradesById[id]); - }); + // const gradesById = {}; + // judgments.forEach((c) => { + // gradesById[c.id] = c.value; + // }); + // const gradesByCandidate = []; + // Object.keys(gradesById).forEach((id) => { + // gradesByCandidate.push(gradesById[id]); + // }); - castBallot(gradesByCandidate, election.id.toString(), token, () => { - router.push(`/vote/${election.id}/confirm`); - }); + // castBallot(gradesByCandidate, election.id.toString(), token, () => { + router.push(`/confirm/${election.id}`); + // }); }; - const [viewportRef, embla] = useEmblaCarousel({skipSnaps: false}); - const [prevBtnEnabled, setPrevBtnEnabled] = useState(false); - const [nextBtnEnabled, setNextBtnEnabled] = useState(false); - const [selectedIndex, setSelectedIndex] = useState(0); - const [scrollSnaps, setScrollSnaps] = useState([]); - - const scrollPrev = useCallback(() => embla && embla.scrollPrev(), [embla]); - const scrollNext = useCallback(() => embla && embla.scrollNext(), [embla]); - const scrollTo = useCallback( - (index) => embla && embla.scrollTo(index), - [embla] - ); + // const [viewportRef, embla] = useEmblaCarousel({skipSnaps: false}); + // const [prevBtnEnabled, setPrevBtnEnabled] = useState(false); + // const [nextBtnEnabled, setNextBtnEnabled] = useState(false); + // const [selectedIndex, setSelectedIndex] = useState(0); + // const [scrollSnaps, setScrollSnaps] = useState([]); - const onSelect = useCallback(() => { - if (!embla) return; - setSelectedIndex(embla.selectedScrollSnap()); - setPrevBtnEnabled(embla.canScrollPrev()); - setNextBtnEnabled(embla.canScrollNext()); - }, [embla, setSelectedIndex]); + // const scrollPrev = useCallback(() => embla && embla.scrollPrev(), [embla]); + // const scrollNext = useCallback(() => embla && embla.scrollNext(), [embla]); + // const scrollTo = useCallback( + // (index) => embla && embla.scrollTo(index), + // [embla] + // ); - useEffect(() => { - if (!embla) return; - onSelect(); - setScrollSnaps(embla.scrollSnapList()); - embla.on('select', onSelect); - }, [embla, setScrollSnaps, onSelect]); + // const onSelect = useCallback(() => { + // if (!embla) return; + // setSelectedIndex(embla.selectedScrollSnap()); + // setPrevBtnEnabled(embla.canScrollPrev()); + // setNextBtnEnabled(embla.canScrollNext()); + // }, [embla, setSelectedIndex]); + + // useEffect(() => { + // if (!embla) return; + // onSelect(); + // setScrollSnaps(embla.scrollSnapList()); + // embla.on('select', onSelect); + // }, [embla, setScrollSnaps, onSelect]); return ( - + <> {election.name} @@ -149,173 +266,50 @@ const VoteBallot = ({election, err, token}: VoteInterface) => { /> -
- - VOTE OUVERT -
-
-

{election.name}

+ +
+

{election.name}

-
-
- {election.candidates.map((candidate, candidateId) => { - return ( -
- - -
{candidate.name}
-
{candidate.id + 1}
- - - {election.grades.map((grade, gradeId) => { - console.assert(gradeId < numGrades); - const gradeValue = grade.value; - const color = getGradeColor(gradeId, numGrades); - return ( - - - - ); - })} - -
-
-
- {candidate.id + 1} -
-
- Next -
-
-
- ); - })} -
-
- {scrollSnaps.map((_, index) => ( - scrollTo(index)} - value={index + 1} - /> - ))} -
-
-
-
- - - {judgments.length !== election.candidates.length ? ( - - ) : ( - - )} - - - + {election.candidates.map((candidate, candidateId) => { + return ( +
+ +
+ {election.grades.map((_, gradeId) => { + console.assert(gradeId < numGrades); + return ( + + ); + })} +
+
+ ); + })} + +
+ + + + ); }; -export default VoteBallot; + +const Ballot = (props) => { + + return ( + + + ) +} + +export default Ballot; diff --git a/public/locales/en/resource.json b/public/locales/en/resource.json index 4e4d236..8192375 100644 --- a/public/locales/en/resource.json +++ b/public/locales/en/resource.json @@ -47,7 +47,7 @@ "common.the-params": "The parameters", "common.welcome": "Welcome!", "error.at-least-2-candidates": "At least two candidates are required.", - "error.catch22": "Erreur inconnue...", + "error.catch22": "Unknown error...", "error.help": "Ask for our help", "error.no-title": "Please add a title to your election.", "grades.very-good": "Very good", @@ -107,5 +107,8 @@ "admin.success-copy-admin": "Copy the admin link", "admin.go-to-admin": "Manage the vote", "vote.home-desc": "Participate in the vote and discover majority judgment", - "vote.home-start": "I participate" + "vote.home-start": "I participate", + "vote.open-until": "Vote open until", + "vote.more-details": "More details...", + "vote.submit": "Cast my ballot" } diff --git a/public/locales/fr/resource.json b/public/locales/fr/resource.json index fc88c20..648f533 100644 --- a/public/locales/fr/resource.json +++ b/public/locales/fr/resource.json @@ -107,5 +107,8 @@ "admin.success-copy-admin": "Copier le lien d'administration", "admin.go-to-admin": "Administrez le vote", "vote.home-desc": "Participez au vote et découvrez le jugement majoritaire.", - "vote.home-start": "Je participe" + "vote.home-start": "Je participe", + "vote.open-until": "Vote ouvert jusqu'au", + "vote.more-details": "Cliquez ici pour en savoir plus", + "vote.submit": "Déposer mon bulletin de vote" } diff --git a/services/BallotContext.tsx b/services/BallotContext.tsx new file mode 100644 index 0000000..a67a519 --- /dev/null +++ b/services/BallotContext.tsx @@ -0,0 +1,97 @@ +/** + * This file provides a context and a reducer to manage a ballot + */ +import {createContext, useContext, useReducer, Dispatch} from 'react'; +import {ElectionPayload} from './api'; + + +export interface Vote { + candidateId: number; + gradeId: number; +} + +export interface BallotContextInterface { + election: ElectionPayload | null; + votes: Array; +} + +const defaultBallot: BallotContextInterface = { + election: null, + votes: [] +}; + + +export enum BallotTypes { + ELECTION = 'ELECTION', + VOTE = 'VOTE', +} + +export type ElectionAction = { + type: BallotTypes.ELECTION; + election: ElectionPayload; +} + +export type VoteAction = { + type: BallotTypes.VOTE; + candidateId: number; + gradeId: number; +} + +export type BallotActionTypes = ElectionAction | VoteAction; + + +function reducer(ballot: BallotContextInterface, action: BallotActionTypes) { + /** + * Manage all types of action doable on an election + */ + switch (action.type) { + case BallotTypes.ELECTION: { + return {...ballot, election: action.election} + } + case BallotTypes.VOTE: { + const votes = [...ballot.votes]; + const voteForCandidate = votes.filter(v => v.candidateId === action.candidateId); + if (voteForCandidate.length > 1) { + throw Error("Inconsistent ballot") + } + if (voteForCandidate.length === 1) { + voteForCandidate[0].candidateId = action.candidateId; + voteForCandidate[0].gradeId = action.gradeId; + } else { + votes.push({ + candidateId: action.candidateId, + gradeId: action.gradeId, + }) + } + return {...ballot, votes}; + } + default: { + return ballot + } + } +} + +type DispatchType = Dispatch; +const BallotContext = createContext<[BallotContextInterface, DispatchType]>([defaultBallot, () => {}]); +// const BallotContext = createContext([defaultBallot, () => {}]); + +export function BallotProvider({children}) { + /** + * Provide the election and the dispatch to all children components + */ + const [ballot, dispatch] = useReducer(reducer, defaultBallot); + + return ( + + {children} + + ); +} + +export function useBallot() { + /** + * A simple hook to manage ballots + */ + return useContext(BallotContext); +} + diff --git a/services/ElectionContext.tsx b/services/ElectionContext.tsx index 00e58ad..275ca3c 100644 --- a/services/ElectionContext.tsx +++ b/services/ElectionContext.tsx @@ -144,7 +144,7 @@ function electionReducer(election: ElectionContextInterface, action) { } case 'grade-set': { if (typeof action.position !== 'number') { - throw Error(`Unexpected grade position ${action.value}`); + throw Error(`Unexpected grade position ${action.position}`); } const grades = [...election.grades]; const grade = grades[action.position]; diff --git a/services/api.ts b/services/api.ts index a673d72..1e25aac 100644 --- a/services/api.ts +++ b/services/api.ts @@ -107,9 +107,7 @@ export const getResults = ( export const getElection = async ( pid: string, - successCallback = null, - failureCallback = null -): Promise => { +): Promise => { /** * Fetch data from external API */ @@ -121,14 +119,13 @@ export const getElection = async ( const res = await fetch(detailsEndpoint.href); if (!res.ok) { - return failureCallback(res.text()) + return res.text() } const payload: ElectionPayload = await res.json(); - if (successCallback) successCallback(payload); return payload; } catch (error) { - return failureCallback && failureCallback(error); + return error; } }; diff --git a/services/routes.ts b/services/routes.ts index 1b9e043..72b6659 100644 --- a/services/routes.ts +++ b/services/routes.ts @@ -6,6 +6,7 @@ import {getWindowUrl} from './utils'; export const CREATE_ELECTION = '/admin/new/'; export const BALLOT = '/ballot/'; +export const ENDED_VOTE = '/ballot/end'; export const VOTE = '/vote/'; export const RESULTS = '/result/'; diff --git a/styles/scss/config.scss b/styles/scss/config.scss index 830fdf3..1451474 100644 --- a/styles/scss/config.scss +++ b/styles/scss/config.scss @@ -33,6 +33,23 @@ $theme-colors: ( // "very-good": #A0CF1C, // "excellent": #3A9918, // ); +$font-size-base: 1rem; + +$h1-font-size: $font-size-base * 2.5; +$h2-font-size: $font-size-base * 2; +$h3-font-size: $font-size-base * 1.75; +$h4-font-size: $font-size-base * 1.5; +$h5-font-size: $font-size-base * 1.25; + +$font-sizes: ( + 1: $h1-font-size, + 2: $h2-font-size, + 3: $h3-font-size, + 4: $h4-font-size, + 5: $font-size-base, + 6: $font-size-base * 0.75 +); + @import "_bootstrap.scss";