Merge pull request #89 from MieuxVoter/dev

v2
pull/97/head
guhur 1 year ago committed by GitHub
commit 9dfa42fe3a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -1,35 +0,0 @@
{
"env": {
"browser": true,
"es6": true,
"node": true,
"commonjs": true
},
"parser": "babel-eslint",
"extends": [
"eslint:recommended",
"plugin:react/recommended"
],
"globals": {
"Atomics": "readonly",
"SharedArrayBuffer": "readonly"
},
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": 2018,
"sourceType": "module"
},
"plugins": [
"react"
],
"settings": {
"react": {
"version": "detect"
}
},
"rules": {
"no-console": "off"
}
}

@ -0,0 +1,3 @@
{
"extends": "next"
}

@ -49,7 +49,7 @@ To connect your application with Mailgun, you need to add the environment variab
- `MAILGUN_DOMAIN`,
- `MAILGUN_URL`,
- `FROM_EMAIL_ADDRESS`,
- `CONTACT_TO_EMAIL_ADDRESS`.
- `REPLY_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/).

@ -0,0 +1,50 @@
import { useTranslation } from 'next-i18next';
import Image from 'next/image';
import { Row, Col, Container } from 'reactstrap';
import ballotBox from '../public/urne.svg';
import email from '../public/email.svg';
import respect from '../public/respect.svg';
const AdvantagesRow = () => {
const { t } = useTranslation('resource');
const resources = [
{
src: ballotBox,
alt: t('home.alt-icon-ballot-box'),
name: t('home.advantage-1-name'),
desc: t('home.advantage-1-desc'),
},
{
src: email,
alt: t('home.alt-icon-envelop'),
name: t('home.advantage-2-name'),
desc: t('home.advantage-2-desc'),
},
{
src: respect,
alt: t('home.alt-icon-respect'),
name: t('home.advantage-3-name'),
desc: t('home.advantage-3-desc'),
},
];
return (
<Container>
<Row>
{resources.map((item, i) => (
<Col key={i} className="my-5 col-md-4 col-12">
<Image
src={item.src}
alt={item.alt}
height="128"
className="d-block mx-auto mb-2"
/>
<h4>{item.name}</h4>
<p>{item.desc}</p>
</Col>
))}
</Row>
</Container>
);
};
export default AdvantagesRow;

@ -0,0 +1,30 @@
import { UncontrolledAlert } from 'reactstrap';
import { useTranslation } from 'next-i18next';
import { CONTACT_MAIL } from '@services/constants';
const AlertDismissible = ({ msg, color }) => {
const { t } = useTranslation();
if (msg) {
return (
<UncontrolledAlert color={color}>
<h4 className={`${color}-heading`}>{t(msg)}</h4>
<p>
<a
href={`mailto:${CONTACT_MAIL}?subject=[HELP]`}
className="btn btn-success m-auto"
>
{t('error.help')}
</a>
</p>
</UncontrolledAlert>
);
}
return null;
};
AlertDismissible.defaultProps = {
color: 'danger',
};
export default AlertDismissible;

@ -0,0 +1,6 @@
const Blur = () => {
return <div id="blur_background"></div>
}
export default Blur;

@ -0,0 +1,42 @@
import { IconProp } from '@fortawesome/fontawesome-svg-core';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Button } from 'reactstrap';
interface ButtonProps {
children?: React.ReactNode;
icon?: IconProp;
customIcon?: JSX.Element;
position?: 'left' | 'right';
[props: string]: any;
}
const ButtonWithIcon = ({
icon,
customIcon,
children,
position = 'left',
...props
}: ButtonProps) => {
if ((icon || customIcon) && position === 'left') {
return (
<Button {...props}>
<div className="w-100 d-flex gap-3 justify-content-between align-items-center">
{customIcon ? customIcon : <FontAwesomeIcon icon={icon} />}
<div>{children}</div>
</div>
</Button>
);
} else if ((icon || customIcon) && position === 'right') {
return (
<Button {...props}>
<div className="w-100 gap-3 d-flex align-items-center justify-content-between">
<div>{children}</div>
{customIcon ? customIcon : <FontAwesomeIcon icon={icon} />}
</div>
</Button>
);
} else {
return <Button {...props}>{children}</Button>;
}
};
export default ButtonWithIcon;

@ -0,0 +1,41 @@
/**
* Contain a button with a content that can be copied
*/
import React, { useState } from 'react';
import { faCheck, faCopy } from '@fortawesome/free-solid-svg-icons';
import { Button } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
interface ButtonCopyInterface {
text: string;
content: any;
}
const ButtonCopy = ({ text, content }: ButtonCopyInterface) => {
const [check, setCheck] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(content);
setCheck(true);
setTimeout(() => {
setCheck(false);
}, 3000);
};
const icon = check ? faCheck : faCopy;
return (
<Button
className="bg-white text-black my-2 shadow-lg border-dark border py-3 px-4 border-3 justify-content-between gx-2 align-items-end"
onClick={handleCopy}
>
<div>{text}</div>
<div>
<FontAwesomeIcon icon={icon} />
</div>
</Button>
);
};
export default ButtonCopy;

@ -0,0 +1,123 @@
import React from 'react';
// import dynamic from 'next/dynamic'
// import {buildURI} from 'react-csv';
// /**
// * See https://github.com/react-csv/react-csv/issues/87
// */
export const isSafari = () => /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
export const isJsons = ((array) => Array.isArray(array) && array.every(
row => (typeof row === 'object' && !(row instanceof Array))
));
export const isArrays = ((array) => Array.isArray(array) && array.every(
row => Array.isArray(row)
));
export const jsonsHeaders = ((array) => Array.from(
array.map(json => Object.keys(json))
.reduce((a, b) => new Set([...a, ...b]), [])
));
export const jsons2arrays = (jsons, headers) => {
headers = headers || jsonsHeaders(jsons);
// allow headers to have custom labels, defaulting to having the header data key be the label
let headerLabels = headers;
let headerKeys = headers;
if (isJsons(headers)) {
headerLabels = headers.map((header) => header.label);
headerKeys = headers.map((header) => header.key);
}
const data = jsons.map((object) => headerKeys.map((header) => getHeaderValue(header, object)));
return [headerLabels, ...data];
};
export const getHeaderValue = (property, obj) => {
const foundValue = property
.replace(/\[([^\]]+)]/g, ".$1")
.split(".")
.reduce(function (o, p, i, arr) {
// if at any point the nested keys passed do not exist, splice the array so it doesnt keep reducing
const value = o[p];
if (value === undefined || value === null) {
arr.splice(1);
} else {
return value;
}
}, obj);
// if at any point the nested keys passed do not exist then looks for key `property` in object obj
return (foundValue === undefined) ? ((property in obj) ? obj[property] : '') : foundValue;
}
export const elementOrEmpty = (element) =>
(typeof element === 'undefined' || element === null) ? '' : element;
export const joiner = ((data, separator = ',', enclosingCharacter = '"') => {
return data
.filter(e => e)
.map(
row => row
.map((element) => elementOrEmpty(element))
.map(column => `${enclosingCharacter}${column}${enclosingCharacter}`)
.join(separator)
)
.join(`\n`);
});
export const arrays2csv = ((data, headers, separator, enclosingCharacter) =>
joiner(headers ? [headers, ...data] : data, separator, enclosingCharacter)
);
export const jsons2csv = ((data, headers, separator, enclosingCharacter) =>
joiner(jsons2arrays(data, headers), separator, enclosingCharacter)
);
export const string2csv = ((data, headers, separator) =>
(headers) ? `${headers.join(separator)}\n${data}` : data.replace(/"/g, '""')
);
export const toCSV = (data, headers, separator, enclosingCharacter) => {
if (isJsons(data)) return jsons2csv(data, headers, separator, enclosingCharacter);
if (isArrays(data)) return arrays2csv(data, headers, separator, enclosingCharacter);
if (typeof data === 'string') return string2csv(data, headers, separator);
throw new TypeError(`Data should be a "String", "Array of arrays" OR "Array of objects" `);
};
const CSVLink = ({filename, data, children, ...rest}) => {
const buildURI = ((data, uFEFF, headers, separator, enclosingCharacter) => {
const csv = toCSV(data, headers, separator, enclosingCharacter);
const type = isSafari() ? 'application/csv' : 'text/csv';
const blob = new Blob([uFEFF ? '\uFEFF' : '', csv], {type});
const dataURI = `data:${type};charset=utf-8,${uFEFF ? '\uFEFF' : ''}${csv}`;
const URL = window.URL || window.webkitURL;
return (typeof URL.createObjectURL === 'undefined')
? dataURI
: URL.createObjectURL(blob);
});
const isNodeEnvironment = typeof window === 'undefined';
const uFEFF = true;
const headers = undefined;
const separator = ",";
const enclosingCharacter = '"';
const href = isNodeEnvironment ? '' : buildURI(data, uFEFF, headers, separator, enclosingCharacter)
return (
<a
download={filename}
target="_blank"
{...rest}
href={href}
>
{children}
</a>
);
}
export default CSVLink;

@ -0,0 +1,57 @@
/**
* A modal to details a candidate
*/
import {
Button,
Col,
Container,
Row,
Modal,
ModalHeader,
ModalBody,
} from 'reactstrap';
import Image from 'next/image';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faXmark} from '@fortawesome/free-solid-svg-icons';
import {CandidatePayload} from '@services/api';
import defaultAvatar from '../public/avatarBlue.svg';
interface CandidateModal {
isOpen: boolean;
toggle: Function;
candidate: CandidatePayload;
}
const CandidateModal = ({isOpen, toggle, candidate}) => {
return (
< Modal
isOpen={isOpen}
toggle={toggle}
keyboard={true}
className="modal_candidate"
centered={true}
>
<div className="w-100 h-100 p-4 bg-white">
<div className="d-flex justify-content-between mb-4">
<Image
src={candidate && candidate.image ? candidate.image : defaultAvatar}
height={96}
width={96}
alt={candidate && candidate.name}
className="rounded-circle bg-light"
/>
<FontAwesomeIcon onClick={toggle} icon={faXmark} />
</div>
<div className="px-2">
<h5>{candidate && candidate.name}</h5>
<p>{candidate && candidate.description}</p>
</div>
</div>
</Modal >
)
};
export default CandidateModal;

@ -1,69 +0,0 @@
/* eslint react/prop-types: 0 */
import React from "react";
import { Button } from "reactstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCopy,
faVoteYea,
faExclamationTriangle,
faExternalLinkAlt,
} from "@fortawesome/free-solid-svg-icons";
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, iconOpen } = 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
href={value}
target="_blank"
rel="noreferrer"
className="btn btn-success"
type="button"
>
<FontAwesomeIcon icon={iconOpen} className="mr-2" />
{t("Go")}
</Button>
*/}
<Button
className="btn btn-secondary"
onClick={handleClickOnButton}
type="button"
>
<FontAwesomeIcon icon={iconCopy} className="mr-2" />
{t("Copy")}
</Button>
</div>
</div>
);
};
CopyField.defaultProps = {
iconCopy: faCopy,
iconOpen: faExternalLinkAlt,
};
export default CopyField;

@ -0,0 +1,84 @@
/* eslint react/prop-types: 0 */
import React, { useState } from 'react';
import { Button, UncontrolledTooltip } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faClone,
faExternalLinkAlt,
faCheck,
faCopy,
} from '@fortawesome/free-solid-svg-icons';
import ClipboardJS from 'clipboard';
const CopyField = ({ value, iconCopy = null, text }) => {
const [check, setCheck] = useState(false);
const handleClickOnField = (event) => {
event.target.focus();
event.target.select();
setCheck(true);
setTimeout(() => {
setCheck(false);
}, 3000);
};
if (typeof window !== 'undefined') {
new ClipboardJS('.btn');
}
let icon = null;
if (check) {
icon = faCheck;
} else if (iconCopy) {
icon = iconCopy;
} else {
icon = faCopy;
}
return (
<div className="input-group my-4 ">
<input
type="text"
style={{ display: 'none' }}
className="form-control"
value={value}
readOnly
onClick={handleClickOnField}
/>
<div className="input-group-append copy">
{/* <Button
href={value}
target="_blank"
rel="noreferrer"
className="btn btn-success"
type="button"
>
<FontAwesomeIcon icon={iconOpen} />
{t("Go")}
</Button> */}
<Button
data-clipboard-text={value}
target="_blank"
rel="noreferrer"
className="btn btn-copy"
type="button"
>
{text}
<FontAwesomeIcon icon={icon} />
</Button>
</div>
<UncontrolledTooltip placement="top" target="tooltip" trigger="click">
Lien copié
</UncontrolledTooltip>
</div>
);
};
CopyField.defaultProps = {
iconCopy: faClone,
iconOpen: faExternalLinkAlt,
};
export default CopyField;

@ -0,0 +1,115 @@
/**
* This component displays a bar releaving the current step
*/
import { useTranslation } from 'next-i18next';
import { faArrowLeft, faCheck } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
const { Row, Col, Container } = require('reactstrap');
const DesktopStep = ({ name, position, active, checked, onClick }) => {
const { t } = useTranslation();
const disabled = !active && !checked ? ' disabled' : '';
const activeClass = active ? 'bg-white text-primary' : 'bg-secondary';
return (
<Col className="col-auto" role={onClick ? 'button' : ''} onClick={onClick}>
<Row className={`align-items-center creation-step ${disabled} `}>
<Col
className={`${activeClass} creation-step-icon desktop_step col-auto align-items-center justify-content-center d-flex`}
>
{checked ? <FontAwesomeIcon icon={faCheck} /> : position}
</Col>
<Col className="col-auto name">{t(`admin.step-${name}`)}</Col>
</Row>
</Col>
);
};
const MobileStep = ({ position, active, checked, onClick }) => {
const disabled = !active && !checked ? ' bg-secondary disabled' : '';
const activeClass = active ? 'bg-white text-primary' : 'bg-secondary';
return (
<div
className={`${disabled} ${activeClass} mobile_step align-items-center justify-content-center d-flex me-2 fw-bold`}
role={onClick ? 'button' : ''}
onClick={onClick}
>
{checked ? <FontAwesomeIcon icon={faCheck} /> : position}
</div>
);
};
export const creationSteps = ['candidate', 'params', 'confirm'];
interface GoToStep {
(): void;
}
interface ProgressStepsProps {
step: string;
goToParams: GoToStep;
goToCandidates: GoToStep;
className?: string;
[props: string]: any;
}
export const ProgressSteps = ({
step,
goToParams,
goToCandidates,
className = '',
...props
}: ProgressStepsProps) => {
const { t } = useTranslation();
if (!creationSteps.includes(step)) {
throw Error(`Unknown step {step}`);
}
const stepId = creationSteps.indexOf(step);
const gotosteps = [goToCandidates, goToParams];
return (
<Row className={`w-100 ms-2 mt-4 m-md-5 d-flex ${className}`} {...props}>
<Col className="col-lg-3 col-8 mb-3 d-none d-md-block">
{step === 'candidate' ? null : (
<Row
role="button"
onClick={goToCandidates}
className="gx-2 align-items-end"
>
<Col className="col-auto">
<FontAwesomeIcon icon={faArrowLeft} />
</Col>
<Col className="col-auto">{t('admin.candidates-back-step')}</Col>
</Row>
)}
</Col>
<Col className="d-none d-md-block col-lg-6 col-12">
<Row className="w-100 gx-4 gx-md-5 justify-content-center">
{creationSteps.map((name, i) => (
<DesktopStep
name={name}
active={step === name}
checked={i < stepId}
key={i}
position={i + 1}
onClick={gotosteps[i]}
/>
))}
</Row>
</Col>
<Col className="d-block d-md-none col-lg-6 col-12 d-flex">
{creationSteps.map((name, i) => (
<MobileStep
active={step === name}
checked={i < stepId}
key={i}
position={i + 1}
onClick={gotosteps[i]}
/>
))}
</Col>
<Col className="col-3"></Col>
</Row>
);
};

@ -0,0 +1,85 @@
import { useState, forwardRef, ReactNode } from 'react';
import { Button, Row, Col } from 'reactstrap';
import { useTranslation } from 'next-i18next';
import {
faCalendarDays,
faChevronDown,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import DatePicker from 'react-datepicker';
import { AppTypes, useAppContext } from '@services/context';
import { useRouter } from 'next/router';
interface InputProps {
children?: ReactNode;
value: string;
onClick: React.MouseEventHandler<HTMLButtonElement>;
}
export type ButtonRef = HTMLButtonElement;
const CustomDatePicker = ({ date, setDate, className = '', ...props }) => {
const { t } = useTranslation();
const router = useRouter();
const [_, dispatchApp] = useAppContext();
const handleChange = (date) => {
const now = new Date();
if (+date < +now) {
dispatchApp({
type: AppTypes.TOAST_ADD,
status: 'error',
message: t('error.date-past'),
});
} else {
setDate(date);
}
};
const ExampleCustomInput = forwardRef<ButtonRef, InputProps>(
({ value, onClick }, ref) => (
<div className="d-grid">
<button onClick={onClick} ref={ref}>
<Row className="p-2 align-items-end">
<Col className="col-auto me-auto">
<Row className="gx-3 align-items-end">
<Col className="col-auto">
<FontAwesomeIcon icon={faCalendarDays} />
</Col>
<Col className="col-auto">
{t('admin.until')}{' '}
{new Date(value).toLocaleDateString(router.locale)}
</Col>
</Row>
</Col>
<Col className="col-auto">
<FontAwesomeIcon className="text-muted" icon={faChevronDown} />
</Col>
</Row>
</button>
</div>
)
);
return (
<DatePicker
selected={date}
className={className}
customInput={<ExampleCustomInput value={null} onClick={null} />}
onChange={handleChange}
/>
);
// {/*<Button className="example-custom-input"
// {value}
// </button>*/}
// return (
// <DatePicker
// selected={startDate}
// onChange={(date) => setStartDate(date)}
// customInput={<ExampleCustomInput />}
// />
// );
};
export default CustomDatePicker;

@ -1,40 +0,0 @@
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-right mr-4">
<Link href="/">
<a className="btn btn-secondary">{t("common.backHomepage")}</a>
</Link>
</Col>
<Col className="text-left ml-4">
<a
href="mailto:app@mieuxvoter.fr?subject=[HELP]"
className="btn btn-success"
>
{t("resource.help")}
</a>
</Col>
</Row>
</Container>
);
};
export default Error;

@ -0,0 +1,35 @@
import { Container } from 'reactstrap';
import { useTranslation } from 'next-i18next';
import { CONTACT_MAIL } from '@services/constants';
import Button from '@components/Button';
import { faEnvelope } from '@fortawesome/free-solid-svg-icons';
const Error = ({ children }) => {
const { t } = useTranslation();
if (!children) return null;
return (
<div className="flex-fill d-flex align-items-center bg-secondary pt-3 pb-5">
<Container style={{ maxWidth: '700px' }} className="mb-5 ">
<h4>{t('common.error')}</h4>
<p>{children}</p>
<a
href={`mailto:${CONTACT_MAIL}?subject=[HELP]`}
className="d-md-flex d-grid"
>
<Button
color="secondary"
outline={true}
className="text-white"
icon={faEnvelope}
>
{t('error.help')}
</Button>
</a>
</Container>
</div>
);
};
export default Error;

@ -0,0 +1,64 @@
import { MAJORITY_JUDGMENT_LINK } from '@services/constants';
import Image from 'next/image';
import { faArrowRight } from '@fortawesome/free-solid-svg-icons';
import { useTranslation } from 'next-i18next';
import { Row, Col, Container } from 'reactstrap';
import Button from '@components/Button';
import vote from '../public/vote.svg';
const Experiencediv = () => {
const { t } = useTranslation('resource');
return (
<div className="pt-5">
<div
className="w-100 justify-content-end d-flex d-md-none"
style={{ marginTop: '-200px' }}
>
<Image src={vote} alt={t('home.alt-icon-ballot')} />
</div>
<Container>
<h3 className="text-center">{t('home.experience-name')}</h3>
</Container>
<div className="d-flex">
<Container>
<Row className="ps-5 my-5 flex-fill justify-content-end align-items-center gx-md-5 d-flex">
<Col className="col-12 col-md-6">
<h5 className="">{t('home.experience-1-name')}</h5>
<p>{t('home.experience-1-desc')}</p>
</Col>
<Col className="col-12 col-md-6">
<h5 className="">{t('home.experience-2-name')}</h5>
<p>{t('home.experience-2-desc')}</p>
<p></p>
</Col>
</Row>
</Container>
<Image
className="d-none d-md-flex justify-content-end"
src={vote}
alt={t('home.alt-icon-ballot')}
/>
</div>
<Container className="d-flex w-100 justify-content-center mt-5">
<a
href={MAJORITY_JUDGMENT_LINK}
target="_blank"
rel="noreferrer noopener"
>
<Button
color="primary py-3"
outline={false}
type="submit"
icon={faArrowRight}
position="right"
>
{t('home.experience-call-to-action')}
</Button>
</a>
</Container>
</div>
);
};
export default Experiencediv;

@ -0,0 +1,66 @@
import { useTranslation } from 'next-i18next';
import { faXmark } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Row, Col } from 'reactstrap';
import { useState } from 'react';
import Button from '@components/Button';
const InputField = ({ value, onDelete }) => {
return (
<Button
customIcon={<FontAwesomeIcon icon={faXmark} onClick={onDelete} />}
className="bg-light text-primary border-0"
outline={true}
style={{ boxShadow: 'unset' }}
>
{value}
</Button>
);
};
const ListInput = ({ onEdit, inputs, validator }) => {
const [state, setState] = useState('');
const { t } = useTranslation();
const handleDelete = (position: number) => {
const inputCopy = [...inputs];
inputCopy.splice(position, 1);
onEdit(inputCopy);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' || e.key === 'Tab' || e.key === ';') {
if (validator(state)) {
onEdit([...inputs, state]);
setState('');
}
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setState(e.target.value);
};
return (
<Row className="list_input gx-2 p-1 my-3 align-items-center">
{inputs.map((item, i) => (
<Col className="col-auto">
<InputField key={i} value={item} onDelete={() => handleDelete(i)} />
</Col>
))}
<Col>
<input
type="text"
className="border-0 w-100"
placeholder={t('admin.private-placeholder')}
onKeyDown={handleKeyDown}
onChange={handleChange}
value={state.replace(';', '')}
/>
</Col>
</Row>
);
};
export default ListInput;

@ -0,0 +1,25 @@
import Image from 'next/image';
import Link from 'next/link';
import { useTranslation } from 'next-i18next';
import { getUrl, RouteTypes } from '@services/routes';
import logoWithText from '../public/logos/logo.svg';
import logo from '../public/logos/logo-footer.svg';
import { useRouter } from 'next/router';
interface LogoProps {
title?: boolean;
[props: string]: any;
}
const Logo = ({ title = true, ...props }: LogoProps) => {
const { t } = useTranslation();
const router = useRouter();
const src = title ? logoWithText : logo;
return (
<Link href={getUrl(RouteTypes.HOME, router).toString()}>
<Image src={src} alt={t('logo.alt')} className="d-block" {...props} />
</Link>
);
};
export default Logo;

@ -0,0 +1,240 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { GradeResultInterface, MeritProfileInterface } from '@services/type';
import { getMajorityGrade } from '@services/majorityJudgment';
interface ParamsInterface {
numVotes: number;
outgaugeThreshold: number;
}
interface GradeBarInterface {
grade: GradeResultInterface;
size: number;
index: number;
params: ParamsInterface;
}
const GradeBar = ({ index, grade, size, params }: GradeBarInterface) => {
const width = `${size * 100}%`;
const textWidth = Math.floor(100 * size);
const left = `${(size * 100) / 2}%`;
const top = index % 2 ? '20px' : '-20px';
if (size < 0.001) {
return null;
}
return (
<div
className="h-100"
style={{
flexBasis: width,
backgroundColor: grade.color,
minHeight: '20px',
}}
>
{/* size < params.outgaugeThreshold ? (
<span
style={{
left: left,
top: top,
display: "relative",
backgroundColor: grade.color,
}}
>
{textWidth}%
</span>
) : (
<span>
{Math.floor(100 * size)}%
</span>
)
*/}
</div>
);
};
const DashedMedian = () => {
return (
<div
className="position-relative d-flex justify-content-center"
style={{ top: '60px', height: '50px' }}
>
<div className="border h-100 border-1 border-dark border-opacity-75 border-dashed"></div>
</div>
);
};
const MajorityGrade = ({ grade, left }) => {
// const spanRef = useRef<HTMLDivElement>();
const spanRef = useRef<HTMLDivElement>();
const [width, setWidth] = useState(0);
const offsetWidth = spanRef && spanRef.current && spanRef.current.offsetWidth;
useEffect(() => {
setTimeout(() => {
setWidth(spanRef.current.offsetWidth);
}, 100);
}, [offsetWidth]);
return (
<div
style={{ left: `calc(${left * 100}% - ${width / 2}px)` }}
className="position-relative"
>
<div
ref={spanRef}
style={{
color: 'white',
backgroundColor: grade.color,
width: 'fit-content',
}}
className="p-2 fw-bold rounded-1 text-center"
>
{grade.name}
</div>
<div
style={{
width: 0,
height: 0,
position: 'relative',
left: `${width / 2 - 6}px`,
borderLeftWidth: 6,
borderRightWidth: 6,
borderTopWidth: 12,
borderStyle: 'solid',
backgroundColor: 'transparent',
borderLeftColor: 'transparent',
borderRightColor: 'transparent',
borderTopColor: grade.color,
color: 'transparent',
}}
></div>
</div>
);
};
interface MeritProfileBarInterface {
profile: MeritProfileInterface;
grades: Array<GradeResultInterface>;
}
const MeritProfileBar = ({ profile, grades }: MeritProfileBarInterface) => {
const gradesByValue: { [key: number]: GradeResultInterface } = {};
grades.forEach((g) => (gradesByValue[g.value] = g));
const numVotes = Object.values(profile).reduce((a, b) => a + b, 0);
const values = grades.map((g) => g.value).sort();
const normalized = {};
values.forEach((v) => (normalized[v] = profile[v] / numVotes || 0));
// low values means great grade
// find the majority grade
const majorityValue = getMajorityGrade(normalized);
const majorityGrade = gradesByValue[majorityValue];
const proponentSizes = values
.filter((v) => v > majorityGrade.value)
.map((v) => normalized[v]);
const proponentWidth = proponentSizes.reduce((a, b) => a + b, 0);
const opponentSizes = values
.filter((v) => v < majorityGrade.value)
.map((v) => normalized[v]);
const opponentWidth = opponentSizes.reduce((a, b) => a + b, 0);
// is proponent higher than opposant?
const proponentMajority = proponentWidth > opponentWidth;
// for mobile phone, we outgauge earlier than on desktop
const innerWidth =
typeof window !== 'undefined' && window.innerWidth
? window.innerWidth
: 1000;
const params: ParamsInterface = {
outgaugeThreshold: innerWidth <= 760 ? 0.05 : 0.03,
numVotes,
};
return (
<>
<DashedMedian />
<MajorityGrade
grade={majorityGrade}
left={proponentWidth + normalized[majorityValue] / 2}
/>
<div className="d-flex">
<div
className={`d-flex border border-${
proponentMajority ? 2 : 1
} border-success`}
style={{ flexBasis: `${proponentWidth * 100}%` }}
>
{values
.filter((v) => v > majorityGrade.value)
.map((v) => {
const index = values.indexOf(v);
const size =
proponentWidth < 1e-3 ? 0 : normalized[index] / proponentWidth;
return (
<GradeBar
index={index}
params={params}
grade={gradesByValue[v]}
key={index}
size={size}
/>
);
})}
</div>
<div
className="border border-2 border-primary"
style={{ flexBasis: `${normalized[majorityValue] * 100}%` }}
>
{values
.filter((v) => v === majorityGrade.value)
.map((v) => {
const index = values.indexOf(v);
return (
<GradeBar
index={index}
params={params}
grade={gradesByValue[v]}
key={index}
size={1}
/>
);
})}
</div>
<div
className={`d-flex border border-${
proponentMajority ? 1 : 2
} border-danger`}
style={{ flexBasis: `${opponentWidth * 100}%` }}
>
{values
.filter((v) => v < majorityGrade.value)
.map((v) => {
const index = values.indexOf(v);
const size =
opponentWidth < 1e-3 ? 0 : normalized[index] / opponentWidth;
return (
<GradeBar
index={index}
params={params}
grade={gradesByValue[v]}
key={index}
size={size}
/>
);
})}
</div>
</div>
{/* <div className='median dash'> </div> */}
</>
);
};
export default MeritProfileBar;

@ -0,0 +1,27 @@
// TODO use bootstrap modal
// https://getbootstrap.com/docs/5.0/components/modal/
//
const Modal = ({ show, onClose, children, title }) => {
const handleCloseClick = (e) => {
e.preventDefault();
onClose();
};
const modalContent = show ? (
<div className="vh-100 modal overlay">
<div className="modal body">
<div className="modal header">
<a href="#" onClick={handleCloseClick}>
x
</a>
</div>
{title && <div>{title}</div>}
<div className="pt-5">{children}</div>
</div>
</div>
) : null;
return modalContent;
};
export default Modal;

@ -0,0 +1,3 @@
export default ({ children }) => {
return <div className="waiting min-vh-100 min-vw-100">{children}</div>;
};

@ -0,0 +1,38 @@
import Image from 'next/image';
import {useTranslation} from 'next-i18next';
import {Row, Col} from 'reactstrap';
import twitter from '../public/twitter.svg';
import facebook from '../public/facebook.svg';
interface ShareInterface {
title?: string;
}
const ShareRow = ({title}: ShareInterface) => {
const {t} = useTranslation('resource');
return (
<Row className="sharing justify-content-md-center">
<Col className="col-md-auto mb-md-0 mb-3 col-12">{title || t('common.share')}</Col>
<Col className="col-auto">
<a
target="_blank"
rel="noopener noreferrer"
href="https://www.facebook.com/mieuxvoter.fr/"
>
<Image height={22} width={22} src={facebook} alt="icon facebook" />
</a>
</Col>
<Col className="col-auto">
<a
target="_blank"
rel="noopener noreferrer"
href="https://twitter.com/mieux_voter"
>
<Image height={22} width={22} src={twitter} alt="icon twitter" />
</a>
</Col>
</Row>
);
};
export default ShareRow;

@ -0,0 +1,28 @@
import {useSortable} from '@dnd-kit/sortable';
export interface SortableInterface {
id: string | number;
children: React.ReactNode;
className?: string;
}
export default ({id, children, className = ""}: SortableInterface) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({id});
const style = {
transform: transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : null,
transition,
};
return (
<div ref={setNodeRef} className={className} style={style} {...attributes} {...listeners}>
{children}
</div>
);
}

@ -0,0 +1,15 @@
const Switch = ({ toggle, state, className = '' }) => {
return (
<div className={`${className} form-check form-switch`}>
<input
onChange={toggle}
className="form-check-input"
type="checkbox"
role="switch"
checked={state}
/>
</div>
);
};
export default Switch;

@ -0,0 +1,8 @@
import Image from 'next/image';
import verticalGripDots from '../public/vertical-grip-dots.svg';
const VerticalGripDots = (props) => (
<Image src={verticalGripDots} alt="vertical grip dots" {...props} />
);
export default VerticalGripDots;

@ -0,0 +1,250 @@
import Image from 'next/image';
import { useTranslation } from 'next-i18next';
import { CSSProperties, useEffect, useState } from 'react';
import { faArrowRight } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Col, Container, Row } from 'reactstrap';
import Button from '@components/Button';
import Share from '@components/Share';
import ErrorMessage from '@components/Error';
import { BallotPayload, ErrorPayload } from '@services/api';
import { AppTypes, useAppContext } from '@services/context';
import { displayRef, isEnded } from '@services/utils';
import { getUrl, RouteTypes } from '@services/routes';
import Logo from './Logo';
import { FORM_FEEDBACK, MAJORITY_JUDGMENT_LINK } from '@services/constants';
import urne from '../public/urne.svg';
import star from '../public/star.svg';
import logo from '../public/logo-red-blue.svg';
import Link from 'next/link';
import { useRouter } from 'next/router';
export interface WaitingBallotInterface {
ballot?: BallotPayload;
error?: ErrorPayload;
}
const ButtonResults = ({ election }) => {
const { t } = useTranslation();
const router = useRouter();
if (!election.hideResults || isEnded(election.date_end)) {
return (
<Link href={getUrl(RouteTypes.RESULTS, router, election.ref)}>
<div className="w-100 d-grid">
<Button
color="light"
className="text-center border border-3 border-dark"
icon={faArrowRight}
position="right"
>
{t('vote.go-to-results')}
</Button>
</div>
</Link>
);
} else {
return null;
}
};
const DiscoverMajorityJudgment = () => {
const { t } = useTranslation();
return (
<Col className="d-flex flex-column justify-content-between bg-secondary p-4 text-white">
<div>
<h5>{t('vote.discover-mj')}</h5>
<p>{t('vote.discover-mj-desc')}</p>
</div>
<a
href={MAJORITY_JUDGMENT_LINK}
target="_blank"
rel="noopener noreferrer"
>
<div className="d-flex align-items-center">
<div className="me-2">{t('common.about')}</div>
<FontAwesomeIcon icon={faArrowRight} />
</div>
</a>
</Col>
);
};
const SupportBetterVote = () => {
const { t } = useTranslation();
return (
<Col className="d-flex flex-column justify-content-between text-secondary p-4 bg-white">
<div>
<div className="d-flex mb-2 align-items-center justify-content-between">
<h5>{t('vote.support-better-vote')}</h5>
<Logo src={logo} title={false} />
</div>
<p>{t('vote.support-desc')}</p>
</div>
<a href="https://mieuxvoter.fr/le-jugement-majoritaire">
<div className="d-flex align-items-center">
<div className="me-2">{t('common.donation')}</div>
<FontAwesomeIcon icon={faArrowRight} />
</div>
</a>
</Col>
);
};
const Thanks = () => {
const { t } = useTranslation();
return (
<>
<h5>{t('vote.thanks')}</h5>
<p>{t('vote.form-desc')}</p>
<a href={FORM_FEEDBACK} target="_blank" rel="noopener noreferrer">
<Button color="secondary" outline={true}>
{t('vote.form')}
</Button>
</a>
</>
);
};
const Info = ({ ballot, error }: WaitingBallotInterface) => {
const { t } = useTranslation();
const [ballotProperties, setBallot] = useState<CSSProperties>({
display: 'none',
});
useEffect(() => {
setTimeout(() => {
setBallot({ display: 'grid' });
}, 4500);
}, []);
if (!ballot) return null;
if (error) {
return <ErrorMessage>{error.detail[0].msg}</ErrorMessage>;
}
return (
<div
style={{
display: ballotProperties.display,
transition: 'display 2s',
}}
className="d-flex flex-column align-items-center"
>
<div>
<h4 className="text-center">{t('vote.success-ballot')}</h4>
<ButtonResults election={ballot.election} />
</div>
<Container className="d-flex my-4 gap-4">
<DiscoverMajorityJudgment />
<SupportBetterVote />
</Container>
<Thanks />
<Share />
</div>
);
};
const AnimationBallot = () => {
const [_, dispatch] = useAppContext();
const [urneProperties, setUrne] = useState<CSSProperties>({
width: 0,
height: 0,
marginBottom: 0,
});
const [starProperties, setStar] = useState<CSSProperties>({
width: 0,
height: 0,
marginLeft: 100,
marginBottom: 0,
});
const [urneContainerProperties, setUrneContainer] = useState<CSSProperties>({
height: '100vh',
});
useEffect(() => {
dispatch({ type: AppTypes.FULLPAGE, value: true });
setUrne((urne) => ({
...urne,
width: 300,
height: 300,
}));
setTimeout(() => {
setStar((star) => ({
...star,
width: 150,
height: 150,
marginLeft: -150,
marginBottom: 300,
}));
}, 1000);
setTimeout(() => {
setUrneContainer((urneContainer) => ({
...urneContainer,
height: 250,
}));
setStar((star) => ({
...star,
width: 100,
height: 100,
marginLeft: -100,
marginBottom: 200,
}));
setUrne((urne) => ({
...urne,
width: 200,
height: 200,
}));
}, 3000);
}, []);
return (
<div
style={{
transition: 'width 2s, height 2s',
height: urneContainerProperties.height,
}}
className="mt-5 flex-fill d-flex align-items-center justify-content-center"
>
<div
className="position-relative"
style={{
transition: 'width 2s, height 2s, margin-bottom 2s',
zIndex: 2,
marginTop: urneProperties.marginBottom,
height: urneProperties.height,
width: urneProperties.width,
}}
>
<Image src={urne} alt="urne" fill={true} />
</div>
<div
className="position-relative"
style={{
transition: 'width 2s, height 2s, margin-left 2s, margin-bottom 2s',
marginLeft: starProperties.marginLeft,
marginBottom: starProperties.marginBottom,
height: starProperties.height,
width: starProperties.width,
}}
>
<Image src={star} fill={true} alt="urne" />
</div>
</div>
);
};
export default ({ ballot, error }: WaitingBallotInterface) => {
return (
<Container className="d-flex min-vh-100 min-vw-100 align-items-between justify-content-center flex-column pb-5">
<AnimationBallot />
<Info ballot={ballot} error={error} />
</Container>
);
};

@ -0,0 +1,214 @@
import Image from 'next/image';
import { useTranslation } from 'next-i18next';
import { CSSProperties, useEffect, useState } from 'react';
import { faArrowRight } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import Button from '@components/Button';
import ButtonCopy from '@components/ButtonCopy';
import Share from '@components/Share';
import ErrorMessage from '@components/Error';
import AdminModalEmail from '@components/admin/AdminModalEmail';
import { ElectionCreatedPayload, ErrorPayload } from '@services/api';
import { AppTypes, useAppContext } from '@services/context';
import { getUrl, RouteTypes } from '@services/routes';
import urne from '../public/urne.svg';
import star from '../public/star.svg';
import { Container } from 'reactstrap';
import { useRouter } from 'next/router';
import { getLocaleShort } from '@services/utils';
export interface WaitingBallotInterface {
election?: ElectionCreatedPayload;
error?: ErrorPayload;
}
interface InfoElectionInterface extends WaitingBallotInterface {
display: string;
}
const InfoElection = ({ election, error, display }: InfoElectionInterface) => {
const { t } = useTranslation();
const router = useRouter();
const [modal, setModal] = useState(false);
const toggleModal = () => setModal((m) => !m);
if (!election) return null;
const urlVote = getUrl(RouteTypes.VOTE, router, election.ref);
const urlResults = getUrl(RouteTypes.RESULTS, router, election.ref);
return (
<div
style={{
display: display,
transition: 'display 2s',
}}
>
<div className="d-flex flex-column align-items-center">
{error && error.detail ? (
<ErrorMessage>{error.detail[0].msg}</ErrorMessage>
) : null}
{election && election.ref ? (
<>
<h4 className="text-center">{t('admin.success-election')}</h4>
{election && election.restricted ? (
<h5 className="text-center">{t('admin.success-emails')}</h5>
) : (
<div className="d-grid w-100">
<ButtonCopy
text={t('admin.success-copy-vote')}
content={urlVote}
/>
<ButtonCopy
text={t('admin.success-copy-result')}
content={urlResults}
/>
</div>
)}
<div className="d-grid w-100">
<Button
icon={faArrowRight}
position="right"
color="info"
outline={false}
onClick={toggleModal}
className="border-dark border-4 mt-3 py-3"
>
{t('admin.go-to-admin')}
</Button>
</div>
<Share title={t('common.share-short')} />
<AdminModalEmail
toggle={toggleModal}
isOpen={modal}
electionRef={election.ref}
adminToken={election.admin}
/>
</>
) : null}
</div>
</div>
);
};
export default ({ election, error }: WaitingBallotInterface) => {
const [_, dispatch] = useAppContext();
const [urneProperties, setUrne] = useState<CSSProperties>({
width: 0,
height: 0,
marginBottom: 0,
});
const [starProperties, setStar] = useState<CSSProperties>({
width: 0,
height: 0,
marginLeft: 100,
marginBottom: 0,
});
const [urneContainerProperties, setUrneContainer] = useState<CSSProperties>({
height: '100vh',
});
const [electionProperties, setElection] = useState<CSSProperties>({
display: 'none',
});
useEffect(() => {
dispatch({ type: AppTypes.FULLPAGE, value: true });
setUrne((urne) => ({
...urne,
width: 300,
height: 300,
}));
const timer = setTimeout(() => {
setStar((star) => ({
...star,
width: 150,
height: 150,
marginLeft: 150,
marginBottom: 300,
}));
}, 1000);
const timer2 = setTimeout(() => {
// setElection({display: "block"});
setUrneContainer((urneContainer) => ({
...urneContainer,
height: '50vh',
}));
setStar((star) => ({
...star,
width: 100,
height: 100,
marginLeft: 100,
marginBottom: 200,
}));
setUrne((urne) => ({
...urne,
width: 200,
height: 200,
}));
}, 3000);
const timer3 = setTimeout(() => {
setElection({ display: 'grid' });
}, 4500);
return () => {
clearTimeout(timer);
clearTimeout(timer2);
clearTimeout(timer3);
};
}, []);
return (
<Container
className="d-flex h-100 w-100 align-items-center flex-column"
style={{
maxWidth: '400px',
}}
>
<div
style={{
transition: 'width 2s, height 2s',
height: urneContainerProperties.height,
}}
className="d-flex align-items-center"
>
<div
className="position-relative"
style={{
transition: 'width 2s, height 2s, margin-bottom 2s',
zIndex: 2,
marginTop: urneProperties.marginBottom,
height: urneProperties.height,
width: urneProperties.width,
}}
>
<Image src={urne} alt="urne" fill={true} />
</div>
<div
className="position-absolute"
style={{
transition: 'width 2s, height 2s, margin-left 2s, margin-bottom 2s',
marginLeft: starProperties.marginLeft,
marginBottom: starProperties.marginBottom,
height: starProperties.height,
width: starProperties.width,
}}
>
<Image src={star} fill={true} alt="urne" />
</div>
</div>
<InfoElection
election={election}
error={error}
display={electionProperties.display}
/>
</Container>
);
};

@ -0,0 +1,37 @@
import { useTranslation } from 'next-i18next';
import { ElectionTypes, useElection } from '@services/ElectionContext';
import { Container } from 'reactstrap';
import Switch from '@components/Switch';
const AccessResults = () => {
const { t } = useTranslation();
const [election, dispatch] = useElection();
const toggle = () => {
dispatch({
type: ElectionTypes.SET,
field: 'hideResults',
value: !election.hideResults,
});
};
return (
<Container className="bg-white p-3 p-md-4">
<div className="d-flex">
<div className="me-auto d-flex flex-row justify-content-center">
<h5 className="mb-0 text-dark d-flex align-items-center">
{t('admin.access-results')}
</h5>
{election.hideResults ? (
<p className="text-muted d-none d-md-block">
{t('admin.access-results-desc')}
</p>
) : null}
</div>
<Switch toggle={toggle} state={!election.hideResults} />
</div>
</Container>
);
};
export default AccessResults;

@ -0,0 +1,48 @@
import { useState } from 'react';
export default function AddPicture(props) {
const [image, setImage] = useState(null);
const [createObjectURL, setCreateObjectURL] = useState(null);
const uploadToClient = (event) => {
if (event.target.files && event.target.files[0]) {
const i = event.target.files[0];
setImage(i);
setCreateObjectURL(URL.createObjectURL(i));
}
};
return (
<div className="ajout-avatar">
<div>
<div className="avatar-placeholer">
<img src={createObjectURL} />
</div>
</div>
<div className="avatar-text">
<h4>
Photo <span> (facultatif)</span>
</h4>
<p>
Importer une photo.
<br />
format : jpg, png, pdf
</p>
<div className="btn-ajout-avatar">
<input
type="file"
name="myImage"
id="myImage"
onChange={uploadToClient}
/>
<label className="inputfile" htmlFor="myImage">
Importer une photo
</label>
ddpi
</div>
</div>
</div>
);
}

@ -0,0 +1,104 @@
import { useState } from 'react';
import { Input, Modal, ModalBody, Form } from 'reactstrap';
import { faArrowLeft, faCheck } from '@fortawesome/free-solid-svg-icons';
import { useTranslation } from 'next-i18next';
import Button from '@components/Button';
import ButtonCopy from '@components/ButtonCopy';
import { sendAdminMail, validateMail } from '@services/mail';
import { getUrl, RouteTypes } from '@services/routes';
import { useElection } from '@services/ElectionContext';
import { getLocaleShort } from '@services/utils';
import { useRouter } from 'next/router';
interface AdminModalEmailInterface {
isOpen: boolean;
toggle: () => void;
electionRef: string | null;
adminToken: string | null;
}
const AdminModalEmail = ({
isOpen,
toggle,
electionRef,
adminToken,
}: AdminModalEmailInterface) => {
const { t } = useTranslation();
const router = useRouter();
const [email, setEmail] = useState(undefined);
const [election, _] = useElection();
const adminUrl =
electionRef && adminToken
? getUrl(RouteTypes.ADMIN, router, electionRef, adminToken)
: null;
const handleEmail = (e) => {
setEmail(e.target.value);
};
const handleSubmit = () => {
const locale = getLocaleShort(router);
sendAdminMail(email, election.name, locale, adminUrl);
toggle();
};
const disabled = !email || !validateMail(email);
return (
<Modal isOpen={isOpen} toggle={toggle} keyboard={true}>
<div className="modal-header p-4">
<h4 className="modal-title">{t('admin.modal-title')}</h4>
<button
type="button"
onClick={toggle}
className="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<ModalBody className="p-4">
<p>{t('admin.modal-desc')}</p>
<div className="d-grid w-100 mb-5">
<ButtonCopy text={t('admin.success-copy-admin')} content={adminUrl} />
</div>
<p>{t('admin.modal-email')}</p>
<p className="text-muted">{t('admin.modal-disclaimer')}</p>
<Form className="container container-fluid">
<div className="mb-3">
<Input
type="text"
placeholder={t('admin.modal-email-placeholder')}
value={email}
onChange={handleEmail}
autoFocus
required
/>
</div>
<div className="mt-5 gap-2 d-grid mb-3 d-md-flex">
<Button
onClick={toggle}
color="dark"
className="me-md-auto"
outline={true}
icon={faArrowLeft}
>
{t('common.cancel')}
</Button>
<Button
disabled={disabled}
color="primary"
position="right"
onClick={handleSubmit}
icon={faCheck}
>
{t('common.send')}
</Button>
</div>
</Form>
</ModalBody>
</Modal>
);
};
export default AdminModalEmail;

@ -0,0 +1,56 @@
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 cancelButton">
<button type="button" className={'btn ' + className} onClick={toggle}>
<div className="annuler">
<img src="/arrow-dark-left.svg" />
<p>Annuler</p>
</div>
</button>
<Modal
isOpen={visibled}
toggle={toggle}
className="modal-dialog-centered cancelForm"
>
<ModalHeader>
<FontAwesomeIcon icon={faTrashAlt} />
</ModalHeader>
<ModalBody>
{t('Are you sure to delete')}
{<br />}
{label && label !== '' ? <b>{label}</b> : <>{t('the row')}</>}
</ModalBody>
<ModalFooter>
<Button className={className} onClick={toggle}>
<div className="annuler">
<img src="/arrow-dark-left.svg" /> {t('No')}
</div>
</Button>
<Button
className="new-btn-confirm"
onClick={() => {
toggle();
onDelete();
}}
>
<FontAwesomeIcon icon={faTrashAlt} />
{t('Yes')}
</Button>
</ModalFooter>
</Modal>
</div>
);
};
export default ButtonWithConfirm;

@ -0,0 +1,123 @@
/**
* This is the candidate field used during election creation
*/
import { useState } from 'react';
import Image from 'next/image';
import { useTranslation } from 'next-i18next';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlus, faTrashCan } from '@fortawesome/free-solid-svg-icons';
import { useSortable } from '@dnd-kit/sortable';
import { ElectionTypes, useElection } from '@services/ElectionContext';
import VerticalGripDots from '@components/VerticalGripDots';
import whiteAvatar from '../../public/avatar.svg';
import CandidateModalSet from './CandidateModalSet';
import CandidateModalDel from './CandidateModalDel';
interface CandidateProps {
position: number;
className?: string;
defaultAvatar?: any;
editable?: boolean;
[props: string]: any;
}
const CandidateField = ({
position,
className = '',
defaultAvatar = whiteAvatar,
editable = true,
...props
}: CandidateProps) => {
const { t } = useTranslation();
const [election, dispatch] = useElection();
const candidate = election.candidates[position];
const image = candidate && candidate.image ? candidate.image : defaultAvatar;
const active = candidate && candidate.active === true;
const [modalDel, setModalDel] = useState(false);
const [modalSet, setModalSet] = useState(false);
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: position + 1 });
const addCandidate = () => {
dispatch({ type: ElectionTypes.CANDIDATE_PUSH, value: 'default' });
};
const toggleSet = () => setModalSet((m) => !m);
const toggleDel = () => setModalDel((m) => !m);
const activeClass = active
? 'bg-white text-secondary'
: 'border border-dashed border-2 border-light border-opacity-25';
const style = {
transform: transform
? `translate3d(${transform.x}px, ${transform.y}px, 0)`
: null,
transition,
};
return (
<div
className={`${activeClass} d-flex justify-content-between align-items-center ${className}`}
ref={setNodeRef}
style={style}
>
<div
onClick={toggleSet}
role="button"
className="py-3 me-1 flex-fill d-flex align-items-center "
>
<Image
src={image}
width={24}
height={24}
className={`${
image == defaultAvatar ? 'default-avatar' : ''
} bg-primary`}
alt={t('common.thumbnail')}
/>
<div className="ps-2 fw-bold">
{candidate.name ? candidate.name : t('admin.add-candidate')}
</div>
</div>
{editable ? (
<div role="button" className="text-end">
{active ? (
<FontAwesomeIcon
icon={faTrashCan}
className="text-black opacity-25"
onClick={() => setModalDel((m) => !m)}
/>
) : (
<FontAwesomeIcon icon={faPlus} onClick={addCandidate} />
)}
</div>
) : null}
<div
{...props}
{...attributes}
{...listeners}
role="button"
className="text-end ms-3"
>
{active ? <VerticalGripDots /> : null}
</div>
<CandidateModalSet
toggle={toggleSet}
isOpen={modalSet}
position={position}
/>
<CandidateModalDel
toggle={toggleDel}
isOpen={modalDel}
position={position}
/>
</div>
);
};
export default CandidateField;

@ -0,0 +1,65 @@
import { Row, Col, Modal, ModalBody } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faTrashCan,
faTrashAlt,
faArrowLeft,
} from '@fortawesome/free-solid-svg-icons';
import { useTranslation } from 'next-i18next';
import { ElectionTypes, useElection } from '@services/ElectionContext';
import Button from '@components/Button';
const CandidateModal = ({ isOpen, position, toggle }) => {
const { t } = useTranslation();
const [election, dispatch] = useElection();
const candidate = election.candidates[position];
const removeCandidate = () => {
dispatch({ type: ElectionTypes.CANDIDATE_RM, position: position });
toggle();
};
return (
<Modal
isOpen={isOpen}
toggle={toggle}
keyboard={true}
className="modal_candidate"
>
<ModalBody className="flex-column justify-contenter-center d-flex p-4">
<Row className="justify-content-center">
<Col className="col-auto px-4 py-4 rounded-circle bg-light">
<FontAwesomeIcon size="2x" icon={faTrashCan} />
</Col>
</Row>
<p className="text-danger fw-bold text-center mt-4">
{t('admin.candidate-confirm-del')}
</p>
{candidate.name ? (
<h4 className="text-center">{candidate.name}</h4>
) : null}
<div className="mt-5 gap-2 d-grid mb-3 d-md-flex">
<Button
onClick={toggle}
color="dark"
icon={faArrowLeft}
outline={true}
className="me-md-auto"
>
{t('admin.candidate-confirm-back')}
</Button>
<Button
icon={faTrashAlt}
color="danger"
role="submit"
onClick={removeCandidate}
>
{t('admin.candidate-confirm-ok')}
</Button>
</div>
</ModalBody>
</Modal>
);
};
export default CandidateModal;

@ -0,0 +1,220 @@
import { useState, useEffect, useRef, KeyboardEvent } from 'react';
import { Row, Col, Label, Input, Modal, ModalBody, Form } from 'reactstrap';
import { faPlus, faArrowLeft } from '@fortawesome/free-solid-svg-icons';
import { useTranslation } from 'next-i18next';
import Image from 'next/image';
import { ElectionTypes, useElection } from '@services/ElectionContext';
import Button from '@components/Button';
import { upload } from '@services/imgpush';
import { IMGPUSH_URL } from '@services/constants';
import { AppTypes, useAppContext } from '@services/context';
import defaultAvatar from '../../public/default-avatar.svg';
const CandidateModal = ({ isOpen, position, toggle }) => {
const { t } = useTranslation();
const [election, dispatch] = useElection();
const candidate = election.candidates[position];
const [state, setState] = useState(candidate);
const image = state.image && state.image != '' ? state.image : defaultAvatar;
const handleFile = async (event) => {
const payload = await upload(event.target.files[0]);
setState((s) => ({ ...s, image: `${IMGPUSH_URL}/${payload['filename']}` }));
};
const [app, dispatchApp] = useAppContext();
// to manage the hidden ugly file input
const hiddenFileInput = useRef(null);
const inputRef = useRef(null);
const names = election.candidates
.filter((_, i) => i != position)
.map((c) => c.name);
const disabled = state.name === '' || names.includes(state.name);
useEffect(() => {
setState(election.candidates[position]);
}, [election]);
useEffect(() => {
// When isOpen got active, we put the focus on the input field
setTimeout(() => {
console.log(inputRef.current);
if (isOpen && inputRef && inputRef.current) {
inputRef.current.focus();
}
}, 100);
}, [isOpen]);
// check if key down is enter
const handleKeyDown = (e: KeyboardEvent<HTMLFormElement>) => {
if (e.key == 'Enter') {
save(e);
}
};
const save = (e) => {
e.preventDefault();
if (state.name === '') {
dispatchApp({
type: AppTypes.TOAST_ADD,
status: 'error',
message: t('error.empty-name'),
});
return;
}
if (names.includes(state.name)) {
alert('foo');
dispatchApp({
type: AppTypes.TOAST_ADD,
status: 'error',
message: t('error.twice-same-names'),
});
return;
}
dispatch({
type: ElectionTypes.CANDIDATE_SET,
position: position,
field: 'image',
value: state.image,
});
dispatch({
type: ElectionTypes.CANDIDATE_SET,
position: position,
field: 'name',
value: state.name,
});
dispatch({
type: ElectionTypes.CANDIDATE_SET,
position: position,
field: 'description',
value: state.description,
});
toggle();
};
const handleName = (e) => {
setState((s) => ({ ...s, name: e.target.value }));
};
const handleDescription = (e) => {
setState((s) => ({ ...s, description: e.target.value }));
};
return (
<Modal
isOpen={isOpen}
toggle={toggle}
keyboard={true}
className="modal_candidate"
>
<div className="modal-header p-4">
<h4 className="modal-title">{t('admin.add-candidate')}</h4>
<button
type="button"
onClick={toggle}
className="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<ModalBody className="p-4">
<p>{t('admin.add-candidate-desc')}</p>
<Col>
<Form className="container container-fluid" onKeyDown={handleKeyDown}>
<div className="my-3">
<Label className="fw-bold">{t('common.name')} </Label>
<input
className="form-control"
type="text"
placeholder={t('admin.candidate-name-placeholder')}
tabIndex={position + 1}
value={state.name}
onChange={handleName}
maxLength={250}
autoFocus={true}
required={true}
ref={inputRef}
/>
</div>
<Label className="fw-bold">
{t('admin.photo')}{' '}
<span className="text-muted"> ({t('admin.optional')})</span>
</Label>
<div className="d-flex flex-column flex-md-row gap-2 justify-content-md-between justify-content-center align-items-center">
<Image
src={image}
alt={t('admin.photo')}
height={120}
width={120}
/>
<div className="mb-3">
<p>{t('admin.photo-type')} jpg, png, pdf</p>
<div className="w-100 d-md-block d-grid">
<input
type="file"
className="hide"
onChange={handleFile}
ref={hiddenFileInput}
/>
<Button
color="dark"
outline={true}
onClick={() => hiddenFileInput.current.click()}
>
{t('admin.photo-import')}
</Button>
</div>
</div>
</div>
<div className="">
<Label className="fw-bold">
{t('common.description')}{' '}
<span className="text-muted"> ({t('admin.optional')})</span>
</Label>
<textarea
rows={3}
className="form-control"
placeholder={t('admin.candidate-desc-placeholder')}
onChange={handleDescription}
value={state.description}
maxLength={250}
/>
</div>
<div className="mt-3 gap-2 d-flex mb-3 justify-content-between">
<Button
color="dark"
onClick={toggle}
className=""
outline={true}
icon={faArrowLeft}
>
{t('common.cancel')}
</Button>
{
// Since we disabled the button, the onCLick is supported by another component
}
<div onClick={save}>
<Button
color={disabled ? 'light' : 'primary'}
position="right"
disabled={disabled}
icon={faPlus}
role="submit"
>
{t('common.save')}
</Button>
</div>
</div>
</Form>
</Col>
</ModalBody>
</Modal>
);
};
export default CandidateModal;

@ -0,0 +1,60 @@
import { useTranslation } from 'next-i18next';
import { Container, Row, Col } from 'reactstrap';
import { ElectionTypes, useElection } from '@services/ElectionContext';
import { DndContext } from '@dnd-kit/core';
import { arrayMove, SortableContext } from '@dnd-kit/sortable';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPen } from '@fortawesome/free-solid-svg-icons';
import CandidateField from './CandidateField';
const CandidatesConfirmField = ({ editable = true }) => {
const { t } = useTranslation();
const [election, dispatch] = useElection();
const handleDragEnd = (event) => {
/**
* Update the list of grades after dragging an item
*/
const { active, over } = event;
if (over && over.id && active.id && active.id !== over.id) {
const newCandidates = arrayMove(
election.candidates,
active.id - 1,
over.id - 1
);
dispatch({
type: ElectionTypes.SET,
field: 'candidates',
value: newCandidates,
});
}
};
const sortIds = election.candidates.map((_, i) => i + 1);
return (
<DndContext onDragEnd={handleDragEnd}>
<SortableContext items={sortIds}>
<Container className="bg-white p-4 mt-3 mt-md-0">
<Row>
<Col className="col-auto me-auto">
<h5 className="text-dark">{t('admin.confirm-candidates')}</h5>
</Col>
</Row>
{election.candidates.map((_, i) => (
<CandidateField
editable={editable}
position={i}
key={i}
className="text-primary m-0"
/>
))}
</Container>
</SortableContext>
</DndContext>
);
};
export default CandidatesConfirmField;

@ -0,0 +1,144 @@
import {
useState,
useEffect,
useRef,
KeyboardEvent,
MouseEventHandler,
} from 'react';
import { useTranslation } from 'next-i18next';
import { Container } from 'reactstrap';
import { DndContext } from '@dnd-kit/core';
import { arrayMove, SortableContext } from '@dnd-kit/sortable';
import { faArrowRight, faPen } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import TitleModal from './TitleModal';
import { MAX_NUM_CANDIDATES } from '@services/constants';
import Alert from '@components/Alert';
import Button from '@components/Button';
import { ElectionTypes, useElection } from '@services/ElectionContext';
import CandidateField from './CandidateField';
import { AppTypes, useAppContext } from '@services/context';
const CandidatesField = ({ onSubmit }) => {
const { t } = useTranslation();
const submitReference = useRef(null);
const [_, dispatchApp] = useAppContext();
const [election, dispatch] = useElection();
const candidates = election.candidates;
const [modalTitle, setModalTitle] = useState(false);
const toggleModalTitle = () => setModalTitle((m) => !m);
const [error, setError] = useState(null);
const disabled = candidates.filter((c) => c.name !== '').length < 2;
// What to do when we change the candidates
useEffect(() => {
// Initialize the list with at least two candidates
if (candidates.length < 2) {
dispatch({ type: ElectionTypes.CANDIDATE_PUSH, value: 'default' });
}
if (candidates.length > MAX_NUM_CANDIDATES) {
setError('error.too-many-candidates');
}
}, [candidates]);
useEffect(() => {
if (!disabled && submitReference.current) {
submitReference.current.focus();
}
}, [disabled, submitReference]);
const handleSubmit = (e) => {
if (disabled) {
dispatchApp({
type: AppTypes.TOAST_ADD,
status: 'error',
message: t('error.at-least-2-candidates'),
});
} else {
return onSubmit(e);
}
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key == 'Enter' && !disabled) {
onSubmit();
}
};
const handleDragEnd = (event) => {
/**
* Update the list of grades after dragging an item
*/
const { active, over } = event;
if (over && over.id && active.id && active.id !== over.id) {
const newCandidates = arrayMove(candidates, active.id - 1, over.id - 1);
dispatch({
type: ElectionTypes.SET,
field: 'candidates',
value: newCandidates,
});
}
};
const sortIds = election.candidates.map((_, i) => i + 1);
return (
<>
<Container onClick={toggleModalTitle} className="candidate mt-5">
<h4 className="mb-4">{t('admin.confirm-question')}</h4>
<div className="d-flex justify-content-between border border-dashed border-2 border-light border-opacity-25 px-4 py-3 mx-2 mx-md-0">
<h5 className="m-0 text-white">{election.name}</h5>
<FontAwesomeIcon icon={faPen} />
</div>
<TitleModal isOpen={modalTitle} toggle={toggleModalTitle} />
</Container>
<DndContext onDragEnd={handleDragEnd}>
<SortableContext items={sortIds}>
<Container className="candidate flex-grow-1 my-5 flex-column d-flex justify-content-between">
<div className="d-flex flex-column">
<h4 className="mb-4">{t('admin.add-candidates')}</h4>
<div className="mb-4">{t('admin.add-candidates-desc')}</div>
<Alert msg={error} />
<div className="d-flex flex-column mx-2 mx-md-0">
{candidates.map((_, index) => {
return (
<CandidateField
key={index}
position={index}
className="px-4 my-3"
/>
);
})}
</div>
</div>
<div
className="w-100 d-flex justify-content-center"
onClick={handleSubmit}
>
<Button
outline={true}
color="secondary"
className={`bg-blue${disabled ? ' disabled' : ''}`}
icon={faArrowRight}
position="right"
onKeyDown={handleKeyDown}
>
{t('admin.candidates-submit')}
</Button>
</div>
</Container>
</SortableContext>
</DndContext>
</>
);
};
export default CandidatesField;

@ -0,0 +1,191 @@
import { useTranslation } from 'next-i18next';
import { NextRouter, useRouter } from 'next/router';
import { faArrowRight } from '@fortawesome/free-solid-svg-icons';
import { Button, Row, Col, Container } from 'reactstrap';
import TitleField from './Title';
import CandidatesConfirmField from './CandidatesConfirmField';
import AccessResults from './AccessResults';
import LimitDate from './LimitDate';
import Grades from './Grades';
import Private from './Private';
import Order from './Order';
import {
useElection,
ElectionContextInterface,
hasEnoughCandidates,
hasEnoughGrades,
checkName,
canBeFinished,
} from '@services/ElectionContext';
import { createElection, ElectionCreatedPayload } from '@services/api';
import { getUrl, RouteTypes } from '@services/routes';
import { GradeItem, CandidateItem } from '@services/type';
import { sendInviteMails } from '@services/mail';
import { AppTypes, useAppContext } from '@services/context';
import { useEffect } from 'react';
const submitElection = (
election: ElectionContextInterface,
successCallback: Function,
failureCallback: Function,
router: NextRouter
) => {
const candidates = election.candidates
.filter((c) => c.active)
.map((c: CandidateItem) => ({
name: c.name,
description: c.description,
image: c.image,
}));
const grades = election.grades
.filter((c) => c.active)
.map((g: GradeItem, i: number) => ({
name: g.name,
value: election.grades.length - 1 - i,
}));
createElection(
election.name,
candidates,
grades,
election.description,
election.emails.length,
election.hideResults,
election.forceClose,
election.restricted,
election.randomOrder,
async (payload: ElectionCreatedPayload) => {
if (
typeof election.emails !== 'undefined' &&
election.emails.length > 0
) {
if (
typeof payload.invites === 'undefined' ||
payload.invites.length !== election.emails.length
) {
throw Error('Can not send invite emails');
}
const urlVotes = payload.invites.map((token: string) =>
getUrl(RouteTypes.VOTE, router, payload.ref, token)
);
const urlResult = getUrl(RouteTypes.RESULTS, router, payload.ref);
await sendInviteMails(
election.emails,
election.name,
urlVotes,
urlResult,
router
);
}
successCallback(payload);
},
failureCallback
);
};
const ConfirmField = ({ onSubmit, onSuccess, onFailure }) => {
const { t } = useTranslation();
const router = useRouter();
const [election, _] = useElection();
const [app, dispatchApp] = useAppContext();
useEffect(() => {
// move to the head of the page on component loading
window && window.scrollTo(0, 0);
}, []);
const handleSubmit = () => {
if (!checkName(election)) {
dispatchApp({
type: AppTypes.TOAST_ADD,
status: 'error',
message: t('error.uncorrect-name'),
});
return;
}
if (!hasEnoughGrades(election)) {
dispatchApp({
type: AppTypes.TOAST_ADD,
status: 'error',
message: t('error.not-enough-grades'),
});
return;
}
if (!hasEnoughCandidates(election)) {
dispatchApp({
type: AppTypes.TOAST_ADD,
status: 'error',
message: t('error.not-enough-candidates'),
});
return;
}
if (!canBeFinished(election)) {
dispatchApp({
type: AppTypes.TOAST_ADD,
status: 'error',
message: t('error.cant-be-finished'),
});
return;
}
onSubmit();
submitElection(election, onSuccess, onFailure, router);
};
const disabled =
!checkName(election) ||
!hasEnoughCandidates(election) ||
!hasEnoughGrades(election) ||
!canBeFinished(election);
return (
<Container
fluid="xl"
className="my-5 flex-column d-flex justify-content-center"
>
<Container className="px-0 d-md-none mb-5">
<h4>{t('admin.confirm-title')}</h4>
</Container>
<Row>
<Col className="col-lg-3 col-12">
<Container className="py-4 d-none d-md-block">
<h4>{t('common.the-vote')}</h4>
</Container>
<TitleField />
<CandidatesConfirmField />
</Col>
<Col className="col-lg-9 col-12 mt-3 mt-md-0">
<Container className="py-4 d-none d-md-block">
<h4>{t('common.the-params')}</h4>
</Container>
<AccessResults />
<LimitDate />
<Grades />
<Order />
<Private />
</Col>
</Row>
<Container
onClick={handleSubmit}
className="my-5 d-md-flex d-grid justify-content-md-center"
>
<Button
outline={true}
color="secondary"
className="bg-blue"
disabled={disabled}
icon={faArrowRight}
position="right"
>
{t('admin.confirm-submit')}
</Button>
</Container>
</Container>
);
};
export default ConfirmField;

@ -0,0 +1,35 @@
import React from 'react';
export const DotButton = ({ selected, onClick, value }) => (
<button
className={`embla__dot ${selected ? 'is-selected' : ''}`}
type="button"
onClick={onClick}
>
{value}
</button>
);
export const PrevButton = ({ enabled, onClick }) => (
<button
className="embla__button embla__button--prev"
onClick={onClick}
disabled={!enabled}
>
<svg className="embla__button__svg" viewBox="137.718 -1.001 366.563 644">
<path d="M428.36 12.5c16.67-16.67 43.76-16.67 60.42 0 16.67 16.67 16.67 43.76 0 60.42L241.7 320c148.25 148.24 230.61 230.6 247.08 247.08 16.67 16.66 16.67 43.75 0 60.42-16.67 16.66-43.76 16.67-60.42 0-27.72-27.71-249.45-249.37-277.16-277.08a42.308 42.308 0 0 1-12.48-30.34c0-11.1 4.1-22.05 12.48-30.42C206.63 234.23 400.64 40.21 428.36 12.5z" />
</svg>
</button>
);
export const NextButton = ({ enabled, onClick }) => (
<button
className="embla__button embla__button--next"
onClick={onClick}
disabled={!enabled}
>
<svg className="embla__button__svg" viewBox="0 0 238.003 238.003">
<path d="M181.776 107.719L78.705 4.648c-6.198-6.198-16.273-6.198-22.47 0s-6.198 16.273 0 22.47l91.883 91.883-91.883 91.883c-6.198 6.198-6.198 16.273 0 22.47s16.273 6.198 22.47 0l103.071-103.039a15.741 15.741 0 0 0 4.64-11.283c0-4.13-1.526-8.199-4.64-11.313z" />
</svg>
</button>
);

@ -0,0 +1,84 @@
import { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faXmark, faRotateLeft } from '@fortawesome/free-solid-svg-icons';
import { useSortable } from '@dnd-kit/sortable';
import { ElectionTypes, useElection } from '@services/ElectionContext';
import { getGradeColor, gradeColors } from '@services/grades';
import VerticalGripDots from '@components/VerticalGripDots';
import GradeModalSet from './GradeModalSet';
export interface GradeInterface {
value: number;
}
export default ({ value }: GradeInterface) => {
const [election, dispatch] = useElection();
const grade = election.grades.filter((g) => g.value === value)[0];
const activeGrade = election.grades.filter((g) => g.active);
const numGrades = activeGrade.length;
const gradeIdx = activeGrade.map((g) => g.value).indexOf(value);
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: grade.name });
const [visible, setVisible] = useState<boolean>(false);
const toggle = () => setVisible((v) => !v);
const handleActive = () => {
if (!grade.active && numGrades >= gradeColors.length) {
return;
}
dispatch({
type: ElectionTypes.GRADE_SET,
position: election.grades.map((g) => g.value).indexOf(value),
field: 'active',
value: !grade.active,
});
};
const color = getGradeColor(gradeIdx, numGrades);
const style = {
color: grade.active ? 'white' : '#8F88BA',
backgroundColor: grade.active ? color : '#F2F0FF',
transform: transform
? `translate3d(${transform.x}px, ${transform.y}px, 0)`
: null,
transition,
};
return (
<div
style={style}
ref={setNodeRef}
className="py-2 pe-3 m-1 fw-bold rounded-1 d-flex justify-content-between gap-1"
>
<div {...attributes} {...listeners} className="d-flex align-items-center">
<VerticalGripDots height={15} width={30} />
</div>
<div
style={{ touchAction: 'none' }}
className={grade.active ? '' : 'text-decoration-line-through'}
role="button"
onClick={toggle}
>
{grade.name}
</div>
<div className="d-flex gap-2 align-items-center ms-2">
{grade.active ? (
<>
<FontAwesomeIcon
onClick={handleActive}
icon={grade.active ? faXmark : faRotateLeft}
style={{ color: 'black', opacity: '25%' }}
/>
</>
) : (
<FontAwesomeIcon onClick={handleActive} icon={faRotateLeft} />
)}
</div>
<GradeModalSet toggle={toggle} isOpen={visible} value={value} />
</div>
);
};

@ -0,0 +1,100 @@
import { useState, useEffect } from 'react';
import { Col, Label, Input, Modal, ModalBody, Form } from 'reactstrap';
import { faPlus, faArrowLeft } from '@fortawesome/free-solid-svg-icons';
import { useTranslation } from 'next-i18next';
import { ElectionTypes, useElection } from '@services/ElectionContext';
import Button from '@components/Button';
import { GradeItem } from '@services/type';
const GradeModal = ({ isOpen, toggle }) => {
const { t } = useTranslation();
const [election, dispatch] = useElection();
const [grade, setGrade] = useState<GradeItem>({
name: '',
description: '',
value: -1,
active: true,
});
useEffect(() => {
const maxValue = Math.max(...election.grades.map((g) => g.value));
setGrade({ ...grade, value: maxValue + 1 });
}, [election]);
const save = () => {
dispatch({
type: ElectionTypes.SET,
field: 'grades',
value: [...election.grades, grade],
});
toggle();
};
const handleName = (e) => {
setGrade((s) => ({ ...s, name: e.target.value }));
};
const names = election.grades.map((g) => g.name);
const disabled = grade.name === '' || names.includes(grade.name);
return (
<Modal
isOpen={isOpen}
toggle={toggle}
keyboard={true}
className="modal_grade"
>
<div className="modal-header p-4">
<h4 className="modal-title">{t('admin.add-grade')}</h4>
<button
type="button"
onClick={toggle}
className="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<ModalBody className="p-4">
<p>{t('admin.add-grade-desc')}</p>
<Col>
<Form className="container container-fluid">
<div className="mb-3">
<Label className="fw-bold">{t('common.name')} </Label>
<Input
type="text"
placeholder={t('admin.grade-name-placeholder')}
value={grade.name}
onChange={handleName}
autoFocus
required
/>
</div>
<div className="mt-5 gap-2 d-grid mb-3 d-md-flex">
<Button
onClick={toggle}
color="dark"
className="me-md-auto"
outline={true}
icon={faArrowLeft}
>
{t('common.cancel')}
</Button>
<Button
disabled={disabled}
color="primary"
role="submit"
onClick={save}
icon={faPlus}
>
{t('common.save')}
</Button>
</div>
</Form>
</Col>
</ModalBody>
</Modal>
);
};
export default GradeModal;

@ -0,0 +1,93 @@
import { useState, useEffect, MouseEvent, ChangeEvent } from 'react';
import { Col, Label, Input, Modal, ModalBody, Form } from 'reactstrap';
import { faPlus, faArrowLeft } from '@fortawesome/free-solid-svg-icons';
import { useTranslation } from 'next-i18next';
import { ElectionTypes, useElection } from '@services/ElectionContext';
import Button from '@components/Button';
const GradeModal = ({ isOpen, toggle, value }) => {
const { t } = useTranslation();
const [election, dispatch] = useElection();
const grade = election.grades.filter((g) => g.value === value)[0];
const [name, setName] = useState<string>(grade.name);
useEffect(() => {
setName(grade.name);
}, [grade]);
const handleSubmit = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
dispatch({
type: ElectionTypes.GRADE_SET,
position: election.grades.map((g) => g.value).indexOf(value),
field: 'name',
value: name,
});
toggle();
};
const handleName = (e: ChangeEvent<HTMLInputElement>) => {
setName(e.target.value);
};
return (
<Modal
isOpen={isOpen}
toggle={toggle}
keyboard={true}
className="modal_grade"
>
<div className="modal-header p-4">
<h4 className="modal-title">{t('admin.edit-grade')}</h4>
<button
type="button"
onClick={toggle}
className="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<ModalBody className="p-4">
<p>{t('admin.add-grade-desc')}</p>
<Col>
<Form className="container container-fluid">
<div className="mb-3">
<Label className="fw-bold">{t('common.name')} </Label>
<Input
type="text"
placeholder={t('admin.grade-name-placeholder')}
value={name}
onChange={handleName}
autoFocus
required
/>
</div>
<div className="mt-5 gap-2 d-grid mb-3 d-md-flex">
<Button
onClick={toggle}
color="dark"
className="me-md-auto"
outline={true}
icon={faArrowLeft}
>
{t('common.cancel')}
</Button>
<Button
color="primary"
onClick={handleSubmit}
icon={faPlus}
role="submit"
>
{t('common.save')}
</Button>
</div>
</Form>
</Col>
</ModalBody>
</Modal>
);
};
export default GradeModal;

@ -0,0 +1,126 @@
/**
* A field to update the grades
*/
import { useState, useEffect } from 'react';
import { useTranslation } from 'next-i18next';
import { Container, Row, Col } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlus } from '@fortawesome/free-solid-svg-icons';
import { DndContext } from '@dnd-kit/core';
import { arrayMove, SortableContext } from '@dnd-kit/sortable';
import { DEFAULT_GRADES } from '@services/constants';
import { ElectionTypes, useElection } from '@services/ElectionContext';
import GradeField from './GradeField';
import GradeModalAdd from './GradeModalAdd';
import { gradeColors } from '@services/grades';
import Switch from '@components/Switch';
const AddField = () => {
const { t } = useTranslation();
const [modal, setModal] = useState(false);
const toggle = () => setModal((m) => !m);
const [election, _] = useElection();
const numGrades = election.grades.filter((g) => g.active).length;
const disabled = numGrades >= gradeColors.length;
return (
<Row
role={disabled ? null : 'button'}
onClick={disabled ? null : toggle}
className={`${
disabled ? 'bg-light text-black-50' : 'bg-primary text-white'
} p-2 m-1 rounded-1`}
>
<Col className="col-auto">
<FontAwesomeIcon icon={faPlus} />
</Col>
<GradeModalAdd
key={election.grades.length}
isOpen={modal}
toggle={toggle}
/>
</Row>
);
};
const Grades = () => {
const { t } = useTranslation();
const [election, dispatch] = useElection();
const [visible, setVisible] = useState(false);
const toggle = () => setVisible((v) => !v);
useEffect(() => {
const defaultGrades = DEFAULT_GRADES.map((g, i) => ({
name: t(g),
value: DEFAULT_GRADES.length - 1 - i,
active: true,
}));
if (election.grades.length < 2) {
dispatch({
type: ElectionTypes.SET,
field: 'grades',
value: defaultGrades,
});
}
/*if (election.grades !== defaultGrades) {
setVisible(true);
}*/
}, []);
const handleDragEnd = (event) => {
/**
* Update the list of grades after dragging an item
*/
const { active, over } = event;
if (active.id !== over.id) {
const names = election.grades.map((g) => g.name);
const activeIdx = names.indexOf(active.id);
const overIdx = names.indexOf(over.id);
const newGrades = arrayMove(election.grades, activeIdx, overIdx);
newGrades.forEach((g, i) => (g.value = i));
dispatch({
type: ElectionTypes.SET,
field: 'grades',
value: newGrades,
});
}
};
return (
<Container className="bg-white p-3 p-md-4 mt-1">
<div className="d-flex justify-content-between">
<h5 className="mb-0 text-dark d-flex align-items-center">
{t('admin.grades-title')}
</h5>
<Switch toggle={toggle} state={visible} />
</div>
{visible && (
<>
<p className="text-muted">{t('admin.grades-desc')}</p>
<Row className="gx-1">
<DndContext onDragEnd={handleDragEnd}>
<SortableContext items={election.grades.map((g) => g.name)}>
{election.grades.map((grade, i) => (
<Col key={i} className="col col-auto">
<GradeField value={grade.value} />
</Col>
))}
<Col className="col col-auto">
<AddField />
</Col>
</SortableContext>
</DndContext>
</Row>
</>
)}
</Container>
);
};
export default Grades;

@ -0,0 +1,62 @@
import { useState } from 'react';
import { useTranslation } from 'next-i18next';
import { Container, Row, Col } from 'reactstrap';
import DatePicker from '@components/DatePicker';
import Switch from '@components/Switch';
import { ElectionTypes, useElection } from '@services/ElectionContext';
const LimitDate = () => {
const { t } = useTranslation();
const defaultEndDate = new Date();
defaultEndDate.setUTCDate(defaultEndDate.getUTCDate() + 15);
const [endDate, setEndDate] = useState(defaultEndDate);
const [election, dispatch] = useElection();
const hasDate = election.dateEnd !== null;
const toggle = () => {
dispatch({
type: ElectionTypes.SET,
field: 'dateEnd',
value: hasDate ? null : endDate,
});
};
const desc = t('admin.limit-duration-desc');
const now = new Date();
const oneDay = 24 * 60 * 60 * 1000;
const remainingDays = Math.ceil((endDate.getTime() - now.getTime()) / oneDay);
return (
<Container className="bg-white p-3 p-md-4 mt-1">
<div className="d-flex">
<div className="me-auto d-flex flex-row justify-content-center">
<h5 className="mb-0 text-dark d-flex align-items-center">
{t('admin.limit-duration')}
{hasDate ? (
<>
{' '}
<div className="badge text-bg-light text-black-50">
{`${t('admin.ending-in')} ${remainingDays} ${t(
'common.days'
)}`}
</div>{' '}
</>
) : null}
</h5>
{desc === '' ? null : <p className="text-muted">{desc}</p>}
</div>
<div className="col-auto d-flex align-items-center">
<Switch toggle={toggle} state={hasDate} />
</div>
</div>
{hasDate ? (
<div className="mt-3">
<DatePicker date={endDate} setDate={setEndDate} />
</div>
) : null}
</Container>
);
};
export default LimitDate;

@ -0,0 +1,41 @@
/**
* A field to set the order of candidates in the ballot vote.
*/
import { useTranslation } from 'next-i18next';
import { Container } from 'reactstrap';
import Switch from '@components/Switch';
import { ElectionTypes, useElection } from '@services/ElectionContext';
const Order = () => {
const { t } = useTranslation();
const [election, dispatch] = useElection();
const toggle = () => {
dispatch({
type: ElectionTypes.SET,
field: 'randomOrder',
value: !election.randomOrder,
});
};
return (
<>
<Container className="bg-white p-3 p-md-4 mt-1">
<div className="d-flex">
<div className="me-auto d-flex flex-column justify-content-center">
<h5 className="mb-0 text-dark d-flex align-items-center">
{t('admin.order-title')}
</h5>
<p className="text-muted d-none d-md-block">
{t('admin.order-desc')}
</p>
</div>
<Switch toggle={toggle} state={election.randomOrder} />
</div>
</Container>
</>
);
};
export default Order;

@ -0,0 +1,78 @@
import { useTranslation } from 'next-i18next';
import { Container } from 'reactstrap';
import { faArrowLeft, faArrowRight } from '@fortawesome/free-solid-svg-icons';
import Button from '@components/Button';
import Grades from './Grades';
import LimitDate from './LimitDate';
import AccessResults from './AccessResults';
import Order from './Order';
import Private from './Private';
import { useElection } from '@services/ElectionContext';
const ParamsField = ({ onSubmit }) => {
const { t } = useTranslation();
const [election, _] = useElection();
const checkDisability =
(election.restricted &&
(typeof election.emails === 'undefined' ||
election.emails.length === 0)) ||
election.grades.filter((g) => g.active).length < 2;
return (
<Container className="params d-flex flex-column flex-grow-1 my-5">
<Container className="px-0 d-md-none mb-5">
<div className="w-100 d-grid d-md-none mb-4">
<Button
outline={true}
color="secondary"
className="bg-blue"
onClick={onSubmit}
icon={faArrowLeft}
position="left"
>
{t('admin.candidates-back-step')}
</Button>
</div>
<h4>{t('admin.params-title')}</h4>
</Container>
<div className="d-flex flex-grow-1 flex-column justify-content-between">
<div className="d-flex flex-column">
<AccessResults />
<LimitDate />
<Grades />
<Order />
<Private />
</div>
<Container className="my-5 d-none d-md-flex justify-content-md-center">
<Button
outline={true}
color="secondary"
className="bg-blue"
onClick={onSubmit}
disabled={checkDisability}
icon={faArrowRight}
position="right"
>
{t('admin.params-submit')}
</Button>
</Container>
<Container className="my-5 d-grid justify-content-md-center d-md-none">
<Button
outline={true}
color="secondary"
className="bg-blue"
onClick={onSubmit}
disabled={checkDisability}
icon={faArrowRight}
position="right"
>
{t('admin.params-submit')}
</Button>
</Container>
</div>
</Container>
);
};
export default ParamsField;

@ -0,0 +1,99 @@
/**
* A field to set the privacy and add emails
*/
import { useTranslation } from 'next-i18next';
import { Container } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCircleInfo } from '@fortawesome/free-solid-svg-icons';
import Switch from '@components/Switch';
import ListInput from '@components/ListInput';
import { ElectionTypes, useElection } from '@services/ElectionContext';
import { validateMail } from '@services/mail';
import { AppTypes, useAppContext } from '@services/context';
const Private = () => {
const { t } = useTranslation();
const [_, dispatchApp] = useAppContext();
const [election, dispatch] = useElection();
const isCreating = !election.ref || election.ref === '';
const toggle = () => {
if (!isCreating) {
dispatchApp({
type: AppTypes.TOAST_ADD,
status: 'error',
message: t('error.cant-set-ongoing'),
});
return;
}
dispatch({
type: ElectionTypes.SET,
field: 'restricted',
value: !election.restricted,
});
};
const handleEmails = (emails: Array<string>) => {
dispatch({
type: ElectionTypes.SET,
field: 'emails',
value: emails,
});
};
return (
<>
<Container className="bg-white p-3 p-md-4 mt-1">
<div className="d-flex">
<div className="me-auto d-flex flex-column justify-content-center">
<h5 className="mb-0 text-dark d-flex align-items-center">
{t('admin.private-title')}
{election.restricted ? (
<>
{' '}
<div
className={`${
election.emails.length > 0 || !isCreating
? 'text-bg-light text-black-50'
: 'text-bg-danger text-white'
} badge ms-2`}
>
{`${election.emails.length} ${t('common.invites')}`}
</div>{' '}
</>
) : null}
</h5>
<p className="text-muted d-none d-md-block">
{isCreating
? t('admin.private-desc-creating')
: t('admin.private-desc-editing')}
</p>
</div>
<Switch toggle={toggle} state={election.restricted} />
</div>
{election.restricted ? (
<>
<ListInput
onEdit={handleEmails}
inputs={election.emails}
validator={validateMail}
/>
<div className="bg-light bt-3 p-2 text-muted fw-bold d-none d-md-flex align-items-center ">
<FontAwesomeIcon icon={faCircleInfo} />
<div className="ms-3">{t('admin.private-tip')}</div>
</div>
</>
) : null}
</Container>
{election.restricted ? (
<Container className="text-white d-md-none p-3">
{t('admin.access-results-desc')}
</Container>
) : null}
</>
);
};
export default Private;

@ -0,0 +1,31 @@
/**
* This component manages the title of the election
*/
import { useTranslation } from 'next-i18next';
import { useEffect, useState } from 'react';
import { Container } from 'reactstrap';
import { faPen } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import TitleModal from './TitleModal';
import { useElection } from '@services/ElectionContext';
const TitleField = ({ defaultName = '' }) => {
const { t } = useTranslation();
const [election, _] = useElection();
const [modal, setModal] = useState(false);
const toggle = () => setModal((m) => !m);
const name =
election.name && election.name != '' ? election.name : defaultName;
return (
<Container onClick={toggle} className="bg-white p-4">
<div className="w-100 text-dark d-flex justify-content-between">
<h5 className="me-2">{t('admin.confirm-question')}</h5>
<FontAwesomeIcon icon={faPen} />
</div>
<h4 className="text-primary">{name}</h4>
<TitleModal isOpen={modal} toggle={toggle} />
</Container>
);
};
export default TitleField;

@ -0,0 +1,124 @@
import { useState, useEffect, useRef, KeyboardEvent, ChangeEvent } from 'react';
import { Label, Modal, ModalBody, Form } from 'reactstrap';
import { faPlus, faArrowLeft } from '@fortawesome/free-solid-svg-icons';
import { useTranslation } from 'next-i18next';
import { ElectionTypes, useElection } from '@services/ElectionContext';
import Button from '@components/Button';
import { checkName } from '@services/ElectionContext';
import { AppTypes, useAppContext } from '@services/context';
import { useRouter } from 'next/router';
const TitleModal = ({ isOpen, toggle }) => {
const { t } = useTranslation();
const router = useRouter();
const [election, dispatch] = useElection();
const [_, dispatchApp] = useAppContext();
const [name, setName] = useState(election.name);
const disabled = name === '';
const inputRef = useRef(null);
useEffect(() => {
setName(election.name);
}, [election.name]);
useEffect(() => {
// When isOpen got active, we put the focus on the input field
setTimeout(() => {
console.log(inputRef.current);
if (isOpen && inputRef && inputRef.current) {
inputRef.current.focus();
}
}, 100);
}, [isOpen]);
const save = (e) => {
e.preventDefault();
if (name === '') {
dispatchApp({
type: AppTypes.TOAST_ADD,
status: 'error',
message: t('error.empty-name'),
});
return;
}
dispatch({
type: ElectionTypes.SET,
field: 'name',
value: name,
});
toggle();
};
// check if key down is enter
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key == 'Enter') {
save(e);
}
};
const handleName = (e) => {
setName(e.target.value);
};
return (
<Modal
isOpen={isOpen}
toggle={toggle}
keyboard={true}
className="modal_candidate"
>
<div className="modal-header p-4">
<h4 className="modal-title">{t('admin.set-title')}</h4>
<button
type="button"
onClick={toggle}
className="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<ModalBody className="p-4">
<Form className="container container-fluid" onKeyDown={handleKeyDown}>
<div className="mb-3">
<Label className="fw-bold">{t('common.name')} </Label>
<input
type="text"
placeholder={t('home.writeQuestion')}
value={name}
onChange={handleName}
required
className="form-control"
ref={inputRef}
/>
</div>
<div className="mt-5 gap-2 d-grid mb-3 d-md-flex">
<Button
onClick={toggle}
color="dark"
className="me-md-auto"
outline={true}
icon={faArrowLeft}
>
{t('common.cancel')}
</Button>
<div onClick={save}>
<Button
color={disabled ? 'light' : 'primary'}
disabled={disabled}
icon={faPlus}
>
{t('common.save')}
</Button>
</div>
</div>
</Form>
</ModalBody>
</Modal>
);
};
export default TitleModal;

@ -0,0 +1,51 @@
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 TrashButton = ({ className, label, onClick }) => {
const [visibled, setVisibility] = useState(false);
const { t } = useTranslation();
const toggle = () => setVisibility(!visibled);
return (
<div className="input-group-append cancelButton">
<FontAwesomeIcon onClick={toggle} icon={faTrashAlt} role="button" />
<Modal
isOpen={visibled}
toggle={toggle}
className="modal-dialog-centered cancelForm"
>
<ModalHeader>
<FontAwesomeIcon icon={faTrashAlt} />
</ModalHeader>
<ModalBody>
{t('Are you sure to delete')}
{<br />}
{label && label !== '' ? <b>{label}</b> : <>{t('the row')}</>}
</ModalBody>
<ModalFooter>
<Button type="button" className={className} onClick={toggle}>
<div className="annuler">
<img src="/arrow-dark-left.svg" /> {t('No')}
</div>
</Button>
<Button
className="new-btn-confirm"
onClick={() => {
toggle();
onClick();
}}
>
<FontAwesomeIcon icon={faTrashAlt} />
{t('Yes')}
</Button>
</ModalFooter>
</Modal>
</div>
);
};
export default TrashButton;

@ -0,0 +1,52 @@
import { useState } from 'react';
import { faCheck } 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 VoteButtonWithConfirm = ({ action }) => {
const [visibled, setVisibility] = useState(false);
const { t } = useTranslation();
const toggle = () => setVisibility(!visibled);
return (
<div className="input-group-append cancelButton">
<button
type="button"
className="btn btn-transparent my-3 "
onClick={toggle}
>
<div className="annuler">
<FontAwesomeIcon icon={faCheck} className="my-auto" />
{t('Submit my vote')}
</div>
</button>
<Modal isOpen={visibled} toggle={toggle} className="noRateVote">
<ModalHeader>
{t('Attention vous navez pas votez pour tous les candidats')}
</ModalHeader>
<ModalBody>
{t(
'Si vous validez votre vote, les candidats sans vote auront la mention la plus basse du scrutin.'
)}
<Button
className="addButton warningVote my-4"
onClick={() => {
action();
}}
>
{t('Validez mon vote')}
<img src="/arrow-white.svg" />
</Button>
<Button className="removeButton backToVoteBtn my-4" onClick={toggle}>
{t('Revenir au vote')}
</Button>
</ModalBody>
</Modal>
</div>
);
};
export default VoteButtonWithConfirm;

@ -0,0 +1,59 @@
import { useState } from 'react';
import { Container } from 'reactstrap';
import { useBallot } from '@services/BallotContext';
import CandidateCard from '@components/ballot/CandidateCard';
import TitleBar from '@components/ballot/TitleBar';
import GradeInput from '@components/ballot/GradeInput';
import { CandidatePayload } from '@services/api';
import CandidateModal from '@components/CandidateModalGet';
const BallotDesktop = () => {
const [ballot, dispatch] = useBallot();
const numGrades = ballot.election.grades.length;
const disabled = ballot.votes.length !== ballot.election.candidates.length;
const [candidate, setCandidate] = useState<CandidatePayload | null>(null);
return (
<div className="w-100 h-100 d-none d-md-block">
<TitleBar election={ballot.election} />
<Container
className="w-100 h-100 d-flex flex-column justify-content-center align-items-center"
style={{ maxWidth: '1050px' }}
>
<h1 className="mb-5">{ballot.election.name}</h1>
{ballot.election.candidates.map((candidate, candidateId) => {
return (
<div
key={candidateId}
className="bg-white justify-content-between d-flex my-2 py-2 w-100 px-3"
>
<CandidateCard
onClick={() => setCandidate(candidate)}
candidate={candidate}
/>
<div className="d-flex">
{ballot.election.grades.map((_, gradeId) => {
console.assert(gradeId < numGrades);
return (
<GradeInput
key={gradeId}
gradeId={gradeId}
candidateId={candidateId}
/>
);
})}
</div>
</div>
);
})}
<CandidateModal
isOpen={candidate !== null}
toggle={() => setCandidate(null)}
candidate={candidate}
/>
</Container>
</div>
);
};
export default BallotDesktop;

@ -0,0 +1,79 @@
import {useState} from 'react'
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faChevronLeft, faChevronRight} from '@fortawesome/free-solid-svg-icons';
import {useBallot} from '@services/BallotContext';
import CandidateCard from '@components/ballot/CandidateCard'
import GradeInput from '@components/ballot/GradeInput'
import {CandidatePayload} from '@services/api';
import CandidateModal from '@components/CandidateModalGet';
interface TitleInterface {
name: string;
}
const TitleName = ({name}: TitleInterface) => {
return (
<div className="fw-bold text-white w-75 p-4">
{name}
</div>
)
}
const BallotMobile = () => {
const [ballot, _] = useBallot();
const [offset, setOffset] = useState(0);
const numGrades = ballot.election.grades.length;
const [candidate, setCandidate] = useState<CandidatePayload | null>(null);
const moveRight = (right: boolean) => {
if (right) setOffset(o => o - 247);
else setOffset(o => o + 247);
}
return (
<div className="d-block d-md-none">
<TitleName name={ballot.election.name} />
<div className="w-100 d-flex">
{ballot.election.candidates.map((candidate, candidateId) => {
return (
<div key={candidateId} className="bg-white flex-column d-flex my-4 mx-2 py-4 px-3 candidate-vote" style={{"left": offset === 0 ? 0 : offset + 30}}>
<div className="d-flex align-items-center mb-1">
{candidateId !== 0 ?
<div className="me-2"
onClick={() => moveRight(false)}
>
<FontAwesomeIcon color="#0A004C" icon={faChevronLeft} />
</div> : null}
<CandidateCard
onClick={() => setCandidate(candidate)}
candidate={candidate}
/>
{candidateId !== ballot.election.candidates.length - 1 ?
<div className="ms-2" onClick={() => moveRight(true)}><FontAwesomeIcon color="#0A004C" icon={faChevronRight} /></div> : null}
</div>
<div className="d-flex mt-2 flex-column">
{ballot.election.grades.map((_, gradeId) => {
console.assert(gradeId < numGrades);
return (
<GradeInput
key={gradeId}
gradeId={gradeId}
candidateId={candidateId}
/>
);
})}
</div>
</div>
);
})}
<CandidateModal isOpen={candidate !== null} toggle={() => setCandidate(null)} candidate={candidate} />
</div>
</div >
)
}
export default BallotMobile

@ -0,0 +1,32 @@
import Image from 'next/image';
import {useTranslation} from 'next-i18next';
import defaultAvatar from '../../public/avatarBlue.svg';
import {CandidatePayload} from '@services/api';
import {MouseEventHandler} from 'react';
interface CandidateCardInterface {
candidate: CandidatePayload;
onClick: MouseEventHandler;
}
const CandidateCard = ({candidate, onClick}: CandidateCardInterface) => {
const {t} = useTranslation();
return (<div
onClick={onClick}
className="d-flex align-items-center flex-fill">
<Image
src={defaultAvatar}
width={32}
height={32}
className="bg-light"
alt={t('common.thumbnail')}
/>
<div className="d-flex lh-sm flex-column justify-content-center ps-3">
<span className="text-black fs-5 m-0 ">{candidate.name}</span>
<br />
<span className="text-muted fs-6 m-0 fw-normal">{t("vote.more-details")}</span>
</div>
</div>)
}
export default CandidateCard;

@ -0,0 +1,60 @@
import {useState, useCallback, useEffect, MouseEvent} from 'react';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faCheck} from '@fortawesome/free-solid-svg-icons';
import {useBallot, BallotTypes} from '@services/BallotContext';
import {getGradeColor} from '@services/grades';
const GradeName = ({name, active}) => {
return (<>
{active ?
<div className="me-3">
<FontAwesomeIcon icon={faCheck} />
</div> : null}
<div className="">
{name}
</div>
</>)
}
interface GradeInputInterface {
gradeId: number;
candidateId: number;
}
const GradeInput = ({gradeId, candidateId}: GradeInputInterface) => {
const [ballot, dispatch] = useBallot();
if (!ballot) {throw Error("Ensure the election is loaded")}
const grade = ballot.election.grades[gradeId];
const numGrades = ballot.election.grades.length;
const handleClick = (event: MouseEvent<HTMLInputElement>) => {
dispatch({type: BallotTypes.VOTE, candidateId: candidateId, gradeId: gradeId})
};
const active = ballot.votes.some(b => b.gradeId === gradeId && b.candidateId === candidateId)
const color = active ? getGradeColor(gradeId, numGrades) : '#C3BFD8';
return (<>
<div
className={`justify-content-center d-none d-md-flex my-1 rounded-1 px-2 py-1 fs-5 text-white ms-3`}
onClick={handleClick}
style={{backgroundColor: color, boxShadow: active ? `0px 2px 0px ${color}` : "0px 2px 0px #8F88BA"}}
>
<GradeName name={grade.name} active={active} />
</div >
<div
className={`d-flex d-md-none my-1 justify-content-center py-2 text-white`}
onClick={handleClick}
style={{
backgroundColor: color,
boxShadow: active ? `0px 2px 0px ${color}` : "0px 2px 0px #8F88BA",
width: "200px"
}}
>
<GradeName name={grade.name} active={active} />
</div >
</>
)
}
export default GradeInput

@ -0,0 +1,39 @@
import {useRouter} from 'next/router';
import Button from '@components/Button';
import {useTranslation} from 'next-i18next';
import {getElection, castBallot, apiErrors, ElectionPayload, CandidatePayload, GradePayload} from '@services/api';
import {getLocaleShort} from '@services/utils';
import {faCalendarDays} from '@fortawesome/free-solid-svg-icons';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
interface TitleBarInterface {
election: ElectionPayload;
}
const TitleBar = ({election}: TitleBarInterface) => {
const {t} = useTranslation();
const router = useRouter();
const locale = getLocaleShort(router);
const dateEnd = new Date(election.date_end);
const farAway = new Date();
farAway.setFullYear(farAway.getFullYear() + 1);
const isFarAway = +dateEnd > +farAway;
if (!isFarAway) {
return (
<div className="w-100 bg-light p-2 text-black justify-content-center d-flex ">
<div className="me-2">
<FontAwesomeIcon icon={faCalendarDays} />
</div>
<div>
{` ${t("vote.open-until")} ${new Date(election.date_end).toLocaleDateString(locale, {dateStyle: "long"})}`}
</div>
</div>
)
} else {
return null;
}
};
export default TitleBar

@ -1,29 +0,0 @@
/* 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

@ -1,25 +0,0 @@
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;

@ -1,24 +0,0 @@
/* 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;

@ -1,44 +0,0 @@
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;

@ -1,4 +0,0 @@
import * as React from "react";
import FlagIconFactory from "react-flag-icon-css";
export const FlagIcon = FlagIconFactory(React, { useCssModules: false });

@ -1,57 +0,0 @@
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>&quot;{label}&quot;</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;

@ -1,55 +0,0 @@
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

@ -1,128 +0,0 @@
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

@ -1,164 +0,0 @@
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

@ -1,75 +0,0 @@
/* 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;

@ -1,94 +0,0 @@
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("resource.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,94 @@
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next';
import { Button, Row, Col } from 'reactstrap';
import Logo from '@components/Logo';
import LanguageSelector from '@components/layouts/LanguageSelector';
import { useAppContext } from '@services/context';
import {
MAJORITY_JUDGMENT_LINK,
NEWS_LINK,
PAYPAL,
WHO_WE_ARE_LINK,
} from '@services/constants';
import { getUrl, RouteTypes } from '@services/routes';
const Footer = () => {
const { t } = useTranslation();
const router = useRouter();
const [app, _] = useAppContext();
if (app.fullPage) {
return null;
}
const menu = [
{
component: <Logo title={false} />,
},
{
component: (
<a
href={MAJORITY_JUDGMENT_LINK}
target="_blank"
rel="noopener noreferrer"
>
{t('menu.majority-judgment')}
</a>
),
},
{
component: (
<a href={WHO_WE_ARE_LINK} target="_blank" rel="noopener noreferrer">
{t('menu.whoarewe')}
</a>
),
},
{
component: (
<Link href={getUrl(RouteTypes.FAQ, router)}>{t('menu.faq')}</Link>
),
},
{
component: (
<a target="_blank" rel="noopener noreferrer" href={NEWS_LINK}>
{t('menu.news')}
</a>
),
},
{
component: (
<a href="mailto:app@mieuxvoter.fr?subject=[HELP]">
{t('menu.contact-us')}
</a>
),
},
{
component: <LanguageSelector selectedSize={14} />,
},
];
return (
<footer>
<Row>
<Col className="col-auto me-auto">
<Row className="gx-3">
{menu.map((item, i) => (
<Col key={i} className="col-auto d-flex align-items-center">
{item.component}
</Col>
))}
</Row>
</Col>
<Col className="col-auto">
<a href={PAYPAL}>
<Button outline={false} color="info" className="noshadow">
{t('common.support-us')}
</Button>
</a>
</Col>
</Row>
</footer>
);
};
export default Footer;

@ -1,61 +0,0 @@
/* 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,151 @@
/* eslint react/prop-types: 0 */
import { useState } from 'react';
import { Collapse, Nav, NavItem, Button } from 'reactstrap';
import Link from 'next/link';
import Image from 'next/image';
import { useTranslation } from 'next-i18next';
import { useAppContext } from '@services/context';
import LanguageSelector from './LanguageSelector';
import openMenuIcon from '../../public/open-menu-icon.svg';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faXmark } from '@fortawesome/free-solid-svg-icons';
import {
CONTACT_MAIL,
MAJORITY_JUDGMENT_LINK,
NEWS_LINK,
PAYPAL,
WHO_WE_ARE_LINK,
} from '@services/constants';
import ShareRow from '@components/Share';
import { getUrl, RouteTypes } from '@services/routes';
import { useRouter } from 'next/router';
import Logo from '@components/Logo';
const Header = () => {
const [isOpen, setOpen] = useState(false);
const router = useRouter();
const { t } = useTranslation();
const [app, _] = useAppContext();
const toggle = () => setOpen(!isOpen);
if (app.fullPage) {
return null;
}
const menu = [
{
component: (
<a
href={MAJORITY_JUDGMENT_LINK}
target="_blank"
rel="noopener noreferrer"
className="navbar-my-link nav-link"
>
{t('menu.majority-judgment')}
</a>
),
},
{
component: (
<a
href={WHO_WE_ARE_LINK}
target="_blank"
rel="noopener noreferrer"
className="navbar-my-link nav-link"
>
{t('menu.whoarewe')}
</a>
),
},
{
component: (
<Link
href={getUrl(RouteTypes.FAQ, router)}
className="navbar-my-link nav-link"
>
{t('menu.faq')}
</Link>
),
},
{
component: (
<a
target="_blank"
rel="noopener noreferrer"
href={NEWS_LINK}
className="navbar-my-link nav-link"
>
{t('menu.news')}
</a>
),
},
{
component: (
<a
href="mailto:app@mieuxvoter.fr?subject=[HELP]"
className="navbar-my-link nav-link"
>
{t('menu.contact-us')}
</a>
),
},
{
component: <LanguageSelector selectedSize={24} />,
},
];
return (
<>
<Image
onClick={toggle}
role="button"
className="btn_menu d-md-none"
src={openMenuIcon}
alt="open menu icon"
height="50"
width="50"
/>
<Collapse isOpen={isOpen} navbar>
<Nav className="navbar-nav-scroll" navbar>
<div className="d-flex flex-column min-vh-100 justify-content-between">
<div>
<div className="d-flex flex-row justify-content-between align-items-center nav-logo">
<Link href="/" className="navbar-brand navbar-brand-mobile">
<img src="/logos/logo.svg" alt="logo" height="80" />
</Link>
<FontAwesomeIcon
icon={faXmark}
onClick={toggle}
size="2x"
role="button"
className="navbar-toggle navbar-close-button"
/>
</div>
<div>
{menu.map((item, i) => (
<NavItem key={i}>{item.component}</NavItem>
))}
</div>
<hr className="my-3" />
</div>
<NavItem className="d-flex w-100 align-items-center flex-column">
<a href={PAYPAL} target="_blank" rel="noreferrer noopener">
<Button color="primary" className="text-white">
{t('common.support-us')}
</Button>
</a>
<ShareRow />
</NavItem>
</div>
</Nav>
</Collapse>
</>
);
};
export default Header;

@ -1,32 +0,0 @@
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,32 @@
import { useRouter } from 'next/router';
import ReactFlagsSelect from 'react-flags-select';
import { getLocaleShort } from '@services/utils';
const LanguageSelector = (props) => {
const router = useRouter();
const localeShort = getLocaleShort(router);
const selectHandler = (e) => {
let locale = e.toLowerCase();
if (locale === 'gb') locale = 'en';
const { pathname, asPath, query } = router;
// change just the locale and maintain all other route information including href's query
router.push({ pathname, query }, asPath, { locale });
};
return (
<ReactFlagsSelect
onSelect={selectHandler}
countries={
// ["GB", "FR", "ES", "DE", "RU"]
['GB', 'FR']
}
selected={localeShort == 'en' ? 'GB' : localeShort.toUpperCase()}
customLabels={{ GB: 'English', FR: 'Francais' }}
{...props}
className="menu-flags"
/>
);
};
export default LanguageSelector;

@ -1,20 +0,0 @@
/* 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];
};

@ -1,14 +0,0 @@
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;

@ -1,8 +0,0 @@
import React from "react";
import Loader from "../loader";
const Wait = () => {
return <Loader />;
};
export default Wait;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

@ -0,0 +1,31 @@
import {InitOptions} from "i18next";
// import all namespaces (for the default language, only)
import emailEn from "../public/locales/en/email.json";
import emailFr from "../public/locales/fr/email.json";
import resourceEn from "../public/locales/en/resource.json";
import resourceFr from "../public/locales/fr/resource.json";
export const defaultNS = "email";
export const resources = {
en: {
email: emailEn,
resource: resourceEn,
},
fr: {
email: emailFr,
resource: resourceFr,
},
} as const;
export const availableLanguages = Object.keys(resources);
export const i18n: InitOptions = {
// https://www.i18next.com/overview/configuration-options#logging
debug: process.env.NODE_ENV === 'development',
ns: ["resource", "email"],
resources,
defaultNS: defaultNS,
}

@ -70,7 +70,9 @@
<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;">Nous sommes très heureux de vous partager ce lien de vote ! Vous allez pouvoir voter avec le jugement majoritaire.</div>
<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.admin.happy' }}{{/i18n}}
</div>
<table border="0" style="margin: 0px auto 0px auto; width: 100%;" aria-describedby="Email">
<!-- LOGO -->
<tr>
@ -79,7 +81,7 @@
<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">
<img alt="Logo" src="https://app.mieuxvoter.fr/logos/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>
@ -92,7 +94,7 @@
<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;">Bonjour ! 🙂</h1>
<h1 style="font-size: 48px; font-weight: 400; margin: 0;">{{#i18n 'email.hello'}}Hi, there! 🙂{{/i18n}}</h1>
</th>
</tr>
</table>
@ -105,39 +107,18 @@
<!-- 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;">Nous sommes très heureux de vous partager ce lien de vote ! Vous allez pouvoir voter avec le jugement majoritaire.</p>
<p style="margin: 0; text-align: left;">
{{#i18n 'email.admin.happy'}}{{/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;">Vous avez été invité·e à participer à l'élection suivante : </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="%recipient.urlVote%" 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;">Voter !</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;">
Si le lien ne fonctionne pas, vous pouvez le copier et le coller dans la barre de navigation de votre navigateur.
{{#i18n 'email.admin.why'}}{{/i18n}}
&nbsp;
<a target="_blank" style="color: #2a43a0;">%recipient.urlVote%</a>
<strong>%recipient.title%</strong>
</p>
</th>
</tr>
@ -145,16 +126,16 @@
<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;">
A la fin de l'élection, vous pourrez accéder aux résultats en cliquant sur ce lien :
&nbsp;
<a target="_blank" style="color: #2a43a0;">%recipient.urlResult%</a>
{{#i18n 'email.admin.linkAdmin' }}{{/i18n}}
&nbsp;
<a target="_blank" style="color: #2a43a0;">%recipient.urlAdmin%</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;">Bon vote,<br>Mieux Voter</p>
<p style="margin: 0; text-align: left;">{{#i18n 'email.bye'}}{{/i18n}},<br>{{#i18n 'resource:common.better-vote'}}{{/i18n}}</p>
</th>
</tr>
</table>
@ -168,10 +149,14 @@
<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">Besoin de plus d'information</a></strong>
<a href="https://mieuxvoter.fr/le-jugement-majoritaire" target="_blank" style="color: #FFFFFF;" rel="noopener noreferrer">
{{#i18n 'email.about-mj'}}{{/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">Vous souhaitez nous aider ?</a></strong>
<a href="https://www.paypal.com/donate/?hosted_button_id=QD6U4D323WV4S" target="_blank" style="color: #111111;" rel="noopener noreferrer">
{{#i18n 'resource:common.support-us'}}{{/i18n}}
</a></strong>
</p>
</th>
</tr>
@ -183,16 +168,18 @@
<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 />
</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;">Vous avez été invité·e à participer à l'élection suivante</p>
<p style="margin: 0;">
{{#i18n email.why }}{{/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;">Mieux Voter - <a "mailto:app@mieuxvoter.fr">app@mieuxvoter.fr</a></p>
<p style="margin: 0;">{{#i18n resource:common.better-vote }}{{/i18n}} - <a "mailto:{{ from_email_address }}">{{ from_email_address }}</a></p>
</th>
</tr>
</table>

@ -0,0 +1,15 @@
{{#i18n 'email.hello'}}{{/i18n}}
{{#i18n 'email.admin.happy'}}{{/i18n}}
{{#i18n 'email.admin.why'}}{{/i18n}}
%recipient.title%
{{#i18n 'email.admin.linkAdmin' }}{{/i18n}}
%recipient.urlAdmin%
{{#i18n 'email.bye'}}{{/i18n}}
{{#i18n 'resource:common.better-vote'}}{{/i18n}}

@ -0,0 +1,148 @@
import fs from 'fs';
import {Handler} from "@netlify/functions";
import formData from 'form-data';
import Mailgun from 'mailgun.js';
import Handlebars from 'handlebars';
import {i18n} from '../i18next';
import i18next from 'i18next';
import {MailgunMessageData, MessagesSendResult} from "mailgun.js/interfaces/Messages.js";
const {
MAILGUN_API_KEY,
MAILGUN_DOMAIN,
MAILGUN_URL,
FROM_EMAIL_ADDRESS,
REPLY_TO_EMAIL_ADDRESS,
} = process.env;
const mailgun = new Mailgun(formData);
const mg = mailgun.client({
username: 'api',
key: MAILGUN_API_KEY,
url: MAILGUN_URL
});
i18next.init(i18n, (err, t) => {
if (err) return console.log('something went wrong loading', err);
t("foo");
});
Handlebars.registerHelper('i18n',
(str: string): string => {
return (i18next != undefined ? i18next.t(str) : str);
}
);
interface RequestPayload {
recipients: {[email: string]: {[key: string]: string}};
locale: string;
action: "invite" | "admin";
}
const handler: Handler = async (event) => {
/**
* Send a mail using Mailgun
*/
if (event.httpMethod !== 'POST') {
return {
statusCode: 405,
body: 'Method Not Allowed',
headers: {Allow: 'POST'},
};
}
const {recipients, action, locale} = JSON.parse(event.body) as RequestPayload;
if (!recipients) {
return {
statusCode: 422,
body: 'The list of recipients is missing.',
};
}
if (!action || !["invite", "admin"].includes(action)) {
return {
statusCode: 422,
body: 'Unknown action.',
};
}
if (!locale || !["fr", "en"].includes(locale)) {
return {
statusCode: 422,
body: 'Unknown locale.',
};
}
const err = await i18next.changeLanguage(locale, (err, t) => {
if (err) return {"error": err}
});
if (err && err["error"]) {
return {statusCode: 200, ...err["error"]}
}
const templates = ["txt", "html"].map(ext =>
fs.readFileSync(`${__dirname}/${action}.${ext}`).toString());
const contents = templates.map(tpl => Handlebars.compile(tpl)({from_email_address: FROM_EMAIL_ADDRESS}));
const payload: MailgunMessageData = {
// from: `${i18next.t("Mieux Voter")} <mailgun@>`,
from: FROM_EMAIL_ADDRESS || '"Mieux Voter" <postmaster@mg.app.mieuxvoter.fr>',
to: Object.keys(recipients),
subject: i18next.t(`email.${action}.subject`),
txt: contents[0],
html: contents[1],
'h:Reply-To': REPLY_TO_EMAIL_ADDRESS || 'app@mieuxvoter.fr',
'o:tag': action,
'o:require-tls': true,
'o:dkim': true,
'recipient-variables': JSON.stringify(recipients),
};
try {
const res: MessagesSendResult = await mg.messages.create(MAILGUN_DOMAIN, payload)
if (res.status != 200) {
return {
statusCode: res.status,
body: JSON.stringify(
{
"message": res.message,
"details": res.details,
}
)
}
}
return {
statusCode: 200,
body: JSON.stringify(
{
"message": res.message,
"details": res.details,
"status": "200",
"results": `${Object.keys(recipients).length} emails were sent.`,
}
)
}
} catch (error) {
return {
statusCode: 422,
body: JSON.stringify(
{
"status": "423",
...error
}
)
}
};
};
export {handler};

@ -71,7 +71,7 @@
<!-- 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}}
{{#i18n 'email.invite.happy' }}{{/i18n}}
</div>
<table border="0" style="margin: 0px auto 0px auto; width: 100%;" aria-describedby="Email">
<!-- LOGO -->
@ -81,7 +81,7 @@
<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">
<img alt="Logo" src="https://app.mieuxvoter.fr/logos/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>
@ -108,7 +108,7 @@
<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}}
{{#i18n 'email.invite.happy'}}{{/i18n}}
</p>
</th>
</tr>
@ -116,9 +116,9 @@
<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}}
{{#i18n 'email.invite.why'}}{{/i18n}}
&nbsp;
<strong>{{title}}</strong>
<strong>%recipient.title%</strong>
</p>
</th>
</tr>
@ -132,7 +132,7 @@
<tr>
<th scope="col" style="border-radius: 3px; background-color: #2a43a0;">
<a href="%recipient.urlVote%" 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>
{{#i18n 'resource:common.vote' }}Vote!{{/i18n}}</a></th>
</tr>
</table>
</th>
@ -144,9 +144,14 @@
<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}}
{{#i18n 'email.copyLink' }}{{/i18n}}
&nbsp;
<a target="_blank" style="color: #2a43a0;">%recipient.urlVote%</a>
<a href="%recipient.urlVote%"
target="_blank"
style="color: #2a43a0;"
>
%recipient.urlVote%
</a>
</p>
</th>
</tr>
@ -154,16 +159,21 @@
<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}}
{{#i18n 'email.invite.linkResult' }}{{/i18n}}
&nbsp;
<a target="_blank" style="color: #2a43a0;">%recipient.urlResult%</a>
<a href="%recipient.urlResult%"
target="_blank"
style="color: #2a43a0;"
>
%recipient.urlResult%
</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>
<p style="margin: 0; text-align: left;">{{#i18n 'email.bye'}}{{/i18n}}<br>{{#i18n 'resource:common.better-vote'}}{{/i18n}}</p>
</th>
</tr>
</table>
@ -177,13 +187,13 @@
<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 href="https://mieuxvoter.fr/le-jugement-majoritaire" target="_blank" style="color: #FFFFFF;" rel="noopener noreferrer">
{{#i18n 'email.about-mj'}}{{/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 href="https://www.paypal.com/donate/?hosted_button_id=QD6U4D323WV4S" target="_blank" style="color: #111111;" rel="noopener noreferrer">
{{#i18n 'resource:common.support-us'}}{{/i18n}}
</a></strong>
</p>
</th>
@ -200,14 +210,14 @@
<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}}
{{#i18n email.why }}{{/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>
<p style="margin: 0;">{{#i18n resource:common.better-vote }}{{/i18n}} - <a "mailto:{{ from_email_address }}">{{ from_email_address }}</a></p>
</th>
</tr>
</table>

@ -0,0 +1,19 @@
{{#i18n 'email.hello'}}{{/i18n}}
{{#i18n 'email.invite.happy'}}{{/i18n}}
{{#i18n 'email.invite.why'}}{{/i18n}}
%recipient.title%
{{#i18n 'email.invite.linkVote' }}{{/i18n}}
%recipient.urlVote%
{{#i18n 'email.invite.linkResult' }}{{/i18n}}
%recipient.urlResult%
{{#i18n 'email.bye'}}{{/i18n}}
{{#i18n 'resource:common.better-vote'}}{{/i18n}}

@ -1,218 +0,0 @@
<!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}}
&nbsp;
<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="%recipient.urlVote%" 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}}
&nbsp;
<a target="_blank" style="color: #2a43a0;">%recipient.urlVote%</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}}
&nbsp;
<a target="_blank" style="color: #2a43a0;">%recipient.urlResult%</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>

@ -1,19 +0,0 @@
{{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}}

@ -1,17 +0,0 @@
Bonjour ! 🙂
Vous avez été invité·e à participer à l'élection suivante :
{{ title }}
Le lien pour voter est le suivant :
%recipient.urlVote%
A la fin de l'élection, vous pourrez accéder aux résultats en cliquant sur ce lien :
%recipient.urlResult%
Bon vote ! 🤗
Mieux Voter

@ -1,19 +0,0 @@
{{#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}}

@ -1,150 +0,0 @@
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: "fr",
// ns: ["emailInvite", "common"],
// defaultNS: "emailInvite",
// fallbackNS: "common",
// debug: false,
// fallbackLng: ["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 txtStr = {
en: fs.readFileSync(__dirname + "/invite-en.txt").toString(),
fr: fs.readFileSync(__dirname + "/invite-fr.txt").toString(),
};
const txtTemplate = {
en: Handlebars.compile(txtStr.en),
fr: Handlebars.compile(txtStr.fr),
};
const htmlStr = {
en: fs.readFileSync(__dirname + "/invite-en.html").toString(),
fr: fs.readFileSync(__dirname + "/invite-fr.html").toString(),
};
const htmlTemplate = {
en: Handlebars.compile(htmlStr.en),
fr: Handlebars.compile(htmlStr.fr),
};
const test = Handlebars.compile("test");
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 || !data.locale) {
return {
statusCode: 422,
body: "Recipient variables and title are required.",
};
}
// i18next.changeLanguage(data.locale);
const templateData = {
title: data.title,
};
const mailgunData = {
// from: `${i18next.t("Mieux Voter")} <mailgun@mg.app.mieuxvoter.fr>`,
from: '"Mieux Voter" <postmaster@mg.app.mieuxvoter.fr>',
to: Object.keys(data.recipientVariables),
text: txtTemplate.fr(templateData),
html: htmlTemplate.fr(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) => {
return success;
}) // logs response data
.catch((err) => {
console.log(err);
return success;
}); // logs any error
return res;
};
exports.handler = sendMail;

@ -21,7 +21,7 @@ module.exports = {
i18nKey: "i18nKey",
defaultsKey: "defaults",
extensions: [".js", ".jsx"],
fallbackKey: function(ns, value) {
fallbackKey: function (ns, value) {
return value;
},
acorn: {
@ -31,7 +31,7 @@ module.exports = {
}
},
lngs: ["en", "fr", "es", "de", "ru"],
ns: ["resource", "common"],
ns: ["resource"],
defaultLng: "en",
defaultNs: "resource",
defaultValue: "__STRING_NOT_TRANSLATED__",
@ -56,7 +56,7 @@ module.exports = {
parser.parseFuncFromString(
content,
{ list: ["i18next._", "i18next.__"] },
{list: ["i18next._", "i18next.__"]},
(key, options) => {
parser.set(
key,
@ -69,14 +69,6 @@ module.exports = {
}
);
if (count > 0) {
/* console.log(
`i18next-scanner: count=${chalk.cyan(count)}, file=${chalk.yellow(
JSON.stringify(file.relative)
)}`
);*/
}
done();
}
};

@ -0,0 +1,32 @@
basepath = "."
locales = [
"en",
"de",
"ru",
"es",
"fr",
]
[[paths]]
reference = "translate/public/static/locale/en-US/*.ftl"
l10n = "translate/public/static/locale/{locale}/*.ftl"
[[project]]
name = "Mieux Voter Front End"
description = """Front-end based on React for Better Vote (Mieux Voter)"""
[[project.import]]
repository = "https://github.com/MieuxVoter/mv-front-react"
revision = "default"
path = "l10n.toml"
vcs = "git"
[[project.metadata]]
priority = 5
locales = ["fr", "en"]
[[project.metadata]]
locales = ["de", "es", "ru"]
priority = 1

@ -1,7 +1,11 @@
[build]
command = "npm run build"
publish = "out"
publish = ".next"
functions = "functions"
[dev]
command = "npm run dev"
[functions]
included_files = ["functions/**"]

5
next-env.d.ts vendored

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

@ -1,9 +1,38 @@
module.exports = {
// https://www.i18next.com/overview/configuration-options#logging
debug: process.env.NODE_ENV === 'development',
i18n: {
defaultLocale: "fr",
locales: ["en", "fr", "de", "es", "ru"],
ns: ["resource", "common", "error"],
defaultNS: "resource",
fallbackNS: ["common", "error"],
defaultLocale: 'en',
locales: ['en', 'fr'],
},
ns: ['resource'],
defaultNS: 'resource',
defaultValue: '__STRING_NOT_TRANSLATED__',
/** To avoid issues when deploying to some paas (vercel...) */
localePath:
typeof window === 'undefined'
? require('path').resolve('./public/locales')
: '/locales',
reloadOnPrerender: process.env.NODE_ENV === 'development',
/**
* @link https://github.com/i18next/next-i18next#6-advanced-configuration
*/
// saveMissing: false,
// strictMode: true,
// serializeConfig: false,
// react: { useSuspense: false }
};
// const path = require('path')
// module.exports = {
// i18n: {
// defaultLocale: "fr",
// locales: ["en", "fr"],
// },
// localePath: path.resolve('./public/locales'),
// // react: {
// // useSuspense: false,
// // wait: true
// // }
// };

@ -1,45 +1,18 @@
const { i18n } = require("./next-i18next.config");
const { i18n } = require('./next-i18next.config.js');
const remoteImage = process.env.IMGPUSH_URL
? process.env.IMGPUSH_URL.split('/')[-1]
: 'imgpush.mieuxvoter.fr';
module.exports = {
i18n,
// See https://github.com/netlify/netlify-plugin-nextjs/issues/223
unstableNetlifyFunctionsSupport: {
"pages/index.jsx": {
includeDirs: ["public"],
},
"pages/faq.jsx": {
includeDirs: ["public"],
},
"pages/legal-notices.jsx": {
includeDirs: ["public"],
},
"pages/new/confirm/[pid].jsx": {
includeDirs: ["public"],
},
"pages/new.jsx": {
includeDirs: ["public"],
},
"pages/result/[pid]/[[...tid]].jsx": {
includeDirs: ["public"],
},
"pages/vote/[pid]/[[...tid]].jsx": {
includeDirs: ["public"],
},
"pages/vote/[pid]/confirm.jsx": {
includeDirs: ["public"],
},
"pages/privacy-policy.jsx": {
includeDirs: ["public"],
},
},
pageExtensions: ["mdx", "jsx", "js", "ts", "tsx"],
webpack(config) {
config.module.rules.push({
test: /\.svg$/,
use: ["@svgr/webpack"],
});
return config;
images: {
remotePatterns: [
{
protocol: 'https',
hostname: remoteImage,
pathname: '**',
},
],
},
target: "experimental-serverless-trace",
};

10739
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -1,46 +1,63 @@
{
"name": "mv-front-react",
"version": "1.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"export": "next export"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^5.15.3",
"@fortawesome/fontawesome-svg-core": "^1.2.35",
"@fortawesome/free-brands-svg-icons": "^5.15.3",
"@fortawesome/free-solid-svg-icons": "^5.15.3",
"@fortawesome/react-fontawesome": "^0.1.14",
"@svgr/webpack": "^5.5.0",
"array-move": "^3.0.1",
"bootstrap": "^4.6.0",
"bootstrap-scss": "^4.6.0",
"domexception": "^2.0.1",
"dotenv": "^8.6.0",
"form-data": "^4.0.0",
"handlebars": "^4.7.7",
"handlebars-i18next": "^1.0.1",
"i18next": "^20.2.2",
"i18next-chained-backend": "^2.1.0",
"i18next-fs-backend": "^1.1.1",
"i18next-http-backend": "^1.2.4",
"i18next-localstorage-backend": "^3.1.2",
"i18next-text": "^0.5.6",
"mailgun.js": "^3.3.2",
"next": "^10.2.0",
"next-i18next": "^8.2.0",
"query-string": "^7.0.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-flags-select": "^2.1.2",
"react-i18next": "^11.8.15",
"react-multi-email": "^0.5.3",
"react-sortable-hoc": "^2.0.0",
"react-toastify": "^7.0.4",
"reactstrap": "^8.9.0",
"sass": "^1.32.13"
}
"name": "mieuxvoter-app",
"version": "2.0.0c",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"export": "next export"
},
"dependencies": {
"@babel/eslint-parser": "^7.19.1",
"@dnd-kit/core": "^6.0.5",
"@dnd-kit/sortable": "^7.0.1",
"@fortawesome/fontawesome-free": "^6.2.0",
"@fortawesome/fontawesome-svg-core": "^6.2.0",
"@fortawesome/free-brands-svg-icons": "^6.2.0",
"@fortawesome/free-regular-svg-icons": "^6.2.0",
"@fortawesome/free-solid-svg-icons": "^6.2.0",
"@fortawesome/react-fontawesome": "^0.2.0",
"@netlify/functions": "^1.3.0",
"@types/react": "^18.0.24",
"bootstrap": "^5.2.2",
"bootstrap-scss": "^5.2.2",
"clipboard": "^2.0.10",
"dotenv": "^8.6.0",
"embla-carousel-react": "^7.0.4",
"eslint-config-next": "^13.0.0",
"framer-motion": "^7.6.4",
"i18next": "^22.0.6",
"next": "^13.0.5",
"next-i18next": "^12.1.0",
"react": "^18.2.0",
"react-csv": "^2.2.2",
"react-datepicker": "^4.8.0",
"react-dom": "^18.2.0",
"react-flags-select": "^2.2.3",
"react-i18next": "^12.0.0",
"reactstrap": "^9.1.4",
"sass": "^1.32.13",
"ts-node": "^10.9.1",
"typescript": "^4.8.4"
},
"devDependencies": {
"@sendgrid/mail": "^7.7.0",
"@types/node": "18.11.9",
"cheerio": "^1.0.0-rc.12",
"eslint": "^8.11.0",
"eslint-plugin-react": "^7.29.3",
"form-data": "^4.0.0",
"handlebars": "^4.7.7",
"mailgun.js": "^8.0.6",
"postmark": "^3.0.14"
},
"prettier": {
"trailingComma": "es5",
"tabWidth": 2,
"semi": true,
"printWidth": 80,
"singleQuote": true
}
}

@ -1,34 +0,0 @@
import Head from 'next/head'
import '@styles/globals.css'
import '@styles/footer.css'
import '@styles/loader.css'
import "@styles/scss/config.scss";
import '@fortawesome/fontawesome-svg-core/styles.css'
import {appWithTranslation} from 'next-i18next'
import {AppProvider} from '@services/context.js'
import Header from '@components/layouts/Header'
import Footer from '@components/layouts/Footer'
function Application({Component, pageProps}) {
const origin = typeof window !== 'undefined' && window.location.origin ? window.location.origin : 'http://localhost';
return (<AppProvider>
<Head>
<link rel="icon" key="favicon" href="/favicon.ico" />
<meta property="og:url" content={origin} key="og:url" />
<meta property="og:type" content="website" key="og:type" />
<meta
property="og:image"
content="https://app.mieuxvoter.fr/app-mieux-voter.png"
key="og:image"
/>
</Head>
<Header />
<main className="d-flex flex-column justify-content-center">
<Component {...pageProps} />
</main>
<Footer />
</AppProvider>);
}
export default appWithTranslation(Application)

@ -0,0 +1,43 @@
import Head from 'next/head';
import '@styles/globals.css';
import '@styles/scss/config.scss';
import '@fortawesome/fontawesome-svg-core/styles.css';
import { appWithTranslation } from 'next-i18next';
import { AppProvider } from '@services/context';
import Header from '@components/layouts/Header';
import Footer from '@components/layouts/Footer';
function Application({ Component, pageProps }) {
const origin =
typeof window !== 'undefined' && window.location.origin
? window.location.origin
: 'http://localhost';
return (
<>
<Head>
<link rel="icon" key="favicon" href="/favicon.ico" />
<meta property="og:type" content="website" key="og:type" />
<meta property="og:url" content={origin} key="og:url" />
<meta
property="og:image"
content="https://app.mieuxvoter.fr/app-mieux-voter.png"
key="og:image"
/>
</Head>
<div className="min-vh-100 d-flex flex-column justify-content-between">
<Header />
<Component {...pageProps} />
<Footer />
</div>
</>
);
}
const AppWithContext = ({ Component, pageProps }) => (
<AppProvider>
<Application Component={Component} pageProps={pageProps} />
</AppProvider>
);
export default appWithTranslation(AppWithContext);

@ -0,0 +1,20 @@
import {Html, Head, Main, NextScript} from 'next/document';
export default function Document() {
return (
<Html>
<Head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin={true} />
<link
href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,wght@0,400;0,500;0,700;1,400;1,500;1,700&family=DM+Serif+Display:ital@0;1&display=swap"
rel="stylesheet"
/>
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}

@ -0,0 +1,391 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import { useTranslation } from 'next-i18next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import { Container, Row, Col } from 'reactstrap';
import {
faArrowRight,
faSquarePollVertical,
} from '@fortawesome/free-solid-svg-icons';
import { getElection, updateElection } from '@services/api';
import {
ElectionContextInterface,
ElectionProvider,
ElectionTypes,
useElection,
isClosed,
canViewResults,
checkName,
hasEnoughGrades,
hasEnoughCandidates,
canBeFinished,
} from '@services/ElectionContext';
import { CandidateItem, GradeItem } from '@services/type';
import { gradeColors } from '@services/grades';
import TitleField from '@components/admin/Title';
import Button from '@components/Button';
import AccessResults from '@components/admin/AccessResults';
import CandidatesConfirmField from '@components/admin/CandidatesConfirmField';
import LimitDate from '@components/admin/LimitDate';
import Grades from '@components/admin/Grades';
import Order from '@components/admin/Order';
import Private from '@components/admin/Private';
import ErrorMessage from '@components/Error';
import Blur from '@components/Blur';
import { getUrl, RouteTypes } from '@services/routes';
import { sendInviteMails } from '@services/mail';
import { AppTypes, useAppContext } from '@services/context';
export async function getServerSideProps({ query, locale }) {
const { pid, tid: token } = query;
const electionRef = pid.replaceAll('-', '');
const [payload, translations] = await Promise.all([
getElection(electionRef),
serverSideTranslations(locale, ['resource']),
]);
if ('msg' in payload) {
return { props: { err: payload.msg, ...translations } };
}
const grades = payload.grades.map((g) => ({ ...g, active: true }));
const candidates: Array<CandidateItem> = payload.candidates.map((c) => ({
...c,
active: true,
}));
const description = JSON.parse(payload.description);
const randomOrder = description.randomOrder;
const context: ElectionContextInterface = {
name: payload.name,
description: description.description,
ref: payload.ref,
dateStart: payload.date_start,
dateEnd: payload.date_end,
hideResults: payload.hide_results,
forceClose: payload.force_close,
restricted: payload.restricted,
randomOrder,
emails: [],
grades,
candidates,
};
return {
props: {
context,
token: token || '',
...translations,
},
};
}
const Spinner = () => {
return (
<div className="spinner-border text-light" role="status">
<span className="visually-hidden">Loading...</span>
</div>
);
};
const HeaderRubbon = ({ token }) => {
const { t } = useTranslation();
const [election, dispatch] = useElection();
const [_, dispatchApp] = useAppContext();
const router = useRouter();
const [waiting, setWaiting] = useState(false);
const handleClosing = async () => {
setWaiting(true);
dispatch({
type: ElectionTypes.SET,
field: 'forceClose',
value: true,
});
const candidates = election.candidates
.filter((c) => c.active)
.map((c: CandidateItem) => ({
name: c.name,
description: c.description,
image: c.image,
id: c.id,
}));
const grades = election.grades
.filter((c) => c.active)
.map((g: GradeItem, i: number) => ({ name: g.name, value: i, id: g.id }));
const response = await updateElection(
election.ref,
election.name,
candidates,
grades,
election.description,
election.emails.length,
election.hideResults,
true,
election.restricted,
election.randomOrder,
token
);
if (response.status === 200 && 'ref' in response) {
if (election.restricted && election.emails.length > 0) {
if (election.emails.length !== response.invites.length) {
throw Error('Unexpected number of invites!');
}
const urlVotes = response.invites.map((token: string) =>
getUrl(RouteTypes.VOTE, router, response.ref, token)
);
const urlResult = getUrl(RouteTypes.RESULTS, router, response.ref);
await sendInviteMails(
election.emails,
election.name,
urlVotes,
urlResult,
router
);
}
setWaiting(false);
dispatchApp({
type: AppTypes.TOAST_ADD,
status: 'success',
message: t('success.election-closed'),
});
}
};
return (
<div className="w-100 p-4 bg-primary text-white d-flex justify-content-between align-items-center">
<h5 className="mx-0">{t('admin.admin-title')}</h5>
<div className="d-flex">
{!election.restricted && !isClosed(election) && (
<Link href={getUrl(RouteTypes.VOTE, router, election.ref)}>
<Button
icon={faArrowRight}
color="primary"
className="me-3"
style={{ border: '2px solid rgba(255, 255, 255, 0.4)' }}
position="right"
>
{t('admin.go-to-vote')}
</Button>
</Link>
)}
{canViewResults(election) && (
<Link href={getUrl(RouteTypes.RESULTS, router, election.ref)}>
<Button
icon={faSquarePollVertical}
color="primary"
className="me-3"
style={{ border: '2px solid rgba(255, 255, 255, 0.4)' }}
position="right"
>
{t('admin.go-to-result')}
</Button>
</Link>
)}
{!isClosed(election) && (
<Button
className="me-3 btn_closing"
style={{ border: '2px solid rgba(255, 255, 255, 0.4)' }}
onClick={handleClosing}
position="right"
>
{waiting ? <Spinner /> : t('admin.close-election')}
</Button>
)}
</div>
</div>
);
};
const CreateElection = ({ context, token }) => {
const { t } = useTranslation();
const [election, dispatch] = useElection();
const [_, dispatchApp] = useAppContext();
const router = useRouter();
const [waiting, setWaiting] = useState(false);
useEffect(() => {
if (context) {
dispatch({ type: ElectionTypes.RESET, value: context });
}
}, [election.name, election.forceClose]);
const handleSubmit = async () => {
if (!checkName(election)) {
dispatchApp({
type: AppTypes.TOAST_ADD,
status: 'error',
message: t('error.uncorrect-name'),
});
return;
}
if (!hasEnoughGrades(election)) {
dispatchApp({
type: AppTypes.TOAST_ADD,
status: 'error',
message: t('error.not-enough-grades'),
});
return;
}
if (!hasEnoughCandidates(election)) {
dispatchApp({
type: AppTypes.TOAST_ADD,
status: 'error',
message: t('error.not-enough-candidates'),
});
return;
}
if (!canBeFinished(election)) {
dispatchApp({
type: AppTypes.TOAST_ADD,
status: 'error',
message: t('error.cant-be-finished'),
});
return;
}
const candidates = election.candidates
.filter((c) => c.active)
.map((c: CandidateItem) => ({
name: c.name,
description: c.description,
image: c.image,
id: c.id,
}));
const grades = election.grades
.filter((c) => c.active)
.map((g: GradeItem, i: number) => ({ name: g.name, value: i, id: g.id }));
setWaiting(true);
const response = await updateElection(
election.ref,
election.name,
candidates,
grades,
election.description,
election.emails.length,
election.hideResults,
true,
election.restricted,
election.randomOrder,
token
);
if (response.status === 200 && 'ref' in response) {
if (election.restricted && election.emails.length > 0) {
if (election.emails.length !== response.invites.length) {
throw Error('Unexpected number of invites!');
}
const urlVotes = response.invites.map((token: string) =>
getUrl(RouteTypes.VOTE, router, response.ref, token)
);
const urlResult = getUrl(
RouteTypes.RESULTS,
router,
response.ref,
token
);
await sendInviteMails(
election.emails,
election.name,
urlVotes,
urlResult,
router
);
}
setWaiting(false);
dispatchApp({
type: AppTypes.TOAST_ADD,
status: 'success',
message: t('success.election-updated'),
});
}
};
const numCandidates = election.candidates.filter(
(c) => c.active && c.name != ''
).length;
const numGrades = election.grades.filter(
(g) => g.active && g.name != ''
).length;
const disabled =
!election.name ||
election.name == '' ||
numCandidates < 2 ||
numGrades < 2 ||
numGrades > gradeColors.length;
return (
<>
<HeaderRubbon token={token} />
<Container
fluid="xl"
className="my-5 flex-column d-flex justify-content-center"
>
<Container className="px-0 d-md-none mb-5">
<h4>{t('admin.confirm-title')}</h4>
</Container>
<Row>
<Col className={isClosed(election) ? 'col-12' : 'col-lg-3 col-12'}>
<Container className="py-4 d-none d-md-block">
<h4>{t('common.the-vote')}</h4>
</Container>
<TitleField defaultName={context.name} />
<CandidatesConfirmField editable={false} />
</Col>
{!isClosed(election) && (
<Col className="col-lg-9 col-12 mt-3 mt-md-0">
<Container className="py-4 d-none d-md-block">
<h4>{t('common.the-params')}</h4>
</Container>
<AccessResults />
<LimitDate />
<Grades />
<Order />
{election.restricted && <Private />}
</Col>
)}
</Row>
<Container className="my-5 d-md-flex d-grid justify-content-md-center">
<Button
outline={true}
color="secondary"
className="bg-blue"
disabled={disabled}
onClick={handleSubmit}
icon={waiting ? undefined : faArrowRight}
position="right"
>
{waiting ? <Spinner /> : t('admin.confirm-edit')}
</Button>
</Container>
</Container>
</>
);
};
const CreateElectionProviding = ({ err, context, token }) => {
const { t } = useTranslation();
if (err) {
return <ErrorMessage>{t('admin.error')}</ErrorMessage>;
}
return (
<ElectionProvider>
<Blur />
<CreateElection context={context} token={token} />
</ElectionProvider>
);
};
export default CreateElectionProviding;

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save