commit
9dfa42fe3a
@ -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"
|
||||
}
|
@ -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,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>"{label}"</b>
|
||||
) : (
|
||||
<>{t("the row")}</>
|
||||
)}{" "}
|
||||
?
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
color="primary-outline"
|
||||
className="text-primary border-primary"
|
||||
onClick={toggle}>
|
||||
{t("No")}
|
||||
</Button>
|
||||
<Button color="primary"
|
||||
onClick={() => {toggle(); onDelete();}}
|
||||
>
|
||||
{t("Yes")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
</div >
|
||||
);
|
||||
}
|
||||
|
||||
export default ButtonWithConfirm;
|
@ -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;
|
Before Width: | Height: | Size: 26 KiB |
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,
|
||||
}
|
@ -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};
|
@ -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}}
|
||||
|
||||
<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}}
|
||||
|
||||
<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}}
|
||||
|
||||
<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;
|
@ -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/**"]
|
||||
|
||||
|
@ -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",
|
||||
};
|
||||
|
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…
Reference in new issue