112 changed files with 7838 additions and 14395 deletions
-
22.gitignore
-
28Makefile
-
71README.md
-
57components/CopyField.jsx
-
35components/Error.jsx
-
29components/banner/Facebook.jsx
-
25components/banner/Gform.jsx
-
24components/banner/Helloasso.jsx
-
44components/banner/Paypal.jsx
-
4components/flag.js
-
57components/form/ButtonWithConfirm.jsx
-
55components/form/CandidateField.jsx
-
128components/form/CandidatesField.jsx
-
164components/form/ConfirmModal.jsx
-
75components/form/HelpButton.jsx
-
94components/layouts/Footer.jsx
-
61components/layouts/Header.jsx
-
32components/layouts/LanguageSelector.jsx
-
20components/layouts/useBbox.jsx
-
14components/loader/index.jsx
-
8components/wait/index.jsx
-
BINcomponents/wait/loader-pulse-2-alpha.gif
-
BINcomponents/wait/loader-pulse-2.gif
-
216functions/send-invite-email/invite.html
-
19functions/send-invite-email/invite.txt
-
135functions/send-invite-email/send-invite-email.js
-
10jsconfig.json
-
7netlify.toml
-
9next-i18next.config.js
-
45next.config.js
-
17331package-lock.json
-
197package.json
-
34pages/_app.jsx
-
262pages/faq.jsx
-
71pages/index.jsx
-
81pages/legal-notices.jsx
-
151pages/new/confirm/[pid].jsx
-
557pages/new/index.js
-
101pages/privacy-policy.jsx
-
340pages/result/[pid]/[[...tid]].jsx
-
271pages/vote/[pid]/[[...tid]].jsx
-
74pages/vote/[pid]/confirm.jsx
-
1public/_redirects
-
BINpublic/banner/en/helloasso.png
-
BINpublic/banner/fr/helloasso.png
-
BINpublic/favicon/android-icon-144x144.png
-
BINpublic/favicon/android-icon-192x192.png
-
BINpublic/favicon/android-icon-36x36.png
-
BINpublic/favicon/android-icon-48x48.png
-
BINpublic/favicon/android-icon-72x72.png
-
BINpublic/favicon/android-icon-96x96.png
-
BINpublic/favicon/apple-icon-114x114.png
-
BINpublic/favicon/apple-icon-120x120.png
-
BINpublic/favicon/apple-icon-144x144.png
-
BINpublic/favicon/apple-icon-152x152.png
-
BINpublic/favicon/apple-icon-180x180.png
-
BINpublic/favicon/apple-icon-57x57.png
-
BINpublic/favicon/apple-icon-60x60.png
-
BINpublic/favicon/apple-icon-72x72.png
-
BINpublic/favicon/apple-icon-76x76.png
-
BINpublic/favicon/apple-icon-precomposed.png
-
BINpublic/favicon/apple-icon.png
-
2public/favicon/browserconfig.xml
-
BINpublic/favicon/favicon-16x16.png
-
BINpublic/favicon/favicon-32x32.png
-
BINpublic/favicon/favicon-96x96.png
-
41public/favicon/manifest.json
-
BINpublic/favicon/ms-icon-144x144.png
-
BINpublic/favicon/ms-icon-150x150.png
-
BINpublic/favicon/ms-icon-310x310.png
-
BINpublic/favicon/ms-icon-70x70.png
-
119public/index.html
-
BINpublic/loader-pulse-2-alpha.gif
-
BINpublic/loader-pulse-2.gif
-
1public/locale/i18n/fr/common.json
-
1public/locale/i18n/ru/common.json
-
0public/locales/de/common.json
-
9public/locales/de/resource.json
-
8public/locales/en/common.json
-
10public/locales/en/emailInvite.json
-
0public/locales/en/locale.json
-
28public/locales/en/resource.json
-
0public/locales/es/common.json
-
0public/locales/es/locale.json
-
9public/locales/es/resource.json
-
8public/locales/fr/common.json
-
10public/locales/fr/emailInvite.json
-
6public/locales/fr/locale.json
-
29public/locales/fr/resource.json
-
0public/locales/ru/common.json
-
0public/locales/ru/locale.json
-
9public/locales/ru/resource.json
-
21public/logos/logo-black.svg
-
21public/logos/logo-blue.svg
-
26public/logos/logo-color.svg
-
47public/logos/logo-line-black.svg
-
47public/logos/logo-line-blue.svg
-
47public/logos/logo-line-white.svg
-
21public/logos/logo-white.svg
-
46public/manifest.json
@ -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 @@ |
|||
[](https://circleci.com/gh/MieuxVoter/mvfront-react) |
|||
[](https://app.netlify.com/sites/epic-nightingale-99f910/deploys) |
|||
# Front-end election web application using NextJs |
|||
|
|||
# Voting application in React |
|||
|
|||
A demo is available at [our website](http://demo.mieuxvoter.fr/). |
|||
[](https://app.netlify.com/sites/mv-front-react/deploys) |
|||
|
|||
## Installation |
|||
|
|||
1. Copy `.env` file into `.env.local` and set there environment variables. |
|||
2. Install [yarn](https://classic.yarnpkg.com/en/docs/install/#debian-stable). |
|||
3. Install dependencies: |
|||
```bash |
|||
$ cd mvfront-react |
|||
$ yarn install |
|||
``` |
|||
:ballot_box: This project is going to be the default front-end for our [election application](https://app.mieuxvoter.fr). |
|||
|
|||
## 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. |
|||
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. |
|||
: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 compile them, launch: `$ yarn translate`. |
|||
## Starting |
|||
:world_map: The front-end stores its own translations. See below how you can edit them easily. |
|||
|
|||
|
|||
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 |
|||
|
|||
[](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: 250 | Height: 250 | Size: 26 KiB |
After Width: 250 | Height: 250 | 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 @@ |
|||