@ -0,0 +1,28 @@
|
|||||||
|
# Usage: $ make
|
||||||
|
|
||||||
|
NPM := $(shell eval command -v npm)
|
||||||
|
APT := $(shell eval command -v apt)
|
||||||
|
|
||||||
|
|
||||||
|
.PHONY: help
|
||||||
|
|
||||||
|
help: ## Usage: make <concept>, eg: make install
|
||||||
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||||
|
|
||||||
|
|
||||||
|
install: ## Install required javascript dependencies
|
||||||
|
ifndef NPM
|
||||||
|
ifdef APT
|
||||||
|
@echo "Installing NPM debian package…"
|
||||||
|
sudo apt install -y npm
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
npm install
|
||||||
|
|
||||||
|
|
||||||
|
demo: ## Run locally at http://localhost:3000
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
|
||||||
|
love: ## Fund development of Majority Judgment
|
||||||
|
firefox https://www.paypal.com/donate/?hosted_button_id=QD6U4D323WV4S
|
@ -1,34 +1,61 @@
|
|||||||
[![Continuous Integration](https://circleci.com/gh/MieuxVoter/mvfront-react.svg?style=svg)](https://circleci.com/gh/MieuxVoter/mvfront-react)
|
# Front-end election web application using NextJs
|
||||||
[![Continuous Deployment](https://api.netlify.com/api/v1/badges/021c39c6-1018-4e3f-98e2-f808b4ea8f6d/deploy-status)](https://app.netlify.com/sites/epic-nightingale-99f910/deploys)
|
|
||||||
|
|
||||||
# Voting application in React
|
|
||||||
|
|
||||||
A demo is available at [our website](http://demo.mieuxvoter.fr/).
|
[![Netlify Status](https://api.netlify.com/api/v1/badges/e5e19870-2d67-4082-973e-593f58c56f87/deploy-status)](https://app.netlify.com/sites/mv-front-react/deploys)
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
1. Copy `.env` file into `.env.local` and set there environment variables.
|
:ballot_box: This project is going to be the default front-end for our [election application](https://app.mieuxvoter.fr).
|
||||||
2. Install [yarn](https://classic.yarnpkg.com/en/docs/install/#debian-stable).
|
|
||||||
3. Install dependencies:
|
|
||||||
```bash
|
|
||||||
$ cd mvfront-react
|
|
||||||
$ yarn install
|
|
||||||
```
|
|
||||||
|
|
||||||
## Translation
|
:computer: It is connected to our [back-end](https://github.com/MieuxVoter/mv-api-server-apiplatform). The back-end is used for storing the votes and computing the majority judgment ranking. You can use our back-end free of charge, but you can also start your own instance of the back-end using our Dockerfiles.
|
||||||
|
|
||||||
We are welcoming translations of the application in any language.
|
:incoming_envelope: The front-end is responsable for sending the invitation mails. You can find the mail templates [on the functions folder](./functions/send-invite-email).
|
||||||
To add a new language, copy a [language folder](./public/locale/i18n/en/) into a new folder with your language as a name.
|
|
||||||
Then, replace values in the JSON files.
|
|
||||||
|
|
||||||
To compile them, launch: `$ yarn translate`.
|
:world_map: The front-end stores its own translations. See below how you can edit them easily.
|
||||||
## Starting
|
|
||||||
|
|
||||||
|
|
||||||
In development, you might want to copy `.env` into `.env.local` and set the environment variables. Then launch `$ yarn start`
|
## :paintbrush: Customize your own application
|
||||||
|
|
||||||
For production, see our [CI/CD configuration](https://github.com/MieuxVoter/continuous-integration).
|
The separation between the front-end and the back-end makes it easy to customize your own application. Just install
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Launch `$ yarn test`
|
|
||||||
|
## :gear: Install options
|
||||||
|
|
||||||
|
**Option one:** One-click deploy
|
||||||
|
|
||||||
|
[![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/MieuxVoter/mv-front-nextjs&utm_source=github)
|
||||||
|
|
||||||
|
|
||||||
|
**Option two:** Manual clone
|
||||||
|
|
||||||
|
1. Clone this repo: `git clone https://github.com/MieuxVoter/mv-front-nextjs.git`
|
||||||
|
2. Navigate to the directory and install dependencies: `npm install` or `make`
|
||||||
|
3. Start a local server: `npm run dev` and open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
4. Make your changes
|
||||||
|
5. Deploy your project.
|
||||||
|
|
||||||
|
We advise for deploying the project to [Netlify](https://netlify.com), because we wrote the mail functions for the framework. Netlify parameters are written in `netlify.toml`.
|
||||||
|
|
||||||
|
If you decide to deploy your project in another way, please fill a pull-request to guide futur users!
|
||||||
|
|
||||||
|
## :incoming_envelope: Support for mail
|
||||||
|
|
||||||
|
To add support for mail sending, you need to connect the application with a mailing service. For now, we only support [Mailgun](mailgun.com), which offer very competitive prices. You can fill an issue if you require another mailing service.
|
||||||
|
|
||||||
|
To connect your application with Mailgun, you need to add the environment variables to your project:
|
||||||
|
|
||||||
|
- `MAILGUN_API_KEY`,
|
||||||
|
- `MAILGUN_DOMAIN`,
|
||||||
|
- `MAILGUN_URL`,
|
||||||
|
- `FROM_EMAIL_ADDRESS`,
|
||||||
|
- `CONTACT_TO_EMAIL_ADDRESS`.
|
||||||
|
|
||||||
|
You can add the environment variables on an `.env` file or directly on [Netlify](https://docs.netlify.com/configure-builds/environment-variables/).
|
||||||
|
|
||||||
|
|
||||||
|
## :world_map: I18N at heart
|
||||||
|
|
||||||
|
You can directly modified the translation files in the folder `public/locales`.
|
||||||
|
|
||||||
|
In case you want to add support for another language, you need as well to add it on `net-i18next.config.js` and on the `LanguageSelector` component.
|
||||||
|
|
||||||
|
@ -0,0 +1,57 @@
|
|||||||
|
/* eslint react/prop-types: 0 */
|
||||||
|
import React from "react";
|
||||||
|
import { Button } from "reactstrap";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
const CopyField = props => {
|
||||||
|
const ref = React.createRef();
|
||||||
|
const handleClickOnField = event => {
|
||||||
|
event.target.focus();
|
||||||
|
event.target.select();
|
||||||
|
};
|
||||||
|
const handleClickOnButton = () => {
|
||||||
|
const input = ref.current;
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
};
|
||||||
|
|
||||||
|
const { t, value, iconCopy } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="input-group ">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
ref={ref}
|
||||||
|
value={value}
|
||||||
|
readOnly
|
||||||
|
onClick={handleClickOnField}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="input-group-append">
|
||||||
|
<Button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={handleClickOnButton}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={iconCopy} className="mr-2" />
|
||||||
|
{t("Copy")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/*<div className="input-group-append">
|
||||||
|
<a
|
||||||
|
className="btn btn-secondary"
|
||||||
|
href={value}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={iconOpen} className="mr-2" />
|
||||||
|
{t("Open")}
|
||||||
|
</a>
|
||||||
|
</div>*/}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CopyField;
|
@ -0,0 +1,35 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
import {Container, Row, Col} from "reactstrap";
|
||||||
|
import {useTranslation} from "next-i18next";
|
||||||
|
|
||||||
|
|
||||||
|
const Error = props => {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Row>
|
||||||
|
<Link href="/">
|
||||||
|
<a className="d-block ml-auto mr-auto mb-4">
|
||||||
|
<img src="/logos/logo-line-white.svg" alt="logo" height="128" />
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</Row>
|
||||||
|
<Row className="mt-4">
|
||||||
|
<Col className="text-center">
|
||||||
|
<h4>{props.value}</h4>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row className="mt-4">
|
||||||
|
<Col className="text-center">
|
||||||
|
<Link href="/">
|
||||||
|
<a className="btn btn-secondary">
|
||||||
|
{ t("common.backHomepage") }
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Error
|
@ -0,0 +1,29 @@
|
|||||||
|
/* eslint react/prop-types: 0 */
|
||||||
|
import React from "react";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faFacebookSquare } from "@fortawesome/free-brands-svg-icons";
|
||||||
|
|
||||||
|
const Facebook = props => {
|
||||||
|
const handleClick = () => {
|
||||||
|
const url =
|
||||||
|
"https://www.facebook.com/sharer.php?u=" +
|
||||||
|
props.url +
|
||||||
|
"&t=" +
|
||||||
|
props.title;
|
||||||
|
window.open(
|
||||||
|
url,
|
||||||
|
"",
|
||||||
|
"menubar=no,toolbar=no,resizable=yes,scrollbars=yes,height=500,width=700"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<button className={props.className} onClick={handleClick} type="button">
|
||||||
|
<FontAwesomeIcon icon={faFacebookSquare} className="mr-2" />
|
||||||
|
{props.text}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Facebook;
|
||||||
|
|
||||||
|
//i
|
@ -0,0 +1,25 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||||
|
import {faCommentAlt} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import {api} from "@services/api"
|
||||||
|
|
||||||
|
|
||||||
|
const Gform = (props) => {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
className={props.className}
|
||||||
|
href={api.feedbackForm}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faCommentAlt} className="mr-2" />
|
||||||
|
Votre avis nous intéresse !
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Gform.propTypes = {
|
||||||
|
className: PropTypes.string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Gform;
|
@ -0,0 +1,24 @@
|
|||||||
|
/* eslint react/prop-types: 0 */
|
||||||
|
import React from "react";
|
||||||
|
import i18n from "../../i18n";
|
||||||
|
|
||||||
|
const Helloasso = props => {
|
||||||
|
const locale =
|
||||||
|
i18n.language.substring(0, 2).toLowerCase() === "fr" ? "fr" : "en";
|
||||||
|
const linkHelloAssoBanner =
|
||||||
|
locale === "fr"
|
||||||
|
? "https://www.helloasso.com/associations/mieux-voter/formulaires/1/widget"
|
||||||
|
: "https://www.helloasso.com/associations/mieux-voter/formulaires/1/widget/en";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a href={linkHelloAssoBanner} target="_blank" rel="noopener noreferrer">
|
||||||
|
<img
|
||||||
|
src={"/banner/" + locale + "/helloasso.png"}
|
||||||
|
alt="support us on helloasso"
|
||||||
|
style={{ width: props.width }}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Helloasso;
|
@ -0,0 +1,44 @@
|
|||||||
|
import {useTranslation} from "next-i18next";
|
||||||
|
import {useRouter} from "next/router"
|
||||||
|
import {faPaypal} from "@fortawesome/free-brands-svg-icons";
|
||||||
|
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
const Paypal = () => {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
|
||||||
|
// FIXME generate a xx_XX string for locale version
|
||||||
|
const {locale} = useRouter();
|
||||||
|
let localeShort = locale.substring(0, 2);
|
||||||
|
let localeComplete =
|
||||||
|
localeShort.toLowerCase() + "_" + localeShort.toUpperCase();
|
||||||
|
if (localeComplete === "en_EN") {
|
||||||
|
localeComplete = "en_US";
|
||||||
|
}
|
||||||
|
const pixelLink =
|
||||||
|
`https://www.paypal.com/${localeComplete}/i/scr/pixel.gif`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="d-inline-block m-auto">
|
||||||
|
<form
|
||||||
|
action="https://www.paypal.com/cgi-bin/webscr"
|
||||||
|
method="post"
|
||||||
|
target="_top"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
title={t("PayPal - The safer, easier way to pay online!")}
|
||||||
|
>
|
||||||
|
{" "}
|
||||||
|
<FontAwesomeIcon icon={faPaypal} className="mr-2" />
|
||||||
|
{t("Support us !")}
|
||||||
|
</button>
|
||||||
|
<input type="hidden" name="cmd" value="_s-xclick" />
|
||||||
|
<input type="hidden" name="hosted_button_id" value="KB2Z7L9KARS7C" />
|
||||||
|
<img alt="" border="0" src={pixelLink} width="1" height="1" />
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Paypal;
|
@ -0,0 +1,4 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import FlagIconFactory from "react-flag-icon-css";
|
||||||
|
|
||||||
|
export const FlagIcon = FlagIconFactory(React, { useCssModules: false });
|
@ -0,0 +1,57 @@
|
|||||||
|
import {useState} from "react";
|
||||||
|
import {
|
||||||
|
faTrashAlt,
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import {Button, Modal, ModalHeader, ModalBody, ModalFooter} from "reactstrap";
|
||||||
|
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||||
|
import {useTranslation} from "next-i18next";
|
||||||
|
|
||||||
|
const ButtonWithConfirm = ({className, label, onDelete}) => {
|
||||||
|
const [visibled, setVisibility] = useState(false);
|
||||||
|
const {t} = useTranslation();
|
||||||
|
|
||||||
|
const toggle = () => setVisibility(!visibled)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="input-group-append">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={className}
|
||||||
|
onClick={toggle}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faTrashAlt} />
|
||||||
|
</button>
|
||||||
|
<Modal
|
||||||
|
isOpen={visibled}
|
||||||
|
toggle={toggle}
|
||||||
|
className="modal-dialog-centered"
|
||||||
|
>
|
||||||
|
<ModalHeader toggle={toggle}>{t("Delete?")}</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
{t("Are you sure to delete")}{" "}
|
||||||
|
{label && label !== "" ? (
|
||||||
|
<b>"{label}"</b>
|
||||||
|
) : (
|
||||||
|
<>{t("the row")}</>
|
||||||
|
)}{" "}
|
||||||
|
?
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button
|
||||||
|
color="primary-outline"
|
||||||
|
className="text-primary border-primary"
|
||||||
|
onClick={toggle}>
|
||||||
|
{t("No")}
|
||||||
|
</Button>
|
||||||
|
<Button color="primary"
|
||||||
|
onClick={() => {toggle(); onDelete();}}
|
||||||
|
>
|
||||||
|
{t("Yes")}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
|
</div >
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ButtonWithConfirm;
|
@ -0,0 +1,55 @@
|
|||||||
|
import {useState} from 'react'
|
||||||
|
import ButtonWithConfirm from "./ButtonWithConfirm";
|
||||||
|
import {
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Input,
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
} from "reactstrap";
|
||||||
|
import {useTranslation} from "react-i18next";
|
||||||
|
import {
|
||||||
|
sortableHandle
|
||||||
|
} from "react-sortable-hoc";
|
||||||
|
import HelpButton from "@components/form/HelpButton";
|
||||||
|
|
||||||
|
const DragHandle = sortableHandle(({children}) => (
|
||||||
|
<span className="input-group-text indexNumber">{children}</span>
|
||||||
|
));
|
||||||
|
const CandidateField = ({label, candIndex, onDelete, ...inputProps}) => {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupAddon addonType="prepend">
|
||||||
|
<DragHandle>
|
||||||
|
<span>{candIndex + 1}</span>
|
||||||
|
</DragHandle>
|
||||||
|
</InputGroupAddon>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={label}
|
||||||
|
{...inputProps}
|
||||||
|
placeholder={t("resource.candidatePlaceholder")}
|
||||||
|
tabIndex={candIndex + 1}
|
||||||
|
maxLength="250"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<ButtonWithConfirm className="btn btn-primary border-light" label={label} onDelete={onDelete}>
|
||||||
|
</ButtonWithConfirm>
|
||||||
|
</InputGroup>
|
||||||
|
</Col>
|
||||||
|
<Col xs="auto" className="align-self-center pl-0">
|
||||||
|
<HelpButton>
|
||||||
|
{t(
|
||||||
|
"Enter the name of your candidate or proposal here (250 characters max.)"
|
||||||
|
)}
|
||||||
|
</HelpButton>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CandidateField
|
@ -0,0 +1,128 @@
|
|||||||
|
import {useState, useEffect, createRef} from 'react'
|
||||||
|
import {useTranslation} from "react-i18next";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardBody
|
||||||
|
} from "reactstrap";
|
||||||
|
import {
|
||||||
|
faPlus,
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||||
|
import {
|
||||||
|
sortableContainer,
|
||||||
|
sortableElement,
|
||||||
|
sortableHandle
|
||||||
|
} from "react-sortable-hoc";
|
||||||
|
import arrayMove from "array-move"
|
||||||
|
import CandidateField from './CandidateField'
|
||||||
|
|
||||||
|
// const SortableItem = sortableElement(({className, ...childProps}) => <li className={className}><CandidateField {...childProps} /></li>);
|
||||||
|
//
|
||||||
|
// const SortableContainer = sortableContainer(({children}) => {
|
||||||
|
// return <ul className="sortable">{children}</ul>;
|
||||||
|
// });
|
||||||
|
|
||||||
|
const SortableItem = ({className, ...childProps}) => <li className={className}><CandidateField {...childProps} /></li>;
|
||||||
|
|
||||||
|
const SortableContainer = ({children}) => {
|
||||||
|
return <ul className="sortable">{children}</ul>;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const CandidatesField = ({onChange}) => {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const [candidates, setCandidates] = useState([])
|
||||||
|
|
||||||
|
const addCandidate = () => {
|
||||||
|
if (candidates.length < 1000) {
|
||||||
|
candidates.push({label: "", fieldRef: createRef()});
|
||||||
|
setCandidates([...candidates]);
|
||||||
|
onChange(candidates)
|
||||||
|
} else {
|
||||||
|
console.error("Too many candidates")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
addCandidate();
|
||||||
|
addCandidate();
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
|
const removeCandidate = index => {
|
||||||
|
if (candidates.length === 1) {
|
||||||
|
const newCandidates = []
|
||||||
|
newCandidates.push({label: "", fieldRef: createRef()});
|
||||||
|
newCandidates.push({label: "", fieldRef: createRef()});
|
||||||
|
setCandidates(newCandidates);
|
||||||
|
onChange(newCandidates)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const newCandidates = candidates.filter((c, i) => i != index)
|
||||||
|
setCandidates(newCandidates);
|
||||||
|
onChange(newCandidates);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const editCandidate = (index, label) => {
|
||||||
|
candidates[index].label = label
|
||||||
|
setCandidates([...candidates]);
|
||||||
|
onChange(candidates);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyPress = (e, index) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (index + 1 === candidates.length) {
|
||||||
|
addCandidate();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
candidates[index + 1].fieldRef.current.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSortEnd = ({oldIndex, newIndex}) => {
|
||||||
|
setCandidates(arrayMove(candidates, oldIndex, newIndex));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SortableContainer onSortEnd={onSortEnd}>
|
||||||
|
{candidates.map((candidate, index) => {
|
||||||
|
const className = "sortable"
|
||||||
|
return (
|
||||||
|
<SortableItem
|
||||||
|
className={className}
|
||||||
|
key={`item-${index}`}
|
||||||
|
index={index}
|
||||||
|
candIndex={index}
|
||||||
|
label={candidate.label}
|
||||||
|
onDelete={() => removeCandidate(index)}
|
||||||
|
onChange={(e) => editCandidate(index, e.target.value)}
|
||||||
|
onKeyPress={(e) => handleKeyPress(e, index)}
|
||||||
|
innerRef={candidate.fieldRef}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</SortableContainer>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="secondary"
|
||||||
|
className="btn-block mt-2"
|
||||||
|
tabIndex={candidates.length + 2}
|
||||||
|
type="button"
|
||||||
|
onClick={addCandidate}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPlus} className="mr-2" />
|
||||||
|
{t("Add a proposal")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default CandidatesField
|
||||||
|
|
@ -0,0 +1,164 @@
|
|||||||
|
import {useTranslation} from "next-i18next";
|
||||||
|
import {useState} from "react";
|
||||||
|
import {
|
||||||
|
faExclamationTriangle,
|
||||||
|
faCheck,
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import {Button, Modal, ModalHeader, ModalBody, ModalFooter} from "reactstrap";
|
||||||
|
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
const ConfirmModal = ({tabIndex, title, candidates, grades, isTimeLimited, start, finish, emails, restrictResult, className, confirmCallback}) => {
|
||||||
|
const [visibled, setVisibility] = useState(false);
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const toggle = () => setVisibility(!visibled)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="input-group-append">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={className}
|
||||||
|
onClick={toggle}
|
||||||
|
tabIndex={tabIndex}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faCheck} className="mr-2" />
|
||||||
|
{t("Validate")}
|
||||||
|
</button>
|
||||||
|
<Modal
|
||||||
|
isOpen={visibled}
|
||||||
|
toggle={toggle}
|
||||||
|
className="modal-dialog-centered"
|
||||||
|
>
|
||||||
|
<ModalHeader toggle={toggle}>
|
||||||
|
{t("Confirm your vote")}
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<div className="mt-1 mb-1">
|
||||||
|
<div className="text-white bg-primary p-2 pl-3 pr-3 rounded">
|
||||||
|
{t("Question of the election")}
|
||||||
|
</div>
|
||||||
|
<div className="p-2 pl-3 pr-3 bg-light mb-3">{title}</div>
|
||||||
|
<div className="text-white bg-primary p-2 pl-3 pr-3 rounded">
|
||||||
|
{t("Candidates/Proposals")}
|
||||||
|
</div>
|
||||||
|
<div className="p-2 pl-3 pr-3 bg-light mb-3">
|
||||||
|
<ul className="m-0 pl-4">
|
||||||
|
{candidates.map((candidate, i) => {
|
||||||
|
if (candidate.label !== "") {
|
||||||
|
return (
|
||||||
|
<li key={i} className="m-0">
|
||||||
|
{candidate.label}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return <li key={i} className="d-none" />;
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className={(isTimeLimited ? "d-block " : "d-none")} >
|
||||||
|
<div className="text-white bg-primary p-2 pl-3 pr-3 rounded">
|
||||||
|
{t("Dates")}
|
||||||
|
</div>
|
||||||
|
<div className="p-2 pl-3 pr-3 bg-light mb-3">
|
||||||
|
{t("The election will take place from")}{" "}
|
||||||
|
<b>
|
||||||
|
{start.toLocaleDateString()}, {t("at")}{" "}
|
||||||
|
{start.toLocaleTimeString()}
|
||||||
|
</b>{" "}
|
||||||
|
{t("to")}{" "}
|
||||||
|
<b>
|
||||||
|
{finish.toLocaleDateString()}, {t("at")}{" "}
|
||||||
|
{finish.toLocaleTimeString()}
|
||||||
|
</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-white bg-primary p-2 pl-3 pr-3 rounded">
|
||||||
|
{t("Grades")}
|
||||||
|
</div>
|
||||||
|
<div className="p-2 pl-3 pr-3 bg-light mb-3">
|
||||||
|
{grades.map((mention, i) => {
|
||||||
|
return i < grades.length ? (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="badge badge-light mr-2 mt-2"
|
||||||
|
style={{
|
||||||
|
backgroundColor: mention.color,
|
||||||
|
color: "#fff"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mention.label}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span key={i} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="text-white bg-primary p-2 pl-3 pr-3 rounded">
|
||||||
|
{t("Voters' list")}
|
||||||
|
</div>
|
||||||
|
<div className="p-2 pl-3 pr-3 bg-light mb-3">
|
||||||
|
{emails.length > 0 ? (
|
||||||
|
emails.join(", ")
|
||||||
|
) : (
|
||||||
|
<p>
|
||||||
|
{t("The form contains no address.")}
|
||||||
|
<br />
|
||||||
|
<em>
|
||||||
|
{t(
|
||||||
|
"The election will be opened to anyone with the link"
|
||||||
|
)}
|
||||||
|
</em>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{restrictResult ? (
|
||||||
|
<div>
|
||||||
|
<div className="small bg-primary text-white p-3 mt-2 rounded">
|
||||||
|
<h6 className="m-0 p-0">
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faExclamationTriangle}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
<u>{t("Results available at the close of the vote")}</u>
|
||||||
|
</h6>
|
||||||
|
<p className="m-2 p-0">
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
"The results page will not be accessible until the end date is reached."
|
||||||
|
)}{" "}
|
||||||
|
({finish.toLocaleDateString()} {t("at")}{" "}
|
||||||
|
{finish.toLocaleTimeString()})
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="small bg-primary text-white p-3 mt-2 rounded">
|
||||||
|
<h6 className="m-0 p-0">
|
||||||
|
{t("Results available at any time")}
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button
|
||||||
|
color="primary-outline"
|
||||||
|
className="text-primary border-primary"
|
||||||
|
onClick={toggle}>
|
||||||
|
{t("Cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button color="primary"
|
||||||
|
onClick={() => {toggle(); confirmCallback();}}
|
||||||
|
>
|
||||||
|
{t("Start the election")}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
|
</div >
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ConfirmModal
|
@ -0,0 +1,75 @@
|
|||||||
|
/* eslint react/prop-types: 0 */
|
||||||
|
import React, { Component } from "react";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
|
class HelpButton extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
tooltipOpen: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
showTooltip = () => {
|
||||||
|
this.setState({
|
||||||
|
tooltipOpen: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
hideTooltip = () => {
|
||||||
|
this.setState({
|
||||||
|
tooltipOpen: false
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<span className={this.props.className}>
|
||||||
|
<span>
|
||||||
|
{this.state.tooltipOpen ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
zIndex: 10,
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#000",
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
display: "inline-block",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
boxShadow: "-5px 0 5px rgba(0,0,0,0.5)",
|
||||||
|
maxWidth: "200px",
|
||||||
|
padding: "10px",
|
||||||
|
marginLeft: "-215px",
|
||||||
|
marginTop: "-25px"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
borderTop: "10px solid transparent",
|
||||||
|
borderBottom: "10px solid transparent",
|
||||||
|
borderLeft: "10px solid #fff",
|
||||||
|
marginLeft: "190px",
|
||||||
|
marginTop: "15px"
|
||||||
|
}}
|
||||||
|
></span>
|
||||||
|
{this.props.children}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faQuestionCircle}
|
||||||
|
onMouseOver={this.showTooltip}
|
||||||
|
onMouseOut={this.hideTooltip}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export default HelpButton;
|
@ -0,0 +1,94 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import {useTranslation} from "next-i18next";
|
||||||
|
import Paypal from "../banner/Paypal";
|
||||||
|
import {useBbox} from "./useBbox";
|
||||||
|
|
||||||
|
const Footer = () => {
|
||||||
|
const linkStyle = {whiteSpace: "nowrap"};
|
||||||
|
const {t} = useTranslation();
|
||||||
|
|
||||||
|
const [bboxLink1, link1] = useBbox();
|
||||||
|
const [bboxLink2, link2] = useBbox();
|
||||||
|
const [bboxLink3, link3] = useBbox();
|
||||||
|
const [bboxLink4, link4] = useBbox();
|
||||||
|
const [bboxLink5, link5] = useBbox();
|
||||||
|
const [bboxLink6, link6] = useBbox();
|
||||||
|
const [bboxLink7, link7] = useBbox();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="text-center">
|
||||||
|
<div>
|
||||||
|
<ul className="tacky">
|
||||||
|
<li
|
||||||
|
ref={link1}
|
||||||
|
className={bboxLink1.top === bboxLink2.top ? "" : "no-tack"}
|
||||||
|
>
|
||||||
|
<Link href="/" style={linkStyle}>
|
||||||
|
{t("Homepage")}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
ref={link2}
|
||||||
|
className={bboxLink2.top === bboxLink3.top ? "" : "no-tack"}
|
||||||
|
>
|
||||||
|
<Link href="/faq" style={linkStyle}>
|
||||||
|
{t("FAQ")}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
ref={link3}
|
||||||
|
className={bboxLink3.top === bboxLink4.top ? "" : "no-tack"}
|
||||||
|
>
|
||||||
|
<a href="mailto:app@mieuxvoter.fr?subject=[HELP]" style={linkStyle}>
|
||||||
|
{t("Need help?")}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
ref={link4}
|
||||||
|
className={bboxLink4.top === bboxLink5.top ? "" : "no-tack"}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="https://mieuxvoter.fr/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={linkStyle}
|
||||||
|
>
|
||||||
|
{t("Who are we?")}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
ref={link5}
|
||||||
|
className={bboxLink5.top === bboxLink6.top ? "" : "no-tack"}
|
||||||
|
>
|
||||||
|
<Link href="/privacy-policy" style={linkStyle}>
|
||||||
|
{t("Privacy policy")}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
ref={link6}
|
||||||
|
className={bboxLink6.top === bboxLink7.top ? "" : "no-tack"}
|
||||||
|
>
|
||||||
|
<Link href="/legal-notices" style={linkStyle}>
|
||||||
|
{t("resource.legalNotices")}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li ref={link7}>
|
||||||
|
{" "}
|
||||||
|
<a
|
||||||
|
href="https://github.com/MieuxVoter"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={linkStyle}
|
||||||
|
>
|
||||||
|
{t("Source code")}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<Paypal btnColor="btn-primary" />
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default Footer;
|
@ -0,0 +1,61 @@
|
|||||||
|
/* eslint react/prop-types: 0 */
|
||||||
|
import {useState} from "react";
|
||||||
|
import {Collapse, Navbar, NavbarToggler, Nav, NavItem} from "reactstrap";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Head from "next/head";
|
||||||
|
import {useTranslation} from 'next-i18next'
|
||||||
|
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||||
|
import {faRocket} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import LanguageSelector from "./LanguageSelector";
|
||||||
|
|
||||||
|
|
||||||
|
const Header = () => {
|
||||||
|
const [isOpen, setOpen] = useState(false)
|
||||||
|
|
||||||
|
const toggle = () => setOpen(!isOpen);
|
||||||
|
|
||||||
|
const {t} = useTranslation()
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head><title>{t("title")}</title></Head>
|
||||||
|
<header>
|
||||||
|
<Navbar color="light" light expand="md">
|
||||||
|
<Link href="/">
|
||||||
|
<a className="navbar-brand">
|
||||||
|
<div className="d-flex flex-row">
|
||||||
|
<div className="align-self-center">
|
||||||
|
<img src="/logos/logo-color.svg" alt="logo" height="32" />
|
||||||
|
</div>
|
||||||
|
<div className="align-self-center ml-2">
|
||||||
|
<div className="logo-text">
|
||||||
|
<h1>
|
||||||
|
{t("Voting platform")}
|
||||||
|
<small>{t("Majority Judgment")}</small>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
<NavbarToggler onClick={toggle} />
|
||||||
|
<Collapse isOpen={isOpen} navbar>
|
||||||
|
<Nav className="ml-auto" navbar>
|
||||||
|
<NavItem>
|
||||||
|
<Link href="/new/">
|
||||||
|
<a className="text-primary nav-link"> <FontAwesomeIcon icon={faRocket} className="mr-2" />
|
||||||
|
{t("Start an election")}
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</NavItem>
|
||||||
|
<NavItem style={{width: "80px"}}>
|
||||||
|
<LanguageSelector />
|
||||||
|
</NavItem>
|
||||||
|
</Nav>
|
||||||
|
</Collapse>
|
||||||
|
</Navbar>
|
||||||
|
</header>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Header;
|
@ -0,0 +1,32 @@
|
|||||||
|
import {useRouter} from 'next/router'
|
||||||
|
import ReactFlagsSelect from 'react-flags-select';
|
||||||
|
|
||||||
|
const LanguageSelector = () => {
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
let localeShort = router.locale.substring(0, 2).toUpperCase();
|
||||||
|
if (localeShort === "EN") localeShort = "GB";
|
||||||
|
|
||||||
|
const selectHandler = e => {
|
||||||
|
let locale = e.toLowerCase();
|
||||||
|
if (locale === "gb") locale = "en";
|
||||||
|
router.push("", "", {locale})
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<ReactFlagsSelect
|
||||||
|
onSelect={selectHandler}
|
||||||
|
countries={
|
||||||
|
// ["GB", "FR", "ES", "DE", "RU"]
|
||||||
|
["GB", "FR"]
|
||||||
|
}
|
||||||
|
showOptionLabel={false}
|
||||||
|
selected={localeShort}
|
||||||
|
selectedSize={15}
|
||||||
|
optionsSize={22}
|
||||||
|
showSelectedLabel={false}
|
||||||
|
showSecondaryOptionLabel={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LanguageSelector;
|
@ -0,0 +1,20 @@
|
|||||||
|
/* eslint react/prop-types: 0 */
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRef } from 'react';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export const useBbox = () => {
|
||||||
|
const ref = useRef();
|
||||||
|
const [bbox, setBbox] = useState({});
|
||||||
|
|
||||||
|
const set = () =>
|
||||||
|
setBbox(ref && ref.current ? ref.current.getBoundingClientRect() : {});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
set();
|
||||||
|
window.addEventListener('resize', set);
|
||||||
|
return () => window.removeEventListener('resize', set);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return [bbox, ref];
|
||||||
|
};
|
@ -0,0 +1,14 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Image from 'next/image'
|
||||||
|
|
||||||
|
const Loader = () => {
|
||||||
|
return (
|
||||||
|
<div className="loader bg-primary">
|
||||||
|
<img src="/loader-pulse-2.gif" alt="Loading..." />
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Loader;
|
@ -0,0 +1,8 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Loader from "../loader";
|
||||||
|
|
||||||
|
const Wait = () => {
|
||||||
|
return <Loader />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Wait;
|
After Width: | Height: | Size: 26 KiB |
After Width: | Height: | Size: 32 KiB |
@ -0,0 +1,216 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" xml:lang="en" xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
<head>
|
||||||
|
<title></title>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
<style type="text/css">
|
||||||
|
/* FONTS */
|
||||||
|
@media screen {
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Lato';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src: local('Lato Regular'), local('Lato-Regular'), url(https://fonts.gstatic.com/s/lato/v11/qIIYRU-oROkIk8vfvxw6QvesZW2xOQ-xsNqO47m55DA.woff) format('woff');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Lato';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
src: local('Lato Bold'), local('Lato-Bold'), url(https://fonts.gstatic.com/s/lato/v11/qdgUG4U09HnJwhYI-uK18wLUuEpTyoUstqEm5AMlJo4.woff) format('woff');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Lato';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
src: local('Lato Italic'), local('Lato-Italic'), url(https://fonts.gstatic.com/s/lato/v11/RYyZNoeFgb0l7W3Vu1aSWOvvDin1pK8aKteLpeZ5c0A.woff) format('woff');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Lato';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 700;
|
||||||
|
src: local('Lato Bold Italic'), local('Lato-BoldItalic'), url(https://fonts.gstatic.com/s/lato/v11/HkF_qI1x_noxlxhrhMQYELO3LdcAZYWl9Si6vvxL-qU.woff) format('woff');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CLIENT-SPECIFIC STYLES */
|
||||||
|
body, table, th, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
|
||||||
|
img { -ms-interpolation-mode: bicubic; border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none;}
|
||||||
|
|
||||||
|
/* RESET STYLES */
|
||||||
|
table { border-collapse: collapse !important; padding: 0 !important;}
|
||||||
|
body { height: 100% !important; margin: 0 !important; padding: 0 !important; width: 100% !important; }
|
||||||
|
|
||||||
|
/* iOS BLUE LINKS */
|
||||||
|
a[x-apple-data-detectors] {
|
||||||
|
color: inherit !important;
|
||||||
|
text-decoration: none !important;
|
||||||
|
font-size: inherit !important;
|
||||||
|
font-family: inherit !important;
|
||||||
|
font-weight: inherit !important;
|
||||||
|
line-height: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* MOBILE STYLES */
|
||||||
|
@media screen and (max-width:600px){
|
||||||
|
h1 {
|
||||||
|
font-size: 32px !important;
|
||||||
|
line-height: 32px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ANDROID CENTER FIX */
|
||||||
|
div[style*="margin: 16px 0;"] { margin: 0 !important; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body style="background-color: #f4f4f4; margin: 0 !important; padding: 0 !important;">
|
||||||
|
|
||||||
|
<!-- HIDDEN PREHEADER TEXT -->
|
||||||
|
<div style="display: none; font-size: 1px; color: #fefefe; line-height: 1px; font-family: 'Lato', Helvetica, Arial, sans-serif; max-height: 0px; max-width: 0px; opacity: 0; overflow: hidden;">
|
||||||
|
{{#i18n 'email.happy' }}We are happy to send you this email! You will be able to vote using majority judgment.{{/i18n}}
|
||||||
|
</div>
|
||||||
|
<table border="0" style="margin: 0px auto 0px auto; width: 100%;" aria-describedby="Email">
|
||||||
|
<!-- LOGO -->
|
||||||
|
<tr>
|
||||||
|
<th scope="col" style="background-color:#efefff ;">
|
||||||
|
<table border="0" style="margin: 0px auto 0px auto; width: 100%; max-width: 600px;" aria-describedby="Logo picture">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" style="vertical-align: top; padding: 40px 10px 40px 10px;">
|
||||||
|
<a href="https://mieuxvoter.fr/" target="_blank" rel="noopener noreferrer">
|
||||||
|
<img alt="Logo" src="https://mieuxvoter.fr/wp-content/uploads/2019/10/mieuxvoter_logo.png" width="40" height="40" style="display: block; margin: 0px auto 0px auto; width: 50%; max-width: 250px; min-width: 40px; height: auto; font-family: 'Lato', Helvetica, Arial, sans-serif; color: #ffffff; font-size: 18px;" border="0">
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
<!-- TITLE -->
|
||||||
|
<tr>
|
||||||
|
<th scope="col" style="background-color: #efefff; padding: 0px 10px 0px 10px;">
|
||||||
|
<table border="0" style="margin: 0px auto 0px auto; width: 100%; max-width: 600px;" aria-describedby="email title">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" style="vertical-align: top; background-color: #ffffff; padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #111111; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; letter-spacing: 4px; line-height: 48px;">
|
||||||
|
<h1 style="font-size: 48px; font-weight: 400; margin: 0;">{{#i18n 'email.hello'}}Hi, there! 🙂{{/i18n}}</h1>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
<!-- BLOCKS -->
|
||||||
|
<tr>
|
||||||
|
<th scope="col" style="background-color: #2a43a0; padding: 0px 10px 0px 10px;">
|
||||||
|
<table border="0" style="margin: 0px auto 0px auto; width: 100%; max-width: 600px;" aria-describedby="email body">
|
||||||
|
<!-- BLOCK SUBTITLE-->
|
||||||
|
<tr>
|
||||||
|
<th scope="col" style="background-color: #ffffff; padding: 20px 30px 40px 30px; color: #666666; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
|
||||||
|
<p style="margin: 0; text-align: left;">
|
||||||
|
{{#i18n 'email.happy'}}We are happy to send you this email! You will be able to vote using majority judgment.{{/i18n}}
|
||||||
|
</p>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
<!-- BLOCK EXPLANATION-->
|
||||||
|
<tr>
|
||||||
|
<th scope="col" style="background-color: #ffffff; padding: 20px 30px 40px 30px; color: #666666; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
|
||||||
|
<p style="margin: 0; text-align: left;">
|
||||||
|
{{#i18n 'email.why'}}This email was sent to you because your email address was entered to participate in the vote on the subject:{{/i18n}}
|
||||||
|
|
||||||
|
<strong>{{title}}</strong>
|
||||||
|
</p>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
<!-- BULLETPROOF BUTTON BLUE-->
|
||||||
|
<tr>
|
||||||
|
<th scope="col" style="background-color: #ffffff;">
|
||||||
|
<table border="0" style="margin: 0px auto 0px auto; width: 100%;" aria-describedby="Blue bulletproof button">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" style="background-color: #ffffff; padding: 20px 30px 60px 30px;">
|
||||||
|
<table border="0" style="margin: 0px auto 0px auto; border-collapse: collapse;" aria-describedby="invitation url">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" style="border-radius: 3px; background-color: #2a43a0;"><a href="{{invitation_url}}" target="_blank" style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; padding: 15px 25px; border-radius: 2px; border: 1px solid #2a43a0; display: inline-block;">{{#i18n 'common.vote' }}Vote!{{/i18n}}</a></th>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
<!-- BLOCK DOES NOT WORK -->
|
||||||
|
<tr>
|
||||||
|
<th scope="col" style="background-color: #ffffff; padding: 20px 30px 40px 30px; color: #666666; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
|
||||||
|
<p style="margin: 0; text-align: left;">
|
||||||
|
{{#i18n 'email.copyLink' }}If that doesn't work, copy and paste the following link into your browser:{{/i18n}}
|
||||||
|
|
||||||
|
<a target="_blank" style="color: #2a43a0;">{{invitation_url}}</a>
|
||||||
|
</p>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
<!-- BLOCK TEXT RESULT -->
|
||||||
|
<tr>
|
||||||
|
<th scope="col" style="background-color: #ffffff; padding: 20px 30px 20px 30px; color: #666666; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
|
||||||
|
<p style="margin: 0; text-align: left;">
|
||||||
|
{{#i18n email.linkResult }}The results will be available with the following link when the vote is finished:{{/i18n}}
|
||||||
|
|
||||||
|
<a target="_blank" style="color: #2a43a0;">{{result_url}}</a>
|
||||||
|
</p>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
<!-- BLOCK THANKS -->
|
||||||
|
<tr>
|
||||||
|
<th scope="col" style="background-color: #ffffff; padding: 0px 30px 40px 30px; border-radius: 0px 0px 4px 4px; color: #666666; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
|
||||||
|
<p style="margin: 0; text-align: left;">{{#i18n 'email.bye'}}Good vote{{/i18n}},<br>{{#i18n 'common.mieuxvoter'}}Mieux Voter{{/i18n}}</p>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
<!-- SUPPORT CALLOUT -->
|
||||||
|
<tr>
|
||||||
|
<th scope="col" style="background-color: #f4f4f4; padding: 30px 10px 0px 10px;">
|
||||||
|
<table border="0" style="margin: 0px auto 0px auto; width: 100%; max-width: 600px;" aria-describedby="support callout">
|
||||||
|
<!-- HEADLINE -->
|
||||||
|
<tr>
|
||||||
|
<th scope="col" style="background-color: #7d8ecf; padding: 30px 30px 30px 30px; border-radius: 4px 4px 4px 4px; color: #666666; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
|
||||||
|
<p style="margin: 0;"><strong>
|
||||||
|
<a href="https://mieuxvoter.fr/index.php/decouvrir/" target="_blank" style="color: #FFFFFF;" rel="noopener noreferrer">
|
||||||
|
{{#i18n 'email.aboutjm'}}Need any further information?{{/i18n}}
|
||||||
|
</a></strong>
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0;"> <strong>
|
||||||
|
<a href="https://mieuxvoter.fr/index.php/decouvrir/" target="_blank" style="color: #111111;" rel="noopener noreferrer">
|
||||||
|
{{#i18n 'common.helpus'}}Do you want to help us?{{/i18n}}
|
||||||
|
</a></strong>
|
||||||
|
</p>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
<!-- FOOTER -->
|
||||||
|
<tr>
|
||||||
|
<th scope="col" style="background-color: #f4f4f4; padding: 0px 10px 0px 10px;">
|
||||||
|
<table border="0" style="margin: 0px auto 0px auto; width: 100%; max-width: 600px;" aria-describedby="footer informations">
|
||||||
|
<!-- EXPLAIN WHY -->
|
||||||
|
</br>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" style="background-color: #f4f4f4; padding: 0px 30px 30px 30px; color: #666666; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 400; line-height: 18px;" >
|
||||||
|
<p style="margin: 0;">
|
||||||
|
{{#i18n email.why }}You received this email because someone invited you to vote.{{/i18n}}
|
||||||
|
</p>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
<!-- ADDRESS -->
|
||||||
|
<tr>
|
||||||
|
<th scope="col" style="background-color: #f4f4f4; padding: 0px 30px 30px 30px; color: #666666; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 400; line-height: 18px;" >
|
||||||
|
<p style="margin: 0;">{{#i18n mieuxvoter }}Mieux Voter{{/i18n}} - <a "mailto:app@mieuxvoter.fr">app@mieuxvoter.fr</a></p>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -0,0 +1,19 @@
|
|||||||
|
{{#i18n 'email.hello'}}Hi there! 🙂{{/i18n}}
|
||||||
|
|
||||||
|
{{#i18n 'email.happy'}}We are happy to send you this email! You will be able to vote using majority judgment.{{/i18n}}
|
||||||
|
|
||||||
|
{{#i18n 'email.why'}}This email was sent to you because your email was filled out to participate in the vote on the subject:{{/i18n}}
|
||||||
|
|
||||||
|
{{ title }}
|
||||||
|
|
||||||
|
{{#i18n 'email.linkVote' }}The link for the vote is as follows:{{/i18n}}
|
||||||
|
|
||||||
|
%recipient.urlVote%
|
||||||
|
|
||||||
|
{{#i18n 'email.linkResult' }}The link that will give you the results when they are available is as follows:{{/i18n}}
|
||||||
|
|
||||||
|
%recipient.urlResult%
|
||||||
|
|
||||||
|
{{#i18n 'email.bye'}}Good vote{{/i18n}}
|
||||||
|
|
||||||
|
{{#i18n 'common.mieuxvoter'}}Mieux Voter{{/i18n}}
|
@ -0,0 +1,135 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const Mailgun = require("mailgun.js");
|
||||||
|
const formData = require("form-data");
|
||||||
|
const dotenv = require("dotenv");
|
||||||
|
const i18next = require("i18next");
|
||||||
|
const Backend = require("i18next-chained-backend");
|
||||||
|
const FSBackend = require("i18next-fs-backend");
|
||||||
|
const HttpApi = require("i18next-http-backend");
|
||||||
|
const Handlebars = require("handlebars");
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
const {
|
||||||
|
MAILGUN_API_KEY,
|
||||||
|
MAILGUN_DOMAIN,
|
||||||
|
MAILGUN_URL,
|
||||||
|
FROM_EMAIL_ADDRESS,
|
||||||
|
CONTACT_TO_EMAIL_ADDRESS,
|
||||||
|
} = process.env;
|
||||||
|
|
||||||
|
const mailgun = new Mailgun(formData);
|
||||||
|
const mg = mailgun.client({
|
||||||
|
username: "api",
|
||||||
|
key: MAILGUN_API_KEY,
|
||||||
|
url: "https://api.eu.mailgun.net",
|
||||||
|
});
|
||||||
|
|
||||||
|
const success = {
|
||||||
|
statusCode: 200,
|
||||||
|
body: "Your message was sent successfully! We'll be in touch.",
|
||||||
|
};
|
||||||
|
const err = {
|
||||||
|
statusCode: 422,
|
||||||
|
body: "Can't send message",
|
||||||
|
};
|
||||||
|
|
||||||
|
// setup i18n
|
||||||
|
i18next.use(Backend).init({
|
||||||
|
lng: "en",
|
||||||
|
ns: ["emailInvite", "common"],
|
||||||
|
defaultNS: "emailInvite",
|
||||||
|
fallbackNS: "common",
|
||||||
|
debug: false,
|
||||||
|
fallbackLng: ["en", "fr"],
|
||||||
|
backend: {
|
||||||
|
backends: [FSBackend, HttpApi],
|
||||||
|
backendOptions: [{ loadPath: "/public/locales/{{lng}}/{{ns}}.json" }, {}],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// setup the template engine
|
||||||
|
// See https://github.com/UUDigitalHumanitieslab/handlebars-i18next
|
||||||
|
function extend(target, ...sources) {
|
||||||
|
sources.forEach((source) => {
|
||||||
|
if (source)
|
||||||
|
for (let key in source) {
|
||||||
|
target[key] = source[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
Handlebars.registerHelper("i18n", function (key, { hash, data, fn }) {
|
||||||
|
let parsed = {};
|
||||||
|
const jsonKeys = [
|
||||||
|
"lngs",
|
||||||
|
"fallbackLng",
|
||||||
|
"ns",
|
||||||
|
"postProcess",
|
||||||
|
"interpolation",
|
||||||
|
];
|
||||||
|
jsonKeys.forEach((key) => {
|
||||||
|
if (hash[key]) {
|
||||||
|
parsed[key] = JSON.parse(hash[key]);
|
||||||
|
delete hash[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let options = extend({}, data.root.i18next, hash, parsed, {
|
||||||
|
returnObjects: false,
|
||||||
|
});
|
||||||
|
let replace = (options.replace = extend({}, this, options.replace, hash));
|
||||||
|
delete replace.i18next; // may creep in if this === data.root
|
||||||
|
if (fn) options.defaultValue = fn(replace);
|
||||||
|
return new Handlebars.SafeString(i18next.t(key, options));
|
||||||
|
});
|
||||||
|
const txtStr = fs.readFileSync(__dirname + "/invite.txt").toString();
|
||||||
|
const txtTemplate = Handlebars.compile(txtStr);
|
||||||
|
const htmlStr = fs.readFileSync(__dirname + "/invite.html").toString();
|
||||||
|
const htmlTemplate = Handlebars.compile(htmlStr);
|
||||||
|
|
||||||
|
const sendMail = async (event) => {
|
||||||
|
if (event.httpMethod !== "POST") {
|
||||||
|
return {
|
||||||
|
statusCode: 405,
|
||||||
|
body: "Method Not Allowed",
|
||||||
|
headers: { Allow: "POST" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(event.body);
|
||||||
|
if (!data.recipientVariables || !data.title) {
|
||||||
|
return {
|
||||||
|
statusCode: 422,
|
||||||
|
body: "Recipient variables and title are required.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
i18next.changeLanguage(data.locale || "en");
|
||||||
|
const templateData = {
|
||||||
|
title: data.title,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mailgunData = {
|
||||||
|
from: `${i18next.t("Mieux Voter")} <mailgun@mg.app.mieuxvoter.fr>`,
|
||||||
|
to: Object.keys(data.recipientVariables),
|
||||||
|
text: txtTemplate(templateData),
|
||||||
|
html: htmlTemplate(templateData),
|
||||||
|
subject: data.title,
|
||||||
|
"h:Reply-To": "app@mieuxvoter.fr",
|
||||||
|
"recipient-variables": JSON.stringify(data.recipientVariables),
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = mg.messages
|
||||||
|
.create("mg.app.mieuxvoter.fr", mailgunData)
|
||||||
|
.then((msg) => {
|
||||||
|
console.log(msg);
|
||||||
|
return success;
|
||||||
|
}) // logs response data
|
||||||
|
.catch((err) => {
|
||||||
|
console.log(err);
|
||||||
|
return success;
|
||||||
|
}); // logs any error
|
||||||
|
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.handler = sendMail;
|
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": "./",
|
||||||
|
"paths": {
|
||||||
|
"@components/*": ["components/*"],
|
||||||
|
"@styles/*": ["styles/*"],
|
||||||
|
"@services/*": ["services/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
[build]
|
||||||
|
command = "npm run build"
|
||||||
|
publish = "out"
|
||||||
|
functions = "functions"
|
||||||
|
|
||||||
|
[dev]
|
||||||
|
command = "npm run dev"
|
@ -0,0 +1,9 @@
|
|||||||
|
module.exports = {
|
||||||
|
i18n: {
|
||||||
|
defaultLocale: 'fr',
|
||||||
|
locales: ['en', 'fr', 'de', 'es', 'ru'],
|
||||||
|
ns: ["resource", "common"],
|
||||||
|
defaultNS: "resource",
|
||||||
|
fallbackNS: ["common"],
|
||||||
|
},
|
||||||
|
}
|
@ -0,0 +1,45 @@
|
|||||||
|
const { i18n } = require("./next-i18next.config");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
i18n,
|
||||||
|
// See https://github.com/netlify/netlify-plugin-nextjs/issues/223
|
||||||
|
unstableNetlifyFunctionsSupport: {
|
||||||
|
"pages/index.jsx": {
|
||||||
|
includeDirs: ["public"],
|
||||||
|
},
|
||||||
|
"pages/faq.jsx": {
|
||||||
|
includeDirs: ["public"],
|
||||||
|
},
|
||||||
|
"pages/legal-notices.jsx": {
|
||||||
|
includeDirs: ["public"],
|
||||||
|
},
|
||||||
|
"pages/new/confirm/[pid].jsx": {
|
||||||
|
includeDirs: ["public"],
|
||||||
|
},
|
||||||
|
"pages/new.jsx": {
|
||||||
|
includeDirs: ["public"],
|
||||||
|
},
|
||||||
|
"pages/result/[pid]/[[...tid]].jsx": {
|
||||||
|
includeDirs: ["public"],
|
||||||
|
},
|
||||||
|
"pages/vote/[pid]/[[...tid]].jsx": {
|
||||||
|
includeDirs: ["public"],
|
||||||
|
},
|
||||||
|
"pages/vote/[pid]/confirm.jsx": {
|
||||||
|
includeDirs: ["public"],
|
||||||
|
},
|
||||||
|
"pages/privacy-policy.jsx": {
|
||||||
|
includeDirs: ["public"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pageExtensions: ["mdx", "jsx", "js", "ts", "tsx"],
|
||||||
|
webpack(config) {
|
||||||
|
config.module.rules.push({
|
||||||
|
test: /\.svg$/,
|
||||||
|
use: ["@svgr/webpack"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
target: "experimental-serverless-trace",
|
||||||
|
};
|
@ -1,165 +1,44 @@
|
|||||||
{
|
{
|
||||||
"name": "front-mieux-voter",
|
"name": "next-netlify-starter",
|
||||||
"version": "0.1.0",
|
"version": "0.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
|
||||||
"@babel/core": "7.4.3",
|
|
||||||
"@fortawesome/fontawesome-free": "^5.9.0",
|
|
||||||
"@fortawesome/fontawesome-svg-core": "^1.2.19",
|
|
||||||
"@fortawesome/free-brands-svg-icons": "^5.13.0",
|
|
||||||
"@fortawesome/free-solid-svg-icons": "^5.9.0",
|
|
||||||
"@fortawesome/react-fontawesome": "^0.1.4",
|
|
||||||
"@svgr/webpack": "4.1.0",
|
|
||||||
"@typescript-eslint/eslint-plugin": "2.29.0",
|
|
||||||
"@typescript-eslint/parser": "2.29.0",
|
|
||||||
"axios": "^0.19.0",
|
|
||||||
"babel-eslint": "^10.1.0",
|
|
||||||
"babel-jest": "^24.8.0",
|
|
||||||
"babel-loader": "8.0.5",
|
|
||||||
"babel-plugin-named-asset-import": "^0.3.2",
|
|
||||||
"babel-preset-react-app": "^9.0.0",
|
|
||||||
"bootstrap": "^4.3.1",
|
|
||||||
"camelcase": "^5.2.0",
|
|
||||||
"case-sensitive-paths-webpack-plugin": "2.2.0",
|
|
||||||
"css-loader": "2.1.1",
|
|
||||||
"dotenv": "6.2.0",
|
|
||||||
"dotenv-expand": "4.2.0",
|
|
||||||
"enzyme": "^3.9.0",
|
|
||||||
"enzyme-adapter-react-16": "^1.13.1",
|
|
||||||
"eslint": "^6.8.0",
|
|
||||||
"eslint-config-react-app": "^5.2.1",
|
|
||||||
"eslint-loader": "4.0.2",
|
|
||||||
"eslint-plugin-flowtype": "4.7.0",
|
|
||||||
"eslint-plugin-import": "2.20.2",
|
|
||||||
"eslint-plugin-jsx-a11y": "6.2.3",
|
|
||||||
"eslint-plugin-react": "7.19.0",
|
|
||||||
"eslint-plugin-react-hooks": "^3.0.0",
|
|
||||||
"file-loader": "3.0.1",
|
|
||||||
"fs-extra": "7.0.1",
|
|
||||||
"global": "^4.4.0",
|
|
||||||
"html-webpack-plugin": "4.0.0-beta.5",
|
|
||||||
"i18next": "^19.4.1",
|
|
||||||
"i18next-browser-languagedetector": "^4.0.2",
|
|
||||||
"i18next-xhr-backend": "^3.2.2",
|
|
||||||
"identity-obj-proxy": "3.0.0",
|
|
||||||
"is-wsl": "^1.1.0",
|
|
||||||
"jest": "24.7.1",
|
|
||||||
"jest-environment-jsdom-fourteen": "0.1.0",
|
|
||||||
"jest-resolve": "24.7.1",
|
|
||||||
"jest-watch-typeahead": "0.3.0",
|
|
||||||
"mini-css-extract-plugin": "0.5.0",
|
|
||||||
"node-sass": "^4.12.0",
|
|
||||||
"optimize-css-assets-webpack-plugin": "5.0.1",
|
|
||||||
"pnp-webpack-plugin": "1.2.1",
|
|
||||||
"postcss-flexbugs-fixes": "4.1.0",
|
|
||||||
"postcss-loader": "3.0.0",
|
|
||||||
"postcss-normalize": "7.0.1",
|
|
||||||
"postcss-preset-env": "6.6.0",
|
|
||||||
"postcss-safe-parser": "4.0.1",
|
|
||||||
"prop-types": "^15.7.2",
|
|
||||||
"query-string": "^6.12.0",
|
|
||||||
"querystringify": "^2.0.0",
|
|
||||||
"react": "^16.8.6",
|
|
||||||
"react-app-polyfill": "^1.0.1",
|
|
||||||
"react-dev-utils": "^9.0.1",
|
|
||||||
"react-dom": "^16.8.6",
|
|
||||||
"react-flag-icon-css": "^1.0.25",
|
|
||||||
"react-flags-select": "^1.1.12",
|
|
||||||
"react-i18next": "^11.3.4",
|
|
||||||
"react-loader-spinner": "^3.1.14",
|
|
||||||
"react-multi-email": "^0.5.3",
|
|
||||||
"react-router-dom": "^5.0.0",
|
|
||||||
"react-sortable-hoc": "^1.9.1",
|
|
||||||
"react-toastify": "^5.2.1",
|
|
||||||
"reactstrap": "^8.0.0",
|
|
||||||
"resolve": "1.10.0",
|
|
||||||
"sass-loader": "7.1.0",
|
|
||||||
"semver": "6.0.0",
|
|
||||||
"style-loader": "0.23.1",
|
|
||||||
"terser-webpack-plugin": "1.2.3",
|
|
||||||
"ts-pnp": "1.1.2",
|
|
||||||
"url-loader": "1.1.2",
|
|
||||||
"webpack": "4.29.6",
|
|
||||||
"webpack-dev-server": "3.2.1",
|
|
||||||
"webpack-manifest-plugin": "2.0.4",
|
|
||||||
"workbox-webpack-plugin": "4.2.0"
|
|
||||||
},
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node scripts/start.js",
|
"dev": "next dev",
|
||||||
"build": "node scripts/build.js",
|
"build": "next build",
|
||||||
"test": "node scripts/test.js",
|
"start": "next start",
|
||||||
"translate": "i18next-scanner --config i18n.config.js 'src/**/*.{js,jsx}'"
|
"export": "next export"
|
||||||
},
|
|
||||||
"eslintConfig": {
|
|
||||||
"extends": "react-app"
|
|
||||||
},
|
|
||||||
"browserslist": {
|
|
||||||
"production": [
|
|
||||||
">0.2%",
|
|
||||||
"not dead",
|
|
||||||
"not op_mini all"
|
|
||||||
],
|
|
||||||
"development": [
|
|
||||||
"last 1 chrome version",
|
|
||||||
"last 1 firefox version",
|
|
||||||
"last 1 safari version"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"jshintConfig": {
|
"dependencies": {
|
||||||
"esversion": 6
|
"@fortawesome/fontawesome-free": "^5.15.3",
|
||||||
},
|
"@fortawesome/fontawesome-svg-core": "^1.2.35",
|
||||||
"jest": {
|
"@fortawesome/free-brands-svg-icons": "^5.15.3",
|
||||||
"collectCoverageFrom": [
|
"@fortawesome/free-solid-svg-icons": "^5.15.3",
|
||||||
"src/**/*.{js,jsx,ts,tsx}",
|
"@fortawesome/react-fontawesome": "^0.1.14",
|
||||||
"!src/**/*.d.ts"
|
"@svgr/webpack": "^5.5.0",
|
||||||
],
|
"array-move": "^3.0.1",
|
||||||
"setupFiles": [
|
"bootstrap": "^4.6.0",
|
||||||
"react-app-polyfill/jsdom"
|
"bootstrap-scss": "^4.6.0",
|
||||||
],
|
"domexception": "^2.0.1",
|
||||||
"setupFilesAfterEnv": [],
|
"dotenv": "^8.2.0",
|
||||||
"testMatch": [
|
"form-data": "^4.0.0",
|
||||||
"<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}",
|
"handlebars": "^4.7.7",
|
||||||
"<rootDir>/src/**/*.{spec,test}.{js,jsx,ts,tsx}"
|
"i18next": "^20.2.1",
|
||||||
],
|
"i18next-chained-backend": "^2.1.0",
|
||||||
"testEnvironment": "jest-environment-jsdom-fourteen",
|
"i18next-fs-backend": "^1.1.1",
|
||||||
"transform": {
|
"i18next-http-backend": "^1.2.1",
|
||||||
"^.+\\.(js|jsx|ts|tsx)$": "<rootDir>/node_modules/babel-jest",
|
"i18next-localstorage-backend": "^3.1.2",
|
||||||
"^.+\\.css$": "<rootDir>/config/jest/cssTransform.js",
|
"mailgun.js": "^3.3.0",
|
||||||
"^(?!.*\\.(js|jsx|ts|tsx|css|json)$)": "<rootDir>/config/jest/fileTransform.js"
|
"next": "^10.0.9",
|
||||||
},
|
"next-i18next": "^8.1.3",
|
||||||
"transformIgnorePatterns": [
|
"query-string": "^7.0.0",
|
||||||
"[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$",
|
"react": "^17.0.2",
|
||||||
"^.+\\.module\\.(css|sass|scss)$"
|
"react-dom": "^17.0.1",
|
||||||
],
|
"react-flags-select": "^2.1.2",
|
||||||
"modulePaths": [],
|
"react-i18next": "^11.8.12",
|
||||||
"moduleNameMapper": {
|
"react-multi-email": "^0.5.3",
|
||||||
"^react-native$": "react-native-web",
|
"react-sortable-hoc": "^2.0.0",
|
||||||
"^.+\\.module\\.(css|sass|scss)$": "identity-obj-proxy"
|
"react-toastify": "^7.0.3",
|
||||||
},
|
"reactstrap": "^8.9.0",
|
||||||
"moduleFileExtensions": [
|
"sass": "^1.32.8"
|
||||||
"web.js",
|
|
||||||
"js",
|
|
||||||
"web.ts",
|
|
||||||
"ts",
|
|
||||||
"web.tsx",
|
|
||||||
"tsx",
|
|
||||||
"json",
|
|
||||||
"web.jsx",
|
|
||||||
"jsx",
|
|
||||||
"node"
|
|
||||||
],
|
|
||||||
"watchPlugins": [
|
|
||||||
"jest-watch-typeahead/filename",
|
|
||||||
"jest-watch-typeahead/testname"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"babel": {
|
|
||||||
"presets": [
|
|
||||||
"react-app"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"i18next-scanner": "^2.11.0",
|
|
||||||
"prettier": "1.19.1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,34 @@
|
|||||||
|
import Head from 'next/head'
|
||||||
|
import '@styles/globals.css'
|
||||||
|
import '@styles/footer.css'
|
||||||
|
import '@styles/loader.css'
|
||||||
|
import "@styles/scss/config.scss";
|
||||||
|
import '@fortawesome/fontawesome-svg-core/styles.css'
|
||||||
|
import {appWithTranslation} from 'next-i18next'
|
||||||
|
import {AppProvider} from '@services/context.js'
|
||||||
|
import Header from '@components/layouts/Header'
|
||||||
|
import Footer from '@components/layouts/Footer'
|
||||||
|
|
||||||
|
|
||||||
|
function Application({Component, pageProps}) {
|
||||||
|
const origin = typeof window !== 'undefined' && window.location.origin ? window.location.origin : 'http://localhost';
|
||||||
|
return (<AppProvider>
|
||||||
|
<Head>
|
||||||
|
<link rel="icon" key="favicon" href="/favicon.ico" />
|
||||||
|
<meta property="og:url" content={origin} key="og:url" />
|
||||||
|
<meta property="og:type" content="website" key="og:type" />
|
||||||
|
<meta
|
||||||
|
property="og:image"
|
||||||
|
content="https://app.mieuxvoter.fr/app-mieux-voter.png"
|
||||||
|
key="og:image"
|
||||||
|
/>
|
||||||
|
</Head>
|
||||||
|
<Header />
|
||||||
|
<main className="d-flex flex-column justify-content-center">
|
||||||
|
<Component {...pageProps} />
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</AppProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default appWithTranslation(Application)
|
@ -0,0 +1,71 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import Head from "next/head";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||||
|
import { useTranslation } from "next-i18next";
|
||||||
|
import { Container, Row, Col, Button, Input } from "reactstrap";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faRocket } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import config from "../next-i18next.config.js";
|
||||||
|
|
||||||
|
export const getStaticProps = async ({ locale }) => ({
|
||||||
|
props: {
|
||||||
|
...(await serverSideTranslations(locale, [], config)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const Home = () => {
|
||||||
|
const [title, setTitle] = useState(null);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<form autoComplete="off">
|
||||||
|
<Row>
|
||||||
|
<img
|
||||||
|
src="logos/logo-line-white.svg"
|
||||||
|
alt="logo of Mieux Voter"
|
||||||
|
height="128"
|
||||||
|
className="d-block ml-auto mr-auto mb-4"
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
<Row>
|
||||||
|
<Col className="text-center">
|
||||||
|
<h3>{t("common.valueProp")}</h3>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row className="mt-2">
|
||||||
|
<Col xs="12" md="9" xl="6" className="offset-xl-2">
|
||||||
|
<Input
|
||||||
|
placeholder={t("resource.writeQuestion")}
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
className="mt-2"
|
||||||
|
name="title"
|
||||||
|
value={title ? title : ""}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
maxLength="250"
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs="12" md="3" xl="2">
|
||||||
|
<Link href={{ pathname: "/new/", query: { title: title } }}>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-block btn-secondary mt-2"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faRocket} className="mr-2" />
|
||||||
|
{t("resource.start")}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row className="mt-4">
|
||||||
|
<Col className="text-center">
|
||||||
|
<p>{t("resource.noAds")}</p>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</form>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Home;
|
@ -0,0 +1,151 @@
|
|||||||
|
import { createRef } from "react";
|
||||||
|
import Head from "next/head";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { useTranslation } from "next-i18next";
|
||||||
|
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||||
|
import { getDetails } from "@services/api";
|
||||||
|
import { Col, Container, Row } from "reactstrap";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
faCopy,
|
||||||
|
faVoteYea,
|
||||||
|
faExclamationTriangle,
|
||||||
|
faExternalLinkAlt,
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import CopyField from "@components/CopyField";
|
||||||
|
import Facebook from "@components/banner/Facebook";
|
||||||
|
import config from "../../../next-i18next.config.js";
|
||||||
|
|
||||||
|
export async function getServerSideProps({ query: { pid }, locale }) {
|
||||||
|
const [res, translations] = await Promise.all([
|
||||||
|
getDetails(
|
||||||
|
pid,
|
||||||
|
(res) => ({ ok: true, ...res }),
|
||||||
|
(err) => ({ ok: false, err })
|
||||||
|
),
|
||||||
|
serverSideTranslations(locale, [], config),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return { props: { err: res.err, ...translations } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
invitationOnly: res.on_invitation_only,
|
||||||
|
restrictResults: res.restrict_results,
|
||||||
|
title: res.title,
|
||||||
|
pid: pid,
|
||||||
|
...translations,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConfirmElection = ({
|
||||||
|
title,
|
||||||
|
restrictResults,
|
||||||
|
invitationOnly,
|
||||||
|
pid,
|
||||||
|
err,
|
||||||
|
}) => {
|
||||||
|
if (err) {
|
||||||
|
return <Error value={err}></Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const origin =
|
||||||
|
typeof window !== "undefined" && window.location.origin
|
||||||
|
? window.location.origin
|
||||||
|
: "http://localhost";
|
||||||
|
const urlVote = new URL(`/vote/${pid}`, origin);
|
||||||
|
const urlResult = new URL(`/result/${pid}`, origin);
|
||||||
|
|
||||||
|
const electionLink = invitationOnly ? (
|
||||||
|
<>
|
||||||
|
<p className="mb-1">
|
||||||
|
{t(
|
||||||
|
"Voters received a link to vote by email. Each link can be used only once!"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="mb-1">{t("Voting address")}</p>
|
||||||
|
<CopyField
|
||||||
|
value={urlVote.href}
|
||||||
|
iconCopy={faCopy}
|
||||||
|
iconOpen={faExternalLinkAlt}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
const fb = invitationOnly ? null : (
|
||||||
|
<Facebook
|
||||||
|
className="btn btn-sm btn-outline-light m-2"
|
||||||
|
text={t("Share election on Facebook")}
|
||||||
|
url={urlVote}
|
||||||
|
title={"app.mieuxvoter.fr"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const participate = invitationOnly ? null : (
|
||||||
|
<Row className="mt-4 mb-4">
|
||||||
|
<Col className="text-center">
|
||||||
|
<Link href={`/vote/${pid}`}>
|
||||||
|
<a className="btn btn-secondary">
|
||||||
|
<FontAwesomeIcon icon={faVoteYea} className="mr-2" />
|
||||||
|
{t("Participate now!")}
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Head>
|
||||||
|
<title>{t("Successful election creation!")}</title>
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
<meta key="og:title" property="og:title" content={title} />
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
key="og:description"
|
||||||
|
content={t("common.application")}
|
||||||
|
/>
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<Row className="mt-5">
|
||||||
|
<Col className="text-center offset-lg-3" lg="6">
|
||||||
|
<h2>{t("Successful election creation!")}</h2>
|
||||||
|
{fb}
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row className="mt-5 mb-4">
|
||||||
|
<Col className="offset-lg-3" lg="6">
|
||||||
|
<h3 className="mb-3 text-center">{title}</h3>
|
||||||
|
<h5 className="mb-3 text-center">
|
||||||
|
<FontAwesomeIcon icon={faExclamationTriangle} className="mr-2" />
|
||||||
|
{t("Keep these links carefully")}
|
||||||
|
</h5>
|
||||||
|
<div className="border rounded p-4 pb-5">
|
||||||
|
{electionLink}
|
||||||
|
|
||||||
|
<p className="mt-4 mb-1">{t("Results address")}</p>
|
||||||
|
<CopyField
|
||||||
|
value={urlResult}
|
||||||
|
iconCopy={faCopy}
|
||||||
|
iconOpen={faExternalLinkAlt}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
{participate}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConfirmElection;
|
@ -0,0 +1,340 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import Head from "next/head";
|
||||||
|
import { useTranslation } from "next-i18next";
|
||||||
|
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Collapse,
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardBody,
|
||||||
|
Table,
|
||||||
|
} from "reactstrap";
|
||||||
|
import { getResults, getDetails, apiErrors } from "@services/api";
|
||||||
|
import { grades } from "@services/grades";
|
||||||
|
import { translateGrades } from "@services/grades";
|
||||||
|
import Facebook from "@components/banner/Facebook";
|
||||||
|
import Error from "@components/Error";
|
||||||
|
import config from "../../../next-i18next.config.js";
|
||||||
|
|
||||||
|
export async function getServerSideProps({ query, locale }) {
|
||||||
|
const { pid, tid } = query;
|
||||||
|
|
||||||
|
const [res, details, translations] = await Promise.all([
|
||||||
|
getResults(
|
||||||
|
pid,
|
||||||
|
(res) => {
|
||||||
|
ok: true, res;
|
||||||
|
},
|
||||||
|
(err) => ({ ok: false, err })
|
||||||
|
),
|
||||||
|
getDetails(
|
||||||
|
pid,
|
||||||
|
(res) => {
|
||||||
|
ok: true, res;
|
||||||
|
},
|
||||||
|
(err) => ({ ok: false, err })
|
||||||
|
),
|
||||||
|
serverSideTranslations(locale, [], config),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return { props: { err: res.err, ...translations } };
|
||||||
|
}
|
||||||
|
if (!details.ok) {
|
||||||
|
return { props: { err: details.err, ...translations } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
title: details.title,
|
||||||
|
numGrades: details.num_grades,
|
||||||
|
candidates: res,
|
||||||
|
pid: pid,
|
||||||
|
...translations,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const Result = ({ candidates, numGrades, title, pid, err }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (err && err !== "") {
|
||||||
|
return <Error value={apiErrors(err, t)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const allGrades = translateGrades(t);
|
||||||
|
const grades = allGrades.filter(
|
||||||
|
(grade) => grade.value >= allGrades.length - numGrades
|
||||||
|
);
|
||||||
|
const offsetGrade = grades.length - numGrades;
|
||||||
|
|
||||||
|
const colSizeCandidateLg = 4;
|
||||||
|
const colSizeCandidateMd = 6;
|
||||||
|
const colSizeCandidateXs = 12;
|
||||||
|
const colSizeGradeLg = 1;
|
||||||
|
const colSizeGradeMd = 1;
|
||||||
|
const colSizeGradeXs = 1;
|
||||||
|
|
||||||
|
const origin =
|
||||||
|
typeof window !== "undefined" && window.location.origin
|
||||||
|
? window.location.origin
|
||||||
|
: "http://localhost";
|
||||||
|
console.log("origin", origin);
|
||||||
|
const urlVote = new URL(`/vote/${pid}`, origin);
|
||||||
|
|
||||||
|
const [collapseProfiles, setCollapseProfiles] = useState(false);
|
||||||
|
const [collapseGraphics, setCollapseGraphics] = useState(false);
|
||||||
|
|
||||||
|
const sum = (seq) => Object.values(seq).reduce((a, b) => a + b, 0);
|
||||||
|
const numVotes =
|
||||||
|
candidates && candidates.length > 0 ? sum(candidates[0].profile) : 1;
|
||||||
|
const gradeIds =
|
||||||
|
candidates && candidates.length > 0
|
||||||
|
? Object.keys(candidates[0].profile)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Head>
|
||||||
|
<title>{title}</title>
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
<meta property="og:title" content={title} />
|
||||||
|
</Head>
|
||||||
|
<Row>
|
||||||
|
<Col xs="12">
|
||||||
|
<h3>{title}</h3>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row className="mt-5">
|
||||||
|
<Col>
|
||||||
|
<ol className="result">
|
||||||
|
{candidates.map((candidate, i) => {
|
||||||
|
const gradeValue = candidate.grade + offsetGrade;
|
||||||
|
return (
|
||||||
|
<li key={i} className="mt-2">
|
||||||
|
<span className="mt-2 ml-2">{candidate.name}</span>
|
||||||
|
<span
|
||||||
|
className="badge badge-light ml-2 mt-2"
|
||||||
|
style={{
|
||||||
|
backgroundColor: grades.slice(0).reverse()[
|
||||||
|
candidate.grade
|
||||||
|
].color,
|
||||||
|
color: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{allGrades.slice(0).reverse()[gradeValue].label}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
<h5>
|
||||||
|
<small>
|
||||||
|
{t("Number of votes:")}
|
||||||
|
{" " + numVotes}
|
||||||
|
</small>
|
||||||
|
</h5>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row className="mt-5">
|
||||||
|
<Col>
|
||||||
|
<Card className="bg-light text-primary">
|
||||||
|
<CardHeader
|
||||||
|
className="pointer"
|
||||||
|
onClick={() => setCollapseGraphics(!collapseGraphics)}
|
||||||
|
>
|
||||||
|
<h4
|
||||||
|
className={
|
||||||
|
"m-0 panel-title " + (collapseGraphics ? "collapsed" : "")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("Graph")}
|
||||||
|
</h4>
|
||||||
|
</CardHeader>
|
||||||
|
<Collapse isOpen={collapseGraphics}>
|
||||||
|
<CardBody className="pt-5">
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="median"
|
||||||
|
style={{ height: candidates.length * 28 + 30 }}
|
||||||
|
/>
|
||||||
|
<table style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
{candidates.map((candidate, i) => {
|
||||||
|
return (
|
||||||
|
<tr key={i}>
|
||||||
|
<td style={{ width: "30px" }}>{i + 1}</td>
|
||||||
|
{/*candidate.label*/}
|
||||||
|
<td>
|
||||||
|
<table style={{ width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
{gradeIds
|
||||||
|
.slice(0)
|
||||||
|
.reverse()
|
||||||
|
.map((id, i) => {
|
||||||
|
const value = candidate.profile[id];
|
||||||
|
if (value > 0) {
|
||||||
|
let percent =
|
||||||
|
(value * 100) / numVotes + "%";
|
||||||
|
if (i === 0) {
|
||||||
|
percent = "auto";
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
width: percent,
|
||||||
|
backgroundColor:
|
||||||
|
grades[i].color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<small>
|
||||||
|
{candidates.map((candidate, i) => {
|
||||||
|
return (
|
||||||
|
<span key={i}>
|
||||||
|
{i > 0 ? ", " : ""}
|
||||||
|
<b>{i + 1}</b>: {candidate.name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<small>
|
||||||
|
{grades.map((grade, i) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="badge badge-light mr-2 mt-2"
|
||||||
|
style={{
|
||||||
|
backgroundColor: grade.color,
|
||||||
|
color: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{grade.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Collapse>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row className="mt-3">
|
||||||
|
<Col>
|
||||||
|
<Card className="bg-light text-primary">
|
||||||
|
<CardHeader
|
||||||
|
className="pointer"
|
||||||
|
onClick={() => setCollapseProfiles(!collapseProfiles)}
|
||||||
|
>
|
||||||
|
<h4
|
||||||
|
className={
|
||||||
|
"m-0 panel-title " + (collapseProfiles ? "collapsed" : "")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("Preference profile")}
|
||||||
|
</h4>
|
||||||
|
</CardHeader>
|
||||||
|
<Collapse isOpen={collapseProfiles}>
|
||||||
|
<CardBody>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<Table className="profiles">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
{grades.map((grade, i) => {
|
||||||
|
return (
|
||||||
|
<th key={i}>
|
||||||
|
<span
|
||||||
|
className="badge badge-light"
|
||||||
|
style={{
|
||||||
|
backgroundColor: grade.color,
|
||||||
|
color: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{grade.label}{" "}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{candidates.map((candidate, i) => {
|
||||||
|
return (
|
||||||
|
<tr key={i}>
|
||||||
|
<td>{i + 1}</td>
|
||||||
|
{gradeIds
|
||||||
|
.slice(0)
|
||||||
|
.reverse()
|
||||||
|
.map((id, i) => {
|
||||||
|
const value = candidate.profile[id];
|
||||||
|
const percent = (
|
||||||
|
(value / numVotes) *
|
||||||
|
100
|
||||||
|
).toFixed(1);
|
||||||
|
return <td key={i}>{percent} %</td>;
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<small>
|
||||||
|
{candidates.map((candidate, i) => {
|
||||||
|
return (
|
||||||
|
<span key={i}>
|
||||||
|
{i > 0 ? ", " : ""}
|
||||||
|
<b>{i + 1}</b>: {candidate.name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</small>
|
||||||
|
</CardBody>
|
||||||
|
</Collapse>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row>
|
||||||
|
<Col xs="12" className="text-center pt-2 pb-5">
|
||||||
|
<Facebook
|
||||||
|
className="btn btn-outline-light m-2"
|
||||||
|
text={t("Share results on Facebook")}
|
||||||
|
url={urlVote}
|
||||||
|
title={title}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default Result;
|
@ -0,0 +1,271 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
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 { toast, ToastContainer } from "react-toastify";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faCheck } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { getDetails, castBallot, apiErrors } from "@services/api";
|
||||||
|
import { translateGrades } from "@services/grades";
|
||||||
|
import config from "../../../next-i18next.config.js";
|
||||||
|
|
||||||
|
const shuffle = (array) => array.sort(() => Math.random() - 0.5);
|
||||||
|
|
||||||
|
export async function getServerSideProps({ query: { pid, tid }, locale }) {
|
||||||
|
const [res, translations] = await Promise.all([
|
||||||
|
getDetails(
|
||||||
|
pid,
|
||||||
|
(res) => ({ ok: true, ...res }),
|
||||||
|
(err) => ({ ok: false, err })
|
||||||
|
),
|
||||||
|
serverSideTranslations(locale, [], config),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return { props: { err: res.err, ...translations } };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(res);
|
||||||
|
|
||||||
|
shuffle(res.candidates);
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
...translations,
|
||||||
|
invitationOnly: res.on_invitation_only,
|
||||||
|
restrictResults: res.restrict_results,
|
||||||
|
candidates: res.candidates.map((name, i) => ({ id: i, label: name })),
|
||||||
|
title: res.title,
|
||||||
|
numGrades: res.num_grades,
|
||||||
|
pid: pid,
|
||||||
|
token: tid || null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const VoteBallot = ({ candidates, title, numGrades, pid, err, token }) => {
|
||||||
|
if (err) {
|
||||||
|
return <Error value={err}></Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [judgments, setJudgments] = useState([]);
|
||||||
|
const colSizeCandidateLg = 4;
|
||||||
|
const colSizeCandidateMd = 6;
|
||||||
|
const colSizeCandidateXs = 12;
|
||||||
|
const colSizeGradeLg = Math.floor((12 - colSizeCandidateLg) / numGrades);
|
||||||
|
const colSizeGradeMd = Math.floor((12 - colSizeCandidateMd) / numGrades);
|
||||||
|
const colSizeGradeXs = Math.floor((12 - colSizeCandidateXs) / numGrades);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const allGrades = translateGrades(t);
|
||||||
|
const grades = allGrades.filter(
|
||||||
|
(grade) => grade.value >= allGrades.length - numGrades
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleGradeClick = (event) => {
|
||||||
|
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 = () => {
|
||||||
|
toast.error(t("You have to judge every candidate/proposal!"), {
|
||||||
|
position: toast.POSITION.TOP_CENTER,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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]);
|
||||||
|
});
|
||||||
|
|
||||||
|
castBallot(gradesByCandidate, pid, token, () => {
|
||||||
|
router.push(`/vote/${pid}/confirm`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Head>
|
||||||
|
<title>{title}</title>
|
||||||
|
|
||||||
|
<title>{title}</title>
|
||||||
|
<meta key="og:title" property="og:title" content={title} />
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
key="og:description"
|
||||||
|
content={t("common.application")}
|
||||||
|
/>
|
||||||
|
</Head>
|
||||||
|
<ToastContainer />
|
||||||
|
<form onSubmit={handleSubmit} autoComplete="off">
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
<h3>{title}</h3>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row className="cardVote d-none d-lg-flex">
|
||||||
|
<Col
|
||||||
|
xs={colSizeCandidateXs}
|
||||||
|
md={colSizeCandidateMd}
|
||||||
|
lg={colSizeCandidateLg}
|
||||||
|
>
|
||||||
|
<h5> </h5>
|
||||||
|
</Col>
|
||||||
|
{grades.map((grade, gradeId) => {
|
||||||
|
return gradeId < numGrades ? (
|
||||||
|
<Col
|
||||||
|
xs={colSizeGradeXs}
|
||||||
|
md={colSizeGradeMd}
|
||||||
|
lg={colSizeGradeLg}
|
||||||
|
key={gradeId}
|
||||||
|
className="text-center p-0"
|
||||||
|
style={{ lineHeight: 2 }}
|
||||||
|
>
|
||||||
|
<small
|
||||||
|
className="nowrap bold badge"
|
||||||
|
style={{ backgroundColor: grade.color, color: "#fff" }}
|
||||||
|
>
|
||||||
|
{grade.label}
|
||||||
|
</small>
|
||||||
|
</Col>
|
||||||
|
) : null;
|
||||||
|
})}
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{candidates.map((candidate, candidateId) => {
|
||||||
|
return (
|
||||||
|
<Row key={candidateId} className="cardVote">
|
||||||
|
<Col
|
||||||
|
xs={colSizeCandidateXs}
|
||||||
|
md={colSizeCandidateMd}
|
||||||
|
lg={colSizeCandidateLg}
|
||||||
|
>
|
||||||
|
<h5 className="m-0">{candidate.label}</h5>
|
||||||
|
<hr className="d-lg-none" />
|
||||||
|
</Col>
|
||||||
|
{grades.map((grade, gradeId) => {
|
||||||
|
console.assert(gradeId < numGrades);
|
||||||
|
const gradeValue = grade.value;
|
||||||
|
return (
|
||||||
|
<Col
|
||||||
|
xs={colSizeGradeXs}
|
||||||
|
md={colSizeGradeMd}
|
||||||
|
lg={colSizeGradeLg}
|
||||||
|
key={gradeId}
|
||||||
|
className="text-lg-center"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
htmlFor={
|
||||||
|
"candidateGrade" + candidateId + "-" + gradeValue
|
||||||
|
}
|
||||||
|
className="check"
|
||||||
|
>
|
||||||
|
<small
|
||||||
|
className="nowrap d-lg-none ml-2 bold badge"
|
||||||
|
style={
|
||||||
|
judgments.find((judgment) => {
|
||||||
|
return (
|
||||||
|
JSON.stringify(judgment) ===
|
||||||
|
JSON.stringify({
|
||||||
|
id: candidate.id,
|
||||||
|
value: gradeValue,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
? { backgroundColor: grade.color, color: "#fff" }
|
||||||
|
: {
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
color: "#000",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{grade.label}
|
||||||
|
</small>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={"candidate" + candidateId}
|
||||||
|
id={"candidateGrade" + candidateId + "-" + gradeValue}
|
||||||
|
data-index={candidateId}
|
||||||
|
data-id={candidate.id}
|
||||||
|
value={grade.value}
|
||||||
|
onClick={handleGradeClick}
|
||||||
|
defaultChecked={judgments.find((element) => {
|
||||||
|
return (
|
||||||
|
JSON.stringify(element) ===
|
||||||
|
JSON.stringify({
|
||||||
|
id: candidate.id,
|
||||||
|
value: gradeValue,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="checkmark"
|
||||||
|
style={
|
||||||
|
judgments.find(function (judgment) {
|
||||||
|
return (
|
||||||
|
JSON.stringify(judgment) ===
|
||||||
|
JSON.stringify({
|
||||||
|
id: candidate.id,
|
||||||
|
value: gradeValue,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
? { backgroundColor: grade.color, color: "#fff" }
|
||||||
|
: {
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
color: "#000",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Col className="text-center">
|
||||||
|
{judgments.length !== candidates.length ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmitWithoutAllRate}
|
||||||
|
className="btn btn-dark "
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faCheck} className="mr-2" />
|
||||||
|
{t("Submit my vote")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button type="submit" className="btn btn-success ">
|
||||||
|
<FontAwesomeIcon icon={faCheck} className="mr-2" />
|
||||||
|
{t("Submit my vote")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</form>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default VoteBallot;
|
@ -0,0 +1,74 @@
|
|||||||
|
import Head from "next/head";
|
||||||
|
import { Col, Container, Row } from "reactstrap";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useTranslation } from "next-i18next";
|
||||||
|
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||||
|
import Paypal from "@components/banner/Paypal";
|
||||||
|
import Gform from "@components/banner/Gform";
|
||||||
|
import { getDetails } from "@services/api";
|
||||||
|
import config from "../../../next-i18next.config.js";
|
||||||
|
|
||||||
|
export async function getServerSideProps({ query: { pid }, locale }) {
|
||||||
|
const [res, translations] = await Promise.all([
|
||||||
|
getDetails(
|
||||||
|
pid,
|
||||||
|
(res) => ({ ok: true, ...res }),
|
||||||
|
(err) => ({ ok: false, err })
|
||||||
|
),
|
||||||
|
serverSideTranslations(locale, [], config),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return { props: { err: res.err, ...translations } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
...translations,
|
||||||
|
invitationOnly: res.on_invitation_only,
|
||||||
|
restrictResults: res.restrict_results,
|
||||||
|
candidates: res.candidates.map((name, i) => ({ id: i, label: name })),
|
||||||
|
title: res.title,
|
||||||
|
numGrades: res.num_grades,
|
||||||
|
pid: pid,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const VoteSuccess = ({ title, invitationOnly, pid }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Head>
|
||||||
|
<title>{t("resource.voteSuccess")}</title>
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
<meta key="og:title" property="og:title" content={title} />
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
key="og:description"
|
||||||
|
content={t("common.application")}
|
||||||
|
/>
|
||||||
|
</Head>
|
||||||
|
<Row>
|
||||||
|
<Link href="/">
|
||||||
|
<a className="d-block ml-auto mr-auto mb-4">
|
||||||
|
<img src="/logos/logo-line-white.svg" alt="logo" height="128" />
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</Row>
|
||||||
|
<Row className="mt-4">
|
||||||
|
<Col className="text-center offset-lg-3" lg="6">
|
||||||
|
<h2>{t("resource.voteSuccess")}</h2>
|
||||||
|
<p>{t("resource.thanks")}</p>
|
||||||
|
<div className="mt-3">
|
||||||
|
<Gform className="btn btn-secondary" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-5">
|
||||||
|
<Paypal btnColor="btn-success" />
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default VoteSuccess;
|
@ -1 +0,0 @@
|
|||||||
/* /index.html 200
|
|
Before Width: | Height: | Size: 7.0 KiB |
Before Width: | Height: | Size: 7.2 KiB |
Before Width: | Height: | Size: 7.2 KiB |
Before Width: | Height: | Size: 8.6 KiB |
Before Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 3.4 KiB |
Before Width: | Height: | Size: 4.5 KiB |
Before Width: | Height: | Size: 5.4 KiB |
Before Width: | Height: | Size: 5.9 KiB |
Before Width: | Height: | Size: 7.2 KiB |
Before Width: | Height: | Size: 7.6 KiB |
Before Width: | Height: | Size: 9.3 KiB |
Before Width: | Height: | Size: 2.8 KiB |
Before Width: | Height: | Size: 3.0 KiB |
Before Width: | Height: | Size: 3.4 KiB |
Before Width: | Height: | Size: 3.5 KiB |
Before Width: | Height: | Size: 9.2 KiB |
Before Width: | Height: | Size: 9.2 KiB |
@ -1,2 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<browserconfig><msapplication><tile><square70x70logo src="/ms-icon-70x70.png"/><square150x150logo src="/ms-icon-150x150.png"/><square310x310logo src="/ms-icon-310x310.png"/><TileColor>#ffffff</TileColor></tile></msapplication></browserconfig>
|
|
Before Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 4.5 KiB |
@ -1,41 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "App",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "\/android-icon-36x36.png",
|
|
||||||
"sizes": "36x36",
|
|
||||||
"type": "image\/png",
|
|
||||||
"density": "0.75"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "\/android-icon-48x48.png",
|
|
||||||
"sizes": "48x48",
|
|
||||||
"type": "image\/png",
|
|
||||||
"density": "1.0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "\/android-icon-72x72.png",
|
|
||||||
"sizes": "72x72",
|
|
||||||
"type": "image\/png",
|
|
||||||
"density": "1.5"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "\/android-icon-96x96.png",
|
|
||||||
"sizes": "96x96",
|
|
||||||
"type": "image\/png",
|
|
||||||
"density": "2.0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "\/android-icon-144x144.png",
|
|
||||||
"sizes": "144x144",
|
|
||||||
"type": "image\/png",
|
|
||||||
"density": "3.0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "\/android-icon-192x192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image\/png",
|
|
||||||
"density": "4.0"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
Before Width: | Height: | Size: 7.2 KiB |
Before Width: | Height: | Size: 7.5 KiB |
Before Width: | Height: | Size: 21 KiB |
Before Width: | Height: | Size: 3.3 KiB |
@ -1,119 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
|
|
||||||
<link rel="manifest" href="/manifest.json" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<!--
|
|
||||||
Notice the use of %PUBLIC_URL% in the tags above.
|
|
||||||
It will be replaced with the URL of the `public` folder during the build.
|
|
||||||
Only files inside the `public` folder can be referenced from the HTML.
|
|
||||||
|
|
||||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
|
||||||
work correctly both with client-side routing and a non-root public URL.
|
|
||||||
Learn how to configure a non-root public URL by running `npm run build`.
|
|
||||||
-->
|
|
||||||
<link
|
|
||||||
rel="apple-touch-icon"
|
|
||||||
sizes="57x57"
|
|
||||||
href="%PUBLIC_URL%/favicon/apple-icon-57x57.png"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="apple-touch-icon"
|
|
||||||
sizes="60x60"
|
|
||||||
href="%PUBLIC_URL%/favicon/apple-icon-60x60.png"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="apple-touch-icon"
|
|
||||||
sizes="72x72"
|
|
||||||
href="%PUBLIC_URL%/favicon/apple-icon-72x72.png"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="apple-touch-icon"
|
|
||||||
sizes="76x76"
|
|
||||||
href="%PUBLIC_URL%/favicon/apple-icon-76x76.png"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="apple-touch-icon"
|
|
||||||
sizes="114x114"
|
|
||||||
href="%PUBLIC_URL%/favicon/apple-icon-114x114.png"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="apple-touch-icon"
|
|
||||||
sizes="120x120"
|
|
||||||
href="%PUBLIC_URL%/favicon/apple-icon-120x120.png"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="apple-touch-icon"
|
|
||||||
sizes="144x144"
|
|
||||||
href="%PUBLIC_URL%/favicon/apple-icon-144x144.png"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="apple-touch-icon"
|
|
||||||
sizes="152x152"
|
|
||||||
href="%PUBLIC_URL%/favicon/apple-icon-152x152.png"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="apple-touch-icon"
|
|
||||||
sizes="180x180"
|
|
||||||
href="%PUBLIC_URL%/favicon/apple-icon-180x180.png"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="icon"
|
|
||||||
type="image/png"
|
|
||||||
sizes="192x192"
|
|
||||||
href="%PUBLIC_URL%/favicon/android-icon-192x192.png"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="icon"
|
|
||||||
type="image/png"
|
|
||||||
sizes="32x32"
|
|
||||||
href="%PUBLIC_URL%/favicon/favicon-32x32.png"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="icon"
|
|
||||||
type="image/png"
|
|
||||||
sizes="96x96"
|
|
||||||
href="%PUBLIC_URL%/favicon/favicon-96x96.png"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="icon"
|
|
||||||
type="image/png"
|
|
||||||
sizes="16x16"
|
|
||||||
href="%PUBLIC_URL%/favicon/favicon-16x16.png"
|
|
||||||
/>
|
|
||||||
<meta name="msapplication-TileColor" content="#ffffff" />
|
|
||||||
<meta
|
|
||||||
name="msapplication-TileImage"
|
|
||||||
content="%PUBLIC_URL%/favicon/ms-icon-144x144.png"
|
|
||||||
/>
|
|
||||||
<meta name="theme-color" content="#ffffff" />
|
|
||||||
<title>Application Jugement Majoritaire</title>
|
|
||||||
<meta property="og:url" content="https://app.mieuxvoter.fr" />
|
|
||||||
<meta property="og:type" content="website" />
|
|
||||||
<meta property="og:title" content="Application Jugement Majoritaire" />
|
|
||||||
<meta
|
|
||||||
property="og:description"
|
|
||||||
content="Simple et gratuit : organisez un vote avec le Jugement Majoritaire."
|
|
||||||
/>
|
|
||||||
<meta
|
|
||||||
property="og:image"
|
|
||||||
content="https://app.mieuxvoter.fr/app-mieux-voter.png"
|
|
||||||
/>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
|
||||||
<div id="root"></div>
|
|
||||||
<!--
|
|
||||||
This HTML file is a template.
|
|
||||||
If you open it directly in the browser, you will see an empty page.
|
|
||||||
|
|
||||||
You can add webfonts, meta tags, or analytics to this file.
|
|
||||||
The build step will place the bundled scripts into the <body> tag.
|
|
||||||
|
|
||||||
To begin the development, run `npm start` or `yarn start`.
|
|
||||||
To create a production bundle, use `npm run build` or `yarn build`.
|
|
||||||
-->
|
|
||||||
</body>
|
|
||||||
</html>
|
|
After Width: | Height: | Size: 26 KiB |
After Width: | Height: | Size: 32 KiB |
@ -1 +0,0 @@
|
|||||||
{}
|
|
@ -1 +0,0 @@
|
|||||||
{}
|
|
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"common.vote": "Vote!",
|
||||||
|
"common.mieuxvoter": "Better Vote",
|
||||||
|
"common.helpus": "Do you want to help us?",
|
||||||
|
"common.valueProp": "Simple and free: organise a vote with Majority Judgment.",
|
||||||
|
"common.candidates": "Candidates/Proposals",
|
||||||
|
"common.backHomepage": "Back to home page"
|
||||||
|
}
|
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"email.hello": "Hi, there! 🙂",
|
||||||
|
"email.why": "This email was sent to you because your email was filled out to participate in the vote on the subject:",
|
||||||
|
"email.linkVote": "The link for the vote is as follows:",
|
||||||
|
"email.linkResult": "The link that will give you the results when they are available is as follows:",
|
||||||
|
"email.happy": "We are happy to send you this email! You will be able to vote using majority judgment.",
|
||||||
|
"email.copyLink": "If that doesn't work, copy and paste the following link into your browser:",
|
||||||
|
"email.aboutjm": "If you require any further information, please visit our site.",
|
||||||
|
"email.bye": "Good vote! 🤗"
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"common.vote": "Votez !",
|
||||||
|
"common.mieuxvoter": "Mieux Voter",
|
||||||
|
"common.helpus": "Vous souhaitez nous soutenir ?",
|
||||||
|
"common.candidates": "Candidats/Propositions",
|
||||||
|
"common.valueProp": "Simple et gratuit : organisez un vote avec le Jugement Majoritaire",
|
||||||
|
"common.backHomepage": "Retour à la page d'accueil"
|
||||||
|
}
|
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"email.hello": "Bonjour ! 🙂",
|
||||||
|
"email.why": "Vous avez été invité·e à participer à l'élection suivante : ",
|
||||||
|
"email.linkVote": "Le lien pour voter est le suivant :",
|
||||||
|
"email.linkResult": "A la fin de l'élection, vous pourrez accéder aux résultats en cliquant sur ce lien :",
|
||||||
|
"email.happy": "Nous sommes très heureux de vous partager ce lien de vote ! Vous allez pouvoir voter avec le jugement majoritaire.",
|
||||||
|
"email.copyLink": "Si le lien ne fonctionne pas, vous pouvez le copier et le coller dans la barre de navigation de votre navigateur.",
|
||||||
|
"email.bye": "Bon vote ! 🤗",
|
||||||
|
"email.aboutjm": "If you require any further information, please visit our site."
|
||||||
|
}
|
After Width: | Height: | Size: 1.9 KiB |
After Width: | Height: | Size: 1.9 KiB |
After Width: | Height: | Size: 2.0 KiB |
After Width: | Height: | Size: 4.7 KiB |
After Width: | Height: | Size: 4.7 KiB |
After Width: | Height: | Size: 4.7 KiB |
After Width: | Height: | Size: 1.9 KiB |
@ -1,46 +0,0 @@
|
|||||||
{
|
|
||||||
"short_name": "MVFRONT",
|
|
||||||
"name": "App Mieux Voter (Front App for MVAPI)",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "\/favicon\/android-icon-36x36.png",
|
|
||||||
"sizes": "36x36",
|
|
||||||
"type": "image\/png",
|
|
||||||
"density": "0.75"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "\/favicon\/android-icon-48x48.png",
|
|
||||||
"sizes": "48x48",
|
|
||||||
"type": "image\/png",
|
|
||||||
"density": "1.0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "\/favicon\/android-icon-72x72.png",
|
|
||||||
"sizes": "72x72",
|
|
||||||
"type": "image\/png",
|
|
||||||
"density": "1.5"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "\/favicon\/android-icon-96x96.png",
|
|
||||||
"sizes": "96x96",
|
|
||||||
"type": "image\/png",
|
|
||||||
"density": "2.0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "\/favicon\/android-icon-144x144.png",
|
|
||||||
"sizes": "144x144",
|
|
||||||
"type": "image\/png",
|
|
||||||
"density": "3.0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "\/favicon\/android-icon-192x192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image\/png",
|
|
||||||
"density": "4.0"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"start_url": ".",
|
|
||||||
"display": "standalone",
|
|
||||||
"theme_color": "#ffffff",
|
|
||||||
"background_color": "#2943a0"
|
|
||||||
}
|
|