From b94b92d254d36d6bb925d8352b800b21c6d0d617 Mon Sep 17 00:00:00 2001 From: heymanpi Date: Tue, 17 Nov 2020 22:39:23 +0100 Subject: [PATCH 1/7] Users able to send feedbacks --- src/App.jsx | 2 + src/components/Footer.jsx | 2 +- src/pages/public/ContactForm.js | 189 ++++++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 src/pages/public/ContactForm.js diff --git a/src/App.jsx b/src/App.jsx index c33c895..5b1c742 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -31,6 +31,7 @@ import Sales from './pages/public/Sales'; import SaleDetail from './pages/public/SaleDetail'; import UserOrders from './pages/public/UserOrders'; import OrderDetail from './pages/public/OrderDetail'; +import ContactForm from "./pages/public/ContactForm"; // Lazy loaded pages const AdminSite = React.lazy(() => import('./pages/admin/')); @@ -78,6 +79,7 @@ class App extends React.Component { + diff --git a/src/components/Footer.jsx b/src/components/Footer.jsx index d01ac14..d1a577d 100644 --- a/src/components/Footer.jsx +++ b/src/components/Footer.jsx @@ -18,7 +18,7 @@ const useStyles = makeStyles({ export default function Footer(props) { const classes = useStyles(); const simdeLink = SiMDE; - const contactLink = Contact; + const contactLink = Contact; return ( Fait avec ♥ par le {simdeLink}. {contactLink} diff --git a/src/pages/public/ContactForm.js b/src/pages/public/ContactForm.js new file mode 100644 index 0000000..b784f61 --- /dev/null +++ b/src/pages/public/ContactForm.js @@ -0,0 +1,189 @@ +import React, { useState } from 'react' +import {getButtonColoredVariant} from "../../styles"; +import { Container, Grid} from "@material-ui/core"; +import TextField from '@material-ui/core/TextField'; +import Button from '@material-ui/core/Button'; +import MenuItem from '@material-ui/core/MenuItem'; +import FormControl from '@material-ui/core/FormControl'; +import Select from '@material-ui/core/Select'; +import CloudUploadIcon from '@material-ui/icons/CloudUpload'; +import {useSelector} from "react-redux"; +import {apiAxios } from "../../redux/actions"; +import Loader from "../../components/common/Loader"; + +const styles = theme => ({ + title: { + fontSize: '4em', + margin: 0, + }, + subtitle: { + color: theme.palette.text.secondary, + fontWeight: 100, + }, + buttonEmpty: { + ...getButtonColoredVariant(theme, "warning", "outlined"), + margin: theme.spacing(1), + }, + buttonBuy: { + ...getButtonColoredVariant(theme, "success", "contained"), + margin: theme.spacing(1), + }, + alert: { + textAlign: 'center', + color: '#f50057', + }, + root: { + '& .MuiTextField-root': { + margin: theme.spacing(1), + width: '25ch', + }, + flexGrow: 1, + + }, + formControl: { + margin: theme.spacing(1), + minWidth: 120, + }, + selectEmpty: { + marginTop: theme.spacing(2), + }, + + paper: { + padding: theme.spacing(2), + textAlign: 'center', + color: theme.palette.text.secondary, + }, + column: { + display: 'flex', + flexDirection: 'column', + paddingTop: '0 !important', + paddingBottom: '0 !important', + minWidth: 220, + }, + controls: { + maxWidth: 280, + }, + editor: { + // borderWidth: 1, + // borderStyle: 'solid', + // borderColor: 'transparent', + padding: theme.spacing(2), + }, + editing: { + borderColor: 'yellow', + }, + textF: { + margin: 3, + padding: 5 + + }, + button: { + margin: theme.spacing(1), + }, +}); + + + +export default function ContactForm() { + const user = useSelector(store => store.getAuthUser()); + const [reasonIndex, setReasonIndex] = useState(0); + const [reason, setReason] = useState(""); + const [message, setMessage] = useState(""); + const [loading, setLoading] = useState(0); + + async function handleSubmit(e){ + e.preventDefault(); + if(message.length < 0 || reasonIndex === 0) { + //TODO : validation + console.log("Données invalides") + return; + } + const reasonToSend = reason + const subject = "[WOOLLY][FeedBack] - " + reason + " - " + user.name; + const sender = {name: user.name, id: user.id, email: user.email}; + const datas = {subject: subject, message: message, sender: sender, reason: reasonToSend}; + setLoading(1); + await apiAxios.post("/feedback", datas).then(() => { + setReasonIndex(0); + setReason(""); + setMessage(""); + setLoading(0); + }); + } + + + return ( + loading ? ( + + ) : ( + +

Contacter l'équipe

+
+ + + + + +

Veuillez sélectionner un motif

+ +
+
+ + + + + setMessage(e.target.value)} + /> + + + +
+
+
+ ) + + ); +} From 9593397b1f4be63e5bb407248c5994d40d8a6ef5 Mon Sep 17 00:00:00 2001 From: heymanpi Date: Sun, 6 Dec 2020 19:22:09 +0100 Subject: [PATCH 2/7] Using custom fields and display error/success message to user --- src/pages/public/ContactForm.js | 250 ++++++++++++-------------------- 1 file changed, 90 insertions(+), 160 deletions(-) diff --git a/src/pages/public/ContactForm.js b/src/pages/public/ContactForm.js index b784f61..4703441 100644 --- a/src/pages/public/ContactForm.js +++ b/src/pages/public/ContactForm.js @@ -1,189 +1,119 @@ -import React, { useState } from 'react' -import {getButtonColoredVariant} from "../../styles"; -import { Container, Grid} from "@material-ui/core"; -import TextField from '@material-ui/core/TextField'; -import Button from '@material-ui/core/Button'; -import MenuItem from '@material-ui/core/MenuItem'; -import FormControl from '@material-ui/core/FormControl'; -import Select from '@material-ui/core/Select'; -import CloudUploadIcon from '@material-ui/icons/CloudUpload'; -import {useSelector} from "react-redux"; -import {apiAxios } from "../../redux/actions"; -import Loader from "../../components/common/Loader"; - -const styles = theme => ({ - title: { - fontSize: '4em', - margin: 0, - }, - subtitle: { - color: theme.palette.text.secondary, - fontWeight: 100, - }, - buttonEmpty: { - ...getButtonColoredVariant(theme, "warning", "outlined"), - margin: theme.spacing(1), - }, - buttonBuy: { - ...getButtonColoredVariant(theme, "success", "contained"), - margin: theme.spacing(1), - }, - alert: { - textAlign: 'center', - color: '#f50057', - }, - root: { - '& .MuiTextField-root': { - margin: theme.spacing(1), - width: '25ch', - }, - flexGrow: 1, - - }, - formControl: { - margin: theme.spacing(1), - minWidth: 120, - }, - selectEmpty: { - marginTop: theme.spacing(2), - }, - - paper: { - padding: theme.spacing(2), - textAlign: 'center', - color: theme.palette.text.secondary, - }, - column: { - display: 'flex', - flexDirection: 'column', - paddingTop: '0 !important', - paddingBottom: '0 !important', - minWidth: 220, - }, - controls: { - maxWidth: 280, - }, - editor: { - // borderWidth: 1, - // borderStyle: 'solid', - // borderColor: 'transparent', - padding: theme.spacing(2), - }, - editing: { - borderColor: 'yellow', - }, - textF: { - margin: 3, - padding: 5 - - }, - button: { - margin: theme.spacing(1), - }, -}); +import React from 'react' +import {useDispatch, useSelector} from "react-redux"; +import messagesActions from "redux/actions/messages"; +import {apiAxios} from "utils/api"; +import {useFormStyles} from "utils/styles"; +import {Box, Container, Grid, Paper, Button} from "@material-ui/core"; +import CloudUploadIcon from '@material-ui/icons/CloudUpload'; +import Loader from "components/common/Loader"; +import FieldGenerator from 'components/common/FieldGenerator'; export default function ContactForm() { - const user = useSelector(store => store.getAuthUser()); - const [reasonIndex, setReasonIndex] = useState(0); - const [reason, setReason] = useState(""); - const [message, setMessage] = useState(""); - const [loading, setLoading] = useState(0); - - async function handleSubmit(e){ - e.preventDefault(); - if(message.length < 0 || reasonIndex === 0) { - //TODO : validation - console.log("Données invalides") - return; + + const error = {}; + const initialStore = {choix: 0, reason: null, message: null, loading: false} + const [store, changeStore] = React.useState(initialStore); + const classes = useFormStyles(); + + const onChange = event => { + const {name, value} = event.target; + if (name === 'choix' && choicePossibilites[value - 1]) { + changeStore(prevState => ({...prevState, reason: choicePossibilites[value - 1].label})); } - const reasonToSend = reason - const subject = "[WOOLLY][FeedBack] - " + reason + " - " + user.name; - const sender = {name: user.name, id: user.id, email: user.email}; - const datas = {subject: subject, message: message, sender: sender, reason: reasonToSend}; - setLoading(1); - await apiAxios.post("/feedback", datas).then(() => { - setReasonIndex(0); - setReason(""); - setMessage(""); - setLoading(0); - }); + changeStore(prevState => ({...prevState, [name]: value})); } + const Field = new FieldGenerator(store, error, onChange); + + const choicePossibilites = [ + { + label: 'Création d\'une vente', value: '1' + }, + { + label: 'Gestion d\'une vente', value: '2' + }, + { + label: 'Commande passée', value: '3' + }, + { + label: 'Signaler un bug', value: '4' + }, + { + label: 'Autre', value: '5' + } + ]; + + const user = useSelector(store => store.getAuthUser()); + const dispatch = useDispatch(); + + async function handleSubmit() { + + const sender = {name: user.name, id: user.id, email: user.email}; + const datas = {message: store.message, sender: sender, reason: store.reason}; + changeStore(prevState => ({...prevState, loading: true})); + + try { + await apiAxios.post("/feedback", datas).then(() => { + changeStore(initialStore); + dispatch(messagesActions.pushMessage("Message envoyé !", "Votre message a bien été transmis au SiMDE", "success")); + }); + } catch (error) { + changeStore(prevState => ({...prevState, loading: false})); + dispatch(messagesActions.pushError(error, "Un ou plusieurs champs sont manquants")); + } + + } return ( - loading ? ( - + store.loading ? ( + ) : ( + + -

Contacter l'équipe

-
- +

Contacter le SiMDE

+ + + + - - - -

Veuillez sélectionner un motif

- -
+ +

Veuillez sélectionner un motif

+ {Field.select('choix', "Choix", choicePossibilites, {required: true})}
- - - - setMessage(e.target.value)} - /> - + +

Message

+ {Field.text('message', 'Message', { + required: true, + multiline: true, + rows: 6, + fullWidth: true + })}
+ +
+ + -
- + +
+
) - ); } From 81d7a85f894d27c14bec207a405e4e5fa44c95b4 Mon Sep 17 00:00:00 2001 From: Alexandre Brasseur Date: Sun, 13 Dec 2020 20:51:49 +0100 Subject: [PATCH 3/7] Add a timer on unopened sales --- src/components/sales/SaleCard.jsx | 174 ++++++++++++++++++------------ src/pages/public/Sales.jsx | 1 + src/utils.js | 141 ++++++++++++++++++++++++ src/utils/api.js | 30 ++++++ 4 files changed, 280 insertions(+), 66 deletions(-) create mode 100644 src/utils.js diff --git a/src/components/sales/SaleCard.jsx b/src/components/sales/SaleCard.jsx index d1279de..7309dfa 100644 --- a/src/components/sales/SaleCard.jsx +++ b/src/components/sales/SaleCard.jsx @@ -1,88 +1,130 @@ import React from 'react'; import PropTypes from 'prop-types'; import { shorten } from 'utils/format'; +import { getCountdown, saleIsOpen } from 'utils/api'; +import {isPast} from "date-fns"; import { makeStyles } from '@material-ui/core/styles'; -import { Grid, Card, CardContent, CardActions } from '@material-ui/core'; +import { Grid, Card, CardContent, CardActions, Chip } from '@material-ui/core'; import { Skeleton } from '@material-ui/lab'; import { NavButton } from 'components/common/Nav'; const useStyles = makeStyles(theme => ({ - card: { - height: '100%', - display: 'flex', - flexDirection: 'column', - '&:hover': { - boxShadow: theme.shadows[4], - }, - '&:hover .go-to-sale': { - color: theme.palette.primary.main, - }, - }, - content: { - flex: 1, - }, - title: { - fontSize: 24, - margin: 0, - }, - subtitle: { - fontStyle: 'italic', - }, - description: { - textAlign: 'justify', - }, + card: { + height: '100%', + display: 'flex', + flexDirection: 'column', + '&:hover': { + boxShadow: theme.shadows[4], + }, + '&:hover .go-to-sale': { + color: theme.palette.primary.main, + }, + }, + content: { + flex: 1, + }, + title: { + fontSize: 24, + margin: 0, + }, + subtitle: { + fontStyle: 'italic', + }, + description: { + textAlign: 'justify', + }, })); -export function SaleCardSkeleton() { - const classes = useStyles(); - return ( - - - - - - - - - +export function SaleCardSkeleton() { + const classes = useStyles(); + return ( + + + + + + + + + - - - - - - ); + + + + + + ); } -export default function SaleCard({ sale, ...props }) { - const classes = useStyles(); - return ( - - - -

- {sale.name} -

- + +export default function SaleCard({sale, ...props}) { + const classes = useStyles(); + + React.useEffect(() => { + const interval = setInterval(() => { + if(document.getElementById(sale.id)) { + const count = getCountdown(sale.begin_at) + if(!count) + window.location.reload(false); + document.getElementById(sale.id).children[0].textContent = count; + } + }, 1000); + return () => clearInterval(interval); + }); + + function currentSaleState() { + if (!sale) + return null; + if (sale.end_at && isPast(new Date(sale.end_at))) + return ['Terminée !', 'red']; + if (sale.begin_at && isPast(new Date(sale.begin_at))) + return ['En cours !', 'green']; + return ['Ouverte prochaine ... ', 'orange']; + } + + return ( + + + +

+ {sale.name} +

+ Par {sale.association && sale.association.shortname} -

- {shorten(sale.description, 150)} -

-
+

+ {shorten(sale.description, 150)} +

+ + + + + +
+ { isPast(new Date(sale.begin_at)) ? ( + + + Accéder à la vente + + + ) : ( + + ) + } - - - Accéder à la vente - - -
-
- ); + + + ); } SaleCard.propTypes = { - sale: PropTypes.object.isRequired, + sale: PropTypes.object.isRequired, }; diff --git a/src/pages/public/Sales.jsx b/src/pages/public/Sales.jsx index 145c9dd..6fae443 100644 --- a/src/pages/public/Sales.jsx +++ b/src/pages/public/Sales.jsx @@ -1,5 +1,6 @@ import React from "react"; import { Container, Box } from "@material-ui/core"; +import { getCountdown} from 'utils/api'; import APIDataTable from "components/common/APIDataTable"; import { Link } from "components/common/Nav"; diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..ed908ba --- /dev/null +++ b/src/utils.js @@ -0,0 +1,141 @@ +import React from 'react'; +import {formatDistanceToNow, formatDistanceToNowStrict, lightFormat, parseISO, isBefore, intervalToDuration} from 'date-fns' + +window.__localeId__ = 'fr' + +/* +|--------------------------------------------------------- +| Object utils +|--------------------------------------------------------- +*/ + +export function isList(object) { + return object && object.length !== undefined; +} + +export function isEmpty(object) { + return object == null || (object instanceof Object && Object.keys(object).length === 0); +} + +export function deepcopy(object) { + return JSON.parse(JSON.stringify(object)); +} + +export function stringToGetter(attr) { + return obj => obj[attr]; +} + +export function arrayToMap(array, getKey, getValue) { + if (typeof getKey === 'string') + getKey = stringToGetter(getKey); + if (typeof getValue === 'string') + getValue = stringToGetter(getValue); + + return array.reduce((map, obj) => { + map[getKey(obj)] = getValue ? getValue(obj) : obj; + return map; + }, {}); +} + + +function goDeep(path, ...args) { + return path ? path.split('.').reduce((dataArr, step) => ( + dataArr.map(data => data[step]) + ), args) : args; +} + +export function getDifferentChildren(prevData, nextData, path=null) { + if (path) + [prevData, nextData] = goDeep(path, prevData, nextData); + return Object.keys(nextData).reduce((store, key) => { + if (key in prevData) + store[prevData[key] === nextData[key] ? 'same' : 'updated'].push(key); + else + store.added.push(key); + return store; + }, { + added: [], + updated: [], + same: [], + deleted: Object.keys(prevData).filter(key => !(key in nextData)), + }); +} + +export function areDifferent(a, b, path=null) { + if (path) + [a, b] = goDeep(path, a, b); + return a !== b || JSON.stringify(a) !== JSON.stringify(b); +} + +export function dataToChoices(data, labelKey) { + return Object.values(data).reduce((choices, object) => { + choices[object.id] = { + value: object.id, + label: object[labelKey], + }; + return choices; + }, {}); +} + +/* +|--------------------------------------------------------- +| Text utils +|--------------------------------------------------------- +*/ + +export function shorten(text, limit) { + if (text && text.length > limit) + return text.slice(0,limit-3) + '...'; + return text; +} + +export function capitalFirst(text) { + return text.charAt(0).toLocaleUpperCase() + text.slice(1); +} + +export function textOrIcon(text, Icon, displayText) { + return displayText ? text : +} + +export const priceFormatter = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }); + +export function formatPrice(price, defaultValue = undefined) { + if (!price || price === '0') { + if (defaultValue !== undefined) + return defaultValue; + else + price = 0; + } + return priceFormatter.format(price); +} + +export function formatDate(date, variant = 'date') { + if (typeof date === 'string') + date = parseISO(date); + switch (variant) { + case 'date': + return lightFormat(date, 'dd/MM/yyyy') + case 'datetime': + return lightFormat(date, 'dd/MM/yyyy HH:mm') + case 'datetimesec': + return lightFormat(date, 'dd/MM/yyyy HH:mm:ss') + case 'fromNow': + return formatDistanceToNow(date); + case 'fromNowStrict': + return formatDistanceToNowStrict(date); + default: + throw Error(`Unknown format '${variant}'`) + } +} + +/* +|--------------------------------------------------------- +| Rights utils +|--------------------------------------------------------- +*/ + +export function hasManagerRights(auth, userAssos) { + return auth.authenticated && ( + auth.user.is_admin || !isEmpty(userAssos) + ); +} diff --git a/src/utils/api.js b/src/utils/api.js index d8e6c52..14be7c6 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -2,6 +2,7 @@ import axios from "axios"; import apiActions from "redux/actions/api"; import { API_URL } from "utils/constants"; import { isEmpty } from "utils/helpers"; +import { formatDistanceToNow, isBefore, intervalToDuration } from 'date-fns' /** * Default axios for the API @@ -69,3 +70,32 @@ export function getStatusActions(dispatch, history) { }; }; + +export function getCountdown(date) { + const today = new Date(); + const begin = new Date(date); + + let duration = intervalToDuration({ + start: begin, + end: today + }); + let result = null; + if(duration.days > 0) { + result = duration.days + " jours " ; + } + if(duration.hours > 0) { + result = result + duration.hours + " h " ; + } + if(duration.minutes > 0) { + result = result + duration.minutes + " min " ; + } + if(duration.seconds > 0) { + result = result + duration.seconds + " sec " ; + } + return result; +} + +export function saleIsOpen(sale) { + const today = new Date(); + return isBefore(new Date(sale.begin_at), today) && isBefore(today, new Date(sale.end_at)); +} From 4f2024bfe8413383f0fc9f7bc8bda9f7c6e1b05b Mon Sep 17 00:00:00 2001 From: Alexandre Brasseur Date: Sun, 13 Dec 2020 20:56:22 +0100 Subject: [PATCH 4/7] Improve the countdown and display a progress bar --- src/components/sales/SaleCard.jsx | 42 +++++++-- src/utils.js | 141 ------------------------------ src/utils/api.js | 22 +++-- 3 files changed, 50 insertions(+), 155 deletions(-) delete mode 100644 src/utils.js diff --git a/src/components/sales/SaleCard.jsx b/src/components/sales/SaleCard.jsx index 7309dfa..32509fd 100644 --- a/src/components/sales/SaleCard.jsx +++ b/src/components/sales/SaleCard.jsx @@ -5,7 +5,10 @@ import { getCountdown, saleIsOpen } from 'utils/api'; import {isPast} from "date-fns"; import { makeStyles } from '@material-ui/core/styles'; -import { Grid, Card, CardContent, CardActions, Chip } from '@material-ui/core'; +import { + Box, Grid, Card, CardContent, CardActions, + Chip, LinearProgress, Typography, +} from '@material-ui/core'; import { Skeleton } from '@material-ui/lab'; import { NavButton } from 'components/common/Nav'; @@ -59,16 +62,35 @@ export function SaleCardSkeleton() { } +function LinearProgressWithLabel(props) { + return ( + + + + + + + + {props.text} + + + + ); +} + export default function SaleCard({sale, ...props}) { const classes = useStyles(); + const [store, changeStore] = React.useState({progress: 0, timeLeft: "Chargement..."}); - React.useEffect(() => { + React.useEffect(() => { const interval = setInterval(() => { if(document.getElementById(sale.id)) { - const count = getCountdown(sale.begin_at) - if(!count) + const count = getCountdown(sale.begin_at); + console.log(count) + if(!count.timer) window.location.reload(false); - document.getElementById(sale.id).children[0].textContent = count; + + changeStore(prevState => ({...prevState, progress: count.nbSeconds, timeLeft: count.timer})); } }, 1000); return () => clearInterval(interval); @@ -92,8 +114,8 @@ export default function SaleCard({sale, ...props}) { {sale.name} - Par {sale.association && sale.association.shortname} - + Par {sale?.association?.shortname} +

{shorten(sale.description, 150)}

@@ -113,10 +135,12 @@ export default function SaleCard({sale, ...props}) { ) : ( - + + /* + />*/ ) } diff --git a/src/utils.js b/src/utils.js deleted file mode 100644 index ed908ba..0000000 --- a/src/utils.js +++ /dev/null @@ -1,141 +0,0 @@ -import React from 'react'; -import {formatDistanceToNow, formatDistanceToNowStrict, lightFormat, parseISO, isBefore, intervalToDuration} from 'date-fns' - -window.__localeId__ = 'fr' - -/* -|--------------------------------------------------------- -| Object utils -|--------------------------------------------------------- -*/ - -export function isList(object) { - return object && object.length !== undefined; -} - -export function isEmpty(object) { - return object == null || (object instanceof Object && Object.keys(object).length === 0); -} - -export function deepcopy(object) { - return JSON.parse(JSON.stringify(object)); -} - -export function stringToGetter(attr) { - return obj => obj[attr]; -} - -export function arrayToMap(array, getKey, getValue) { - if (typeof getKey === 'string') - getKey = stringToGetter(getKey); - if (typeof getValue === 'string') - getValue = stringToGetter(getValue); - - return array.reduce((map, obj) => { - map[getKey(obj)] = getValue ? getValue(obj) : obj; - return map; - }, {}); -} - - -function goDeep(path, ...args) { - return path ? path.split('.').reduce((dataArr, step) => ( - dataArr.map(data => data[step]) - ), args) : args; -} - -export function getDifferentChildren(prevData, nextData, path=null) { - if (path) - [prevData, nextData] = goDeep(path, prevData, nextData); - return Object.keys(nextData).reduce((store, key) => { - if (key in prevData) - store[prevData[key] === nextData[key] ? 'same' : 'updated'].push(key); - else - store.added.push(key); - return store; - }, { - added: [], - updated: [], - same: [], - deleted: Object.keys(prevData).filter(key => !(key in nextData)), - }); -} - -export function areDifferent(a, b, path=null) { - if (path) - [a, b] = goDeep(path, a, b); - return a !== b || JSON.stringify(a) !== JSON.stringify(b); -} - -export function dataToChoices(data, labelKey) { - return Object.values(data).reduce((choices, object) => { - choices[object.id] = { - value: object.id, - label: object[labelKey], - }; - return choices; - }, {}); -} - -/* -|--------------------------------------------------------- -| Text utils -|--------------------------------------------------------- -*/ - -export function shorten(text, limit) { - if (text && text.length > limit) - return text.slice(0,limit-3) + '...'; - return text; -} - -export function capitalFirst(text) { - return text.charAt(0).toLocaleUpperCase() + text.slice(1); -} - -export function textOrIcon(text, Icon, displayText) { - return displayText ? text : -} - -export const priceFormatter = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }); - -export function formatPrice(price, defaultValue = undefined) { - if (!price || price === '0') { - if (defaultValue !== undefined) - return defaultValue; - else - price = 0; - } - return priceFormatter.format(price); -} - -export function formatDate(date, variant = 'date') { - if (typeof date === 'string') - date = parseISO(date); - switch (variant) { - case 'date': - return lightFormat(date, 'dd/MM/yyyy') - case 'datetime': - return lightFormat(date, 'dd/MM/yyyy HH:mm') - case 'datetimesec': - return lightFormat(date, 'dd/MM/yyyy HH:mm:ss') - case 'fromNow': - return formatDistanceToNow(date); - case 'fromNowStrict': - return formatDistanceToNowStrict(date); - default: - throw Error(`Unknown format '${variant}'`) - } -} - -/* -|--------------------------------------------------------- -| Rights utils -|--------------------------------------------------------- -*/ - -export function hasManagerRights(auth, userAssos) { - return auth.authenticated && ( - auth.user.is_admin || !isEmpty(userAssos) - ); -} diff --git a/src/utils/api.js b/src/utils/api.js index 14be7c6..ee92fe9 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -80,19 +80,31 @@ export function getCountdown(date) { end: today }); let result = null; + let durationSec = 0; if(duration.days > 0) { - result = duration.days + " jours " ; + result = duration.days + "jours" ; + durationSec += duration.days * 24 * 3600; } if(duration.hours > 0) { - result = result + duration.hours + " h " ; + result = result + duration.hours + "h" ; + durationSec += duration.hours * 3600; } if(duration.minutes > 0) { - result = result + duration.minutes + " min " ; + result = result + duration.minutes + "min" ; + durationSec += duration.minutes * 60; } if(duration.seconds > 0) { - result = result + duration.seconds + " sec " ; + result = result + duration.seconds + "sec " ; + durationSec += duration.seconds; } - return result; + + if(durationSec > 3600) + durationSec = 0; + else { + durationSec = Math.round((3600/(durationSec + 3600))*100); + } + + return {timer: result, nbSeconds: durationSec}; } export function saleIsOpen(sale) { From 46ac0dce50723042275e0b55d3be7578936e27b3 Mon Sep 17 00:00:00 2001 From: Alexandre Brasseur Date: Sun, 13 Dec 2020 21:11:47 +0100 Subject: [PATCH 5/7] Move the timer to sale page --- src/components/sales/SaleCard.jsx | 64 +++--------------- src/pages/public/SaleDetail.jsx | 109 +++++++++++++++++++++++------- 2 files changed, 95 insertions(+), 78 deletions(-) diff --git a/src/components/sales/SaleCard.jsx b/src/components/sales/SaleCard.jsx index 32509fd..249977b 100644 --- a/src/components/sales/SaleCard.jsx +++ b/src/components/sales/SaleCard.jsx @@ -12,7 +12,6 @@ import { import { Skeleton } from '@material-ui/lab'; import { NavButton } from 'components/common/Nav'; - const useStyles = makeStyles(theme => ({ card: { height: '100%', @@ -62,48 +61,16 @@ export function SaleCardSkeleton() { } -function LinearProgressWithLabel(props) { - return ( - - - - - - - - {props.text} - - - - ); -} - export default function SaleCard({sale, ...props}) { const classes = useStyles(); - const [store, changeStore] = React.useState({progress: 0, timeLeft: "Chargement..."}); - - React.useEffect(() => { - const interval = setInterval(() => { - if(document.getElementById(sale.id)) { - const count = getCountdown(sale.begin_at); - console.log(count) - if(!count.timer) - window.location.reload(false); - - changeStore(prevState => ({...prevState, progress: count.nbSeconds, timeLeft: count.timer})); - } - }, 1000); - return () => clearInterval(interval); - }); - function currentSaleState() { if (!sale) return null; if (sale.end_at && isPast(new Date(sale.end_at))) - return ['Terminée !', 'red']; + return {label: 'Terminée !', color: 'red'}; if (sale.begin_at && isPast(new Date(sale.begin_at))) - return ['En cours !', 'green']; - return ['Ouverte prochaine ... ', 'orange']; + return {label: 'En cours !', color: 'green'}; + return {label: 'Ouverte prochaine ... ', color: 'orange'}; } return ( @@ -114,35 +81,26 @@ export default function SaleCard({sale, ...props}) { {sale.name} - Par {sale?.association?.shortname} + Par {sale.association?.shortname}

{shorten(sale.description, 150)}

- - { isPast(new Date(sale.begin_at)) ? ( - - - Accéder à la vente - - - ) : ( - + + + Accéder à la vente + + - /**/ - ) - } diff --git a/src/pages/public/SaleDetail.jsx b/src/pages/public/SaleDetail.jsx index 8b8b5dc..673e96b 100644 --- a/src/pages/public/SaleDetail.jsx +++ b/src/pages/public/SaleDetail.jsx @@ -8,6 +8,7 @@ import { apiAxios } from 'utils/api'; import { isPast } from 'date-fns'; import { formatDate } from 'utils/format'; +import { getCountdown } from 'utils/api'; import { getButtonColoredVariant } from 'utils/styles'; import Loader from 'components/common/Loader'; @@ -15,10 +16,37 @@ import ItemsTable from 'components/sales/ItemsTable'; import UnpaidOrderDialog from 'components/orders/UnpaidOrderDialog'; import { Link } from 'components/common/Nav'; -import { withStyles } from '@material-ui/core/styles'; -import { Container, Box, Grid, Button, Paper, FormControlLabel, Checkbox, Collapse } from '@material-ui/core'; -import { ShoppingCart, Delete } from '@material-ui/icons'; -import { Alert, AlertTitle } from '@material-ui/lab'; +import {withStyles} from '@material-ui/core/styles'; +import { + Container, Box, Grid, Button, Paper, FormControlLabel, Checkbox, + Collapse, LinearProgress,Typography, +} from '@material-ui/core'; +import {ShoppingCart, Delete} from '@material-ui/icons'; +import {Alert, AlertTitle} from '@material-ui/lab'; + + +function LinearProgressWithLabel(props) { + return ( + + {props.value ? ( + + + + + ) : ( + + + + + )} + + + {props.text} + + + + ); +} const connector = connect((store, props) => { @@ -38,6 +66,8 @@ class SaleDetail extends React.Component{ quantities: {}, buying: false, cgvAccepted: false, + progress: 0, + timeLeft: "Chargement...", } componentDidMount() { @@ -50,6 +80,16 @@ class SaleDetail extends React.Component{ if (!this.props.items) this.props.dispatch(apiActions.sales(saleId).items.all()); + + this.interval = setInterval(() => { + if (document.getElementById(this.props.sale.id)) { + const countdown = getCountdown(this.props.sale.begin_at); + if (!countdown.timer) + window.location.reload(false); + + this.setState(prevState => ({...prevState, progress: countdown.nbSeconds, timeLeft: countdown.timer})) + } + }, 1000); } componentDidUpdate(prevProps) { @@ -66,6 +106,10 @@ class SaleDetail extends React.Component{ } } + componentWillUnmount() { + clearInterval(this.interval); + } + // ----------------------------------------------------- // Order handlers // ----------------------------------------------------- @@ -297,17 +341,28 @@ class SaleDetail extends React.Component{ - - - {/* SAVE BUTTON, utile ?? + {saleState === 'NOT_BEGUN' ? ( + + + La vente n'a pas encore commencée, encore un peu de patience + ! + + + + + ) : ( + + + {/* SAVE BUTTON, utile ?? - + + + + + )} + From ce994cff935be9d282d472d27815a06f771b0c9e Mon Sep 17 00:00:00 2001 From: Alexandre Brasseur Date: Sun, 13 Dec 2020 23:43:15 +0100 Subject: [PATCH 6/7] refactor: Move getSaleStatus to utils --- src/components/sales/SaleCard.jsx | 36 ++----- src/pages/public/SaleDetail.jsx | 172 +++++++++++++----------------- src/pages/public/Sales.jsx | 1 - src/utils/api.js | 59 +++------- src/utils/constants.js | 8 ++ src/utils/format.js | 12 ++- 6 files changed, 113 insertions(+), 175 deletions(-) diff --git a/src/components/sales/SaleCard.jsx b/src/components/sales/SaleCard.jsx index 249977b..7e9dbec 100644 --- a/src/components/sales/SaleCard.jsx +++ b/src/components/sales/SaleCard.jsx @@ -1,14 +1,10 @@ import React from 'react'; import PropTypes from 'prop-types'; import { shorten } from 'utils/format'; -import { getCountdown, saleIsOpen } from 'utils/api'; -import {isPast} from "date-fns"; +import { getSaleState } from "utils/api"; import { makeStyles } from '@material-ui/core/styles'; -import { - Box, Grid, Card, CardContent, CardActions, - Chip, LinearProgress, Typography, -} from '@material-ui/core'; +import { Grid, Card, CardContent, CardActions, Chip } from '@material-ui/core'; import { Skeleton } from '@material-ui/lab'; import { NavButton } from 'components/common/Nav'; @@ -63,16 +59,7 @@ export function SaleCardSkeleton() { export default function SaleCard({sale, ...props}) { const classes = useStyles(); - function currentSaleState() { - if (!sale) - return null; - if (sale.end_at && isPast(new Date(sale.end_at))) - return {label: 'Terminée !', color: 'red'}; - if (sale.begin_at && isPast(new Date(sale.begin_at))) - return {label: 'En cours !', color: 'green'}; - return {label: 'Ouverte prochaine ... ', color: 'orange'}; - } - + const currentSaleState = getSaleState(sale); return ( @@ -81,27 +68,22 @@ export default function SaleCard({sale, ...props}) { {sale.name} - Par {sale.association?.shortname} + Par {sale.association?.shortname || "..."}

{shorten(sale.description, 150)}

- - - - - + Accéder à la vente - -
); diff --git a/src/pages/public/SaleDetail.jsx b/src/pages/public/SaleDetail.jsx index 673e96b..f6de53f 100644 --- a/src/pages/public/SaleDetail.jsx +++ b/src/pages/public/SaleDetail.jsx @@ -6,9 +6,9 @@ import apiActions from 'redux/actions/api'; import messagesActions from 'redux/actions/messages'; import { apiAxios } from 'utils/api'; -import { isPast } from 'date-fns'; +import { differenceInSeconds } from 'date-fns' import { formatDate } from 'utils/format'; -import { getCountdown } from 'utils/api'; +import { getSaleState } from 'utils/api'; import { getButtonColoredVariant } from 'utils/styles'; import Loader from 'components/common/Loader'; @@ -19,35 +19,13 @@ import { Link } from 'components/common/Nav'; import {withStyles} from '@material-ui/core/styles'; import { Container, Box, Grid, Button, Paper, FormControlLabel, Checkbox, - Collapse, LinearProgress,Typography, + Collapse, LinearProgress, } from '@material-ui/core'; import {ShoppingCart, Delete} from '@material-ui/icons'; import {Alert, AlertTitle} from '@material-ui/lab'; -function LinearProgressWithLabel(props) { - return ( - - {props.value ? ( - - - - - ) : ( - - - - - )} - - - {props.text} - - - - ); -} - +const COUNTDOWN_MAX = 12 * 3600; // Countdown 12h before const connector = connect((store, props) => { const saleId = props.match.params.sale_id; @@ -66,8 +44,10 @@ class SaleDetail extends React.Component{ quantities: {}, buying: false, cgvAccepted: false, - progress: 0, - timeLeft: "Chargement...", + countdown: undefined, + // progress: 0, + // timeLeft: "Chargement...", + saleState: null, } componentDidMount() { @@ -81,21 +61,16 @@ class SaleDetail extends React.Component{ if (!this.props.items) this.props.dispatch(apiActions.sales(saleId).items.all()); - this.interval = setInterval(() => { - if (document.getElementById(this.props.sale.id)) { - const countdown = getCountdown(this.props.sale.begin_at); - if (!countdown.timer) - window.location.reload(false); - - this.setState(prevState => ({...prevState, progress: countdown.nbSeconds, timeLeft: countdown.timer})) - } - }, 1000); + if (this.props.sale) + this.updateSaleState(); } componentDidUpdate(prevProps) { - const order = this.props.order; + if (prevProps.sale !== this.props.sale) + this.updateSaleState(); // Update quantities from current order + const order = this.props.order; if (prevProps.order !== order && order?.orderlines?.length) { this.setState({ quantities: order.orderlines.reduce((acc, orderline) => { @@ -106,9 +81,31 @@ class SaleDetail extends React.Component{ } } - componentWillUnmount() { - clearInterval(this.interval); - } + componentWillUnmount() { + if (this.interval) + clearInterval(this.interval); + } + + updateSaleState = () => { + if (this.interval) + clearInterval(this.interval); + + const saleState = getSaleState(this.props.sale); + this.setState({ saleState, countdown: undefined }); + + // Start countdown if not begun and less than 24 hours to go + const start = new Date(this.props.sale.begin_at); + if (saleState?.key === 'NOT_BEGUN' && differenceInSeconds(start, new Date()) < COUNTDOWN_MAX) { + this.interval = setInterval(() => { + const secondsLeft = differenceInSeconds(start, new Date()); + const countdown = (COUNTDOWN_MAX - secondsLeft) / COUNTDOWN_MAX * 100; + if (secondsLeft <= 0) + this.updateSaleState(); + else + this.setState(prevState => ({ ...prevState, countdown })); + }, 1000); + } + } // ----------------------------------------------------- // Order handlers @@ -217,17 +214,6 @@ class SaleDetail extends React.Component{ // Display // ----------------------------------------------------- - currentSaleState = () => { - const sale = this.props.sale; - if (!sale) - return null; - if (sale.end_at && isPast(new Date(sale.end_at))) - return 'FINISHED'; - if (sale.begin_at && isPast(new Date(sale.begin_at))) - return 'ONGOING'; - return 'NOT_BEGUN'; - } - hasUnpaidOrder = () => Boolean(this.props.order && this.props.order.status === 3) areItemsDisabled = () => Boolean(!this.props.authenticated || this.hasUnpaidOrder()) @@ -237,17 +223,16 @@ class SaleDetail extends React.Component{ canBuy = () => ( this.props.authenticated && this.state.cgvAccepted - && this.currentSaleState() === 'ONGOING' + && this.state.saleState?.key === 'ONGOING' && this.hasItemsInCart() ) render() { const { classes, sale } = this.props; - const { cgvAccepted } = this.state; + const { cgvAccepted, saleState } = this.state; if (!sale || this.props.fetchingSale) return - const saleState = this.currentSaleState() const CGVLink = props => return ( @@ -279,15 +264,21 @@ class SaleDetail extends React.Component{

Articles en ventes

- {saleState === 'NOT_BEGUN' && ( + {saleState?.key === 'NOT_BEGUN' && ( La vente n'a pas encore commencée - Revenez d'ici {sale.begin_at && formatDate(sale.begin_at, 'fromNowStrict')} pour pouvoir commander. + Encore un peu de patience ! + Revenez d'ici {sale.begin_at && formatDate(sale.begin_at, 'fromNowStrict')} pour pouvoir commander. + {this.state.countdown && ( + + + + )} )} - {saleState === 'FINISHED' && ( + {saleState?.key === 'FINISHED' && ( La vente est terminée @@ -295,7 +286,7 @@ class SaleDetail extends React.Component{ )} - {saleState === 'ONGOING' && ( + {saleState?.key === 'ONGOING' && ( - {saleState === 'NOT_BEGUN' ? ( - - - La vente n'a pas encore commencée, encore un peu de patience - ! - - - - - ) : ( - - - {/* SAVE BUTTON, utile ?? + - */} + {/* SAVE BUTTON, utile ?? - - - - )} + */} + +
- 0) { - result = duration.days + "jours" ; - durationSec += duration.days * 24 * 3600; - } - if(duration.hours > 0) { - result = result + duration.hours + "h" ; - durationSec += duration.hours * 3600; - } - if(duration.minutes > 0) { - result = result + duration.minutes + "min" ; - durationSec += duration.minutes * 60; - } - if(duration.seconds > 0) { - result = result + duration.seconds + "sec " ; - durationSec += duration.seconds; - } - - if(durationSec > 3600) - durationSec = 0; - else { - durationSec = Math.round((3600/(durationSec + 3600))*100); - } - - return {timer: result, nbSeconds: durationSec}; -} - -export function saleIsOpen(sale) { - const today = new Date(); - return isBefore(new Date(sale.begin_at), today) && isBefore(today, new Date(sale.end_at)); -} diff --git a/src/utils/constants.js b/src/utils/constants.js index ef884da..7acc20d 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -12,6 +12,14 @@ export const SLUG_REGEX = /^[a-zA-Z]([-_]?[a-zA-Z0-9])*$/; export const PAGE_SIZES = [10, 25, 50, 100]; export const DEFAULT_PAGE_SIZE = 10; +// Sales + +export const SALE_STATUS = { + NOT_BEGUN: {key: 'NOT_BEGUN', label: 'Ouverte prochaine... ', color: 'orange'}, + ONGOING: {key: 'ONGOING', label: 'En cours !', color: 'green'}, + FINISHED: {key: 'FINISHED', label: 'Terminée !', color: 'red'}, +} + // Orders export const VALID_ORDER_STATUS = [2, 4]; diff --git a/src/utils/format.js b/src/utils/format.js index 2bcac08..0838070 100644 --- a/src/utils/format.js +++ b/src/utils/format.js @@ -5,6 +5,7 @@ import { formatDistanceToNow, formatDistanceToNowStrict, } from "date-fns"; +import { fr } from "date-fns/locale"; window.__localeId__ = 'fr'; @@ -40,17 +41,18 @@ export function formatPrice(price, defaultValue = undefined) { export function formatDate(date, variant = "date") { if (typeof date === "string") date = parseISO(date); + const options = { locale: fr }; switch (variant) { case "date": - return lightFormat(date, "dd/MM/yyyy"); + return lightFormat(date, "dd/MM/yyyy", options); case "datetime": - return lightFormat(date, "dd/MM/yyyy HH:mm"); + return lightFormat(date, "dd/MM/yyyy HH:mm", options); case "datetimesec": - return lightFormat(date, "dd/MM/yyyy HH:mm:ss"); + return lightFormat(date, "dd/MM/yyyy HH:mm:ss", options); case "fromNow": - return formatDistanceToNow(date); + return formatDistanceToNow(date, options); case "fromNowStrict": - return formatDistanceToNowStrict(date); + return formatDistanceToNowStrict(date, options); default: throw Error(`Unknown format '${variant}'`); } From f5aee84be2d323b8479b6a633edcee7904f3e471 Mon Sep 17 00:00:00 2001 From: Alexandre Brasseur Date: Sun, 20 Dec 2020 12:01:53 +0100 Subject: [PATCH 7/7] feat(admin): Choose a color for a sale --- package.json | 2 +- src/components/common/FieldGenerator.jsx | 312 ++++++++++--------- src/pages/admin/SaleEditor/DetailsEditor.jsx | 3 +- src/utils/constants.js | 2 + 4 files changed, 170 insertions(+), 149 deletions(-) diff --git a/package.json b/package.json index 40644d8..82fb6be 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "copy-to-clipboard": "^3.3.1", "date-fns": "^2.16.1", "material-table": "^1.69.1", + "material-ui-color": "^0.4.6", "react": "^16.14.0", "react-dom": "^16.14.0", "react-redux": "^7.2.1", @@ -19,7 +20,6 @@ "react-scripts": "^3.4.4", "redux": "^4.0.5", "redux-promise-middleware": "^6.1.0", - "redux-thunk": "^2.3.0", "use-deep-compare-effect": "^1.4.0" }, "scripts": { diff --git a/src/components/common/FieldGenerator.jsx b/src/components/common/FieldGenerator.jsx index 43ae477..7aacfa2 100644 --- a/src/components/common/FieldGenerator.jsx +++ b/src/components/common/FieldGenerator.jsx @@ -1,147 +1,165 @@ -import React from 'react'; -import { - TextField, Checkbox, FormControlLabel, - FormControl, InputLabel, Select, MenuItem, Chip -} from '@material-ui/core'; -import { KeyboardDateTimePicker } from '@material-ui/pickers'; -// import CheckInput from './CheckInput'; - -class FieldGenerator { - - // TODO Finish error texts - - constructor(store, errors, onChange, keyPrefix = null, defaultProps = null) { - this.store = store; - this.errors = errors; - this.onChange = onChange; - this.keyPrefix = keyPrefix; - this.defaultProps = defaultProps; - } - - onChangeDatetime = name => value => { - const fakeEvent = { target: { name, value } }; - return this.onChange(fakeEvent); - } - - getKey = (key) => (this.keyPrefix ? `${this.keyPrefix}.${key}` : key) - - getValue = (key, params) => ( - key.split('.').reduce((props, step) => props[step], this.store) || params.default - ) - - getProps = (props) => ( - this.defaultProps ? { ...this.defaultProps, ...props } : props - ) - - displayErrors = (key) => ( - this.errors[key] ? this.errors[key].join('
') : '' - ) - - text = (key, label, props = {}) => ( - - ) - - number = (key, label, props = {}) => ( - - ) - - boolean = (key, label, props = {}) => ( - - } - /> - ) - - datetime = (key, label, props = {}) => ( - - ) - - select = (key, label, choices, props = {}) => ( - - {label} - - - ) - - selectChips = (key, label, choices, props = {}) => ( - - {label} - - - ) -} - -export default FieldGenerator; +import React from 'react'; +import { + TextField, Checkbox, FormControlLabel, + FormControl, InputLabel, Select, MenuItem, Chip +} from '@material-ui/core'; +import { KeyboardDateTimePicker } from '@material-ui/pickers'; +import { ColorPicker } from 'material-ui-color'; +// import CheckInput from './CheckInput'; + +class FieldGenerator { + + // TODO Finish error texts + + constructor(store, errors, onChange, keyPrefix = null, defaultProps = null) { + this.store = store; + this.errors = errors; + this.onChange = onChange; + this.keyPrefix = keyPrefix; + this.defaultProps = defaultProps; + } + + onChangeDatetime = name => value => { + const fakeEvent = { target: { name, value } }; + return this.onChange(fakeEvent); + } + + onChangeColor = name => color => { + const fakeEvent = { target: { name, value: `#${color.hex}` } }; + return this.onChange(fakeEvent); + } + + getKey = (key) => (this.keyPrefix ? `${this.keyPrefix}.${key}` : key) + + getValue = (key, params) => ( + key.split('.').reduce((props, step) => props[step], this.store) || params.default + ) + + getProps = (props) => ( + this.defaultProps ? { ...this.defaultProps, ...props } : props + ) + + displayErrors = (key) => ( + this.errors[key] ? this.errors[key].join('
') : '' + ) + + text = (key, label, props = {}) => ( + + ) + + number = (key, label, props = {}) => ( + + ) + + boolean = (key, label, props = {}) => ( + + } + /> + ) + + datetime = (key, label, props = {}) => ( + + ) + + select = (key, label, choices, props = {}) => ( + + {label} + + + ) + + selectChips = (key, label, choices, props = {}) => ( + + {label} + + + ) + + color = (key, label, props = {}) => ( + + {label} +
+ +
+
+ ) +} + +export default FieldGenerator; diff --git a/src/pages/admin/SaleEditor/DetailsEditor.jsx b/src/pages/admin/SaleEditor/DetailsEditor.jsx index fd31626..a3bb540 100644 --- a/src/pages/admin/SaleEditor/DetailsEditor.jsx +++ b/src/pages/admin/SaleEditor/DetailsEditor.jsx @@ -1,8 +1,8 @@ import React from 'react'; import { Box, Paper, Grid, Button } from '@material-ui/core'; -import { LoadingButton } from 'components/common/Buttons'; import FieldGenerator from 'components/common/FieldGenerator'; +import { LoadingButton } from 'components/common/Buttons'; import { mergeClasses, useFormStyles } from 'utils/styles'; export default function DetailsEditor({ disabled, editing, isCreator, ...props }) { @@ -20,6 +20,7 @@ export default function DetailsEditor({ disabled, editing, isCreator, ...props } {Field.text('id', 'ID', onlyCreate)} {Field.select('association', 'Association', props.assos, onlyCreate)} {Field.text('description', 'Description', { required: true, multiline: true, rows: 4 })} + {Field.color('color', 'Couleur')} diff --git a/src/utils/constants.js b/src/utils/constants.js index 7acc20d..82d647f 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -65,6 +65,8 @@ export const BLANK_SALE_DETAILS = { begin_at: null, end_at: null, + + color:"", }; export const BLANK_ITEMGROUP = {