parent
2fba1f7405
commit
867042e791
@ -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: | 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 |