all repos — caroster @ 7ce6d4879bd581f8a7a3ab70053eff5ee9bdeffe

[Octree] Group carpool to your event https://caroster.io

feat: :sparkles: Handle passenger emails & notifications

#160 #193
Tim Izzo tim@octree.ch
Wed, 20 Oct 2021 18:04:34 +0200
commit

7ce6d4879bd581f8a7a3ab70053eff5ee9bdeffe

parent

194f9d892d23c5018e47649cd5f79d2d95d9eca9

M backend/api/car/models/car.jsbackend/api/car/models/car.js

@@ -1,8 +1,50 @@

'use strict'; -/** - * Read the documentation (https://strapi.io/documentation/v3.x/concepts/models.html#lifecycle-hooks) - * to customize this model - */ +const _uniq = require('lodash/uniq'); + +const {STRAPI_URL = ''} = process.env; -module.exports = {}; +module.exports = { + lifecycles: { + async afterCreate(result) { + sendEmailsToWaitingList(result.event); + }, + }, +}; + +const sendEmailsToWaitingList = async event => { + const eventWaitingList = event?.waitingList || []; + const userEmails = eventWaitingList.map(user => user.email).filter(Boolean); + const templateId = await getTemplateId('waitinglist_notif'); + + try { + await strapi.plugins['email-designer'].services.email.sendTemplatedEmail( + { + to: _uniq(userEmails), + }, + { + templateId, + subject: `Caroster: nouvelle voiture pour ${event.name}`, + }, + { + eventName: event.name, + eventLink: `${STRAPI_URL}/e/${event.uuid}`, + } + ); + } catch (error) { + console.error(error); + strapi.log.error( + `Impossible to send email waiting list notification for event #${ + event.id + }. Error: ${JSON.stringify(error)}` + ); + } +}; + +const getTemplateId = async templateName => { + const template = await strapi.plugins[ + 'email-designer' + ].services.template.fetch({name: templateName}); + if (!template) throw new Error(`No email template with name ${templateName}`); + return template.id; +};
M backend/api/car/models/car.settings.jsonbackend/api/car/models/car.settings.json

@@ -2,11 +2,13 @@ {

"kind": "collectionType", "collectionName": "cars", "info": { - "name": "car" + "name": "car", + "description": "" }, "options": { "increments": true, - "timestamps": true + "timestamps": true, + "draftAndPublish": false }, "attributes": { "name": {

@@ -34,7 +36,9 @@ "via": "cars",

"model": "event" }, "passengers": { - "type": "json" + "type": "component", + "repeatable": true, + "component": "passenger.passenger" } } }
M backend/api/event/models/event.jsbackend/api/event/models/event.js

@@ -10,87 +10,73 @@

module.exports = { lifecycles: { async beforeCreate(event) { - if (!event.uuid) { - event.uuid = uuid.v4(); - } - + if (!event.uuid) event.uuid = uuid.v4(); // If user provides an address, get its lat/lng position using OSM API - if (event.address) { - const query = encodeURI(event.address); - try { - const {data} = await axios.get( - ` https://nominatim.openstreetmap.org/search?format=json&q=${query}` - ); - if (Array.isArray(data) && data.length > 0) { - const [entity] = data; - event.position = [entity.lat, entity.lon]; - } else - strapi.log.info( - `No location from Nominatim API for ${event.address}` - ); - } catch (error) { - strapi.log.error(error); - } - } - + if (event.address) event.position = getPosition(event.address); // If user accepts newsletters, subscribe it if (event.newsletter) sendgrid.subscribe(event.email); }, + async afterCreate(event) { + sendEmailToCreator(event); + }, + async beforeUpdate(params, event) { const eventInDb = await strapi.services.event.findOne(params); + if (!eventInDb.uuid) event.uuid = uuid.v4(); + if (event.address) event.position = getPosition(event.address); + }, + }, +}; - if (!eventInDb.uuid) { - event.uuid = uuid.v4(); - } +const getPosition = async address => { + try { + const query = encodeURI(address); + const {data} = await axios.get( + `https://nominatim.openstreetmap.org/search?format=json&q=${query}` + ); + if (Array.isArray(data) && data.length > 0) { + const [entity] = data; + return [entity.lat, entity.lon]; + } else strapi.log.info(`No location from Nominatim API for ${address}`); + } catch (error) { + strapi.log.error(error); + } +}; - if (event.address) { - const query = encodeURI(event.address); - try { - const {data} = await axios.get( - ` https://nominatim.openstreetmap.org/search?format=json&q=${query}` - ); - if (Array.isArray(data) && data.length > 0) { - const [entity] = data; - event.position = [entity.lat, entity.lon]; - } else - strapi.log.info( - `No location from Nominatim API for ${event.address}` - ); - } catch (error) { - strapi.log.error(error); - } +const sendEmailToCreator = async event => { + try { + const templateId = getTemplateId('creator_notif'); + await strapi.plugins['email-designer'].services.email.sendTemplatedEmail( + { + to: event.email, + }, + { + templateId, + subject: `Caroster: ${event.name}`, + }, + { + eventName: event.name, + eventTime: event.date + ? moment(event.date).format('dddd D MMMM YYYY') + : null, + eventAddress: event.address, + eventLink: `${STRAPI_URL}/e/${event.uuid}`, } - }, + ); + } catch (error) { + console.error(error); + strapi.log.error( + `Impossible to send email notification to ${event.email} for event#${ + event.id + }. Error: ${JSON.stringify(error)}` + ); + } +}; - async afterCreate(event) { - try { - await strapi.plugins[ - 'email-designer' - ].services.email.sendTemplatedEmail( - { - to: event.email, - }, - { - templateId: 1, - subject: `Caroster: ${event.name}`, - }, - { - eventName: event.name, - eventTime: event.date - ? moment(event.date).format('dddd D MMMM YYYY') - : null, - eventAddress: event.address, - eventLink: `${STRAPI_URL}/e/${event.uuid}`, - } - ); - } catch (error) { - console.error(error); - strapi.log.error( - `Impossible to send email notification to ${event.email} for event#${ - event.id - }. Error: ${JSON.stringify(error)}` - ); - } - }, - }, +const getTemplateId = async templateName => { + const template = await strapi.plugins[ + 'email-designer' + ].services.template.fetch({name: templateName}); + if (!template) throw new Error(`No email template with name ${templateName}`); + return template.id; };
M backend/api/event/models/event.settings.jsonbackend/api/event/models/event.settings.json

@@ -32,9 +32,6 @@ },

"position": { "type": "json" }, - "waiting_list": { - "type": "json" - }, "users": { "via": "events", "plugin": "users-permissions",

@@ -43,6 +40,11 @@ },

"uuid": { "type": "string", "unique": true + }, + "waitingList": { + "type": "component", + "repeatable": true, + "component": "passenger.passenger" } } }
A backend/components/passenger/passenger.json

@@ -0,0 +1,18 @@

+{ + "collectionName": "components_passenger_passengers", + "info": { + "name": "passenger", + "icon": "user" + }, + "options": {}, + "attributes": { + "name": { + "type": "string", + "required": true + }, + "email": { + "type": "email", + "required": false + } + } +}
M frontend/containers/Car/index.jsfrontend/containers/Car/index.tsx

@@ -12,9 +12,16 @@ import useAddToEvents from '../../hooks/useAddToEvents';

import { useUpdateCarMutation, useUpdateEventMutation, + Car as CarType, + EditComponentPassengerPassengerInput as PassengerInput, } from '../../generated/graphql'; -const Car = ({car}) => { +interface Props { + car: CarType; +} + +const Car = (props: Props) => { + const {car} = props; const classes = useStyles(); const {t} = useTranslation(); const event = useEventStore(s => s.event);

@@ -26,13 +33,16 @@ const {addToEvent} = useAddToEvents();

if (!car) return null; - const addPassenger = async passenger => { + const addPassenger = async (passenger: PassengerInput) => { try { + const existingPassengers = + car.passengers?.map(({__typename, ...item}) => item) || []; + const passengers = [...existingPassengers, passenger]; await updateCar({ variables: { id: car.id, carUpdate: { - passengers: [...(car.passengers || []), passenger], + passengers, }, }, });

@@ -42,17 +52,24 @@ console.error(error);

} }; - const removePassenger = async idx => { + const removePassenger = async (passengerId: string) => { if (car?.passengers) { try { + const {id, ...removedPassenger} = + car.passengers?.find(item => item.id === passengerId) || {}; + const existingPassengers = + car.passengers?.map(({__typename, ...item}) => item) || []; + const waitingList = [...event.waitingList, removedPassenger].map( + ({__typename, ...item}) => item + ); + const passengers = existingPassengers.filter( + item => item.id !== passengerId + ); await updateEvent({ variables: { id: event.id, eventUpdate: { - waiting_list: [ - ...(event.waiting_list || []), - car.passengers[idx], - ], + waitingList, }, }, });

@@ -60,7 +77,7 @@ await updateCar({

variables: { id: car.id, carUpdate: { - passengers: car.passengers.filter((_, i) => i !== idx), + passengers, }, }, });
M frontend/containers/CreateEvent/Step1.jsfrontend/containers/CreateEvent/Step1.js

@@ -9,6 +9,7 @@ import {useTranslation} from 'react-i18next';

import useDebounce from '../../hooks/useDebounce'; import useProfile from '../../hooks/useProfile'; import {CardActions} from '@material-ui/core'; +import {isValidEmail} from '../../lib/formValidation'; const Step1 = ({nextStep, event, addToEvent}) => { const {t} = useTranslation();

@@ -133,11 +134,5 @@ justifyContent: 'space-evenly',

textAlign: 'center', }, })); - -const isValidEmail = email => - // eslint-disable-next-line - /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( - email - ); export default Step1;
A frontend/containers/PassengersList/ClearButton.tsx

@@ -0,0 +1,25 @@

+import IconButton from '@material-ui/core/IconButton'; +import Icon from '@material-ui/core/Icon'; +import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'; + +interface Props { + onClick?: () => void; + icon: string; +} + +const ClearButton = (props: Props) => { + const {icon, onClick} = props; + + if (onClick) + return ( + <ListItemSecondaryAction> + <IconButton size="small" color="primary" onClick={onClick}> + <Icon>{icon}</Icon> + </IconButton> + </ListItemSecondaryAction> + ); + + return <Icon color="primary">{icon}</Icon>; +}; + +export default ClearButton;
D frontend/containers/PassengersList/Input.js

@@ -1,51 +0,0 @@

-import React, {useState} from 'react'; -import Box from '@material-ui/core/Box'; -import TextField from '@material-ui/core/TextField'; -import IconButton from '@material-ui/core/IconButton'; -import Divider from '@material-ui/core/Divider'; -import Icon from '@material-ui/core/Icon'; -import {useTranslation} from 'react-i18next'; - -const Input = ({addPassenger, id}) => { - const [name, setName] = useState(''); - const {t} = useTranslation(); - - const onSave = () => { - if (!!name) { - addPassenger(name); - setName(''); - } - }; - - const onKeyDown = e => { - if (e.keyCode === 13) onSave(); - }; - - return ( - <Box pb={1}> - <Box display="flex" flexDirection="row" alignItems="center" px={2} pb={2}> - <TextField - value={name} - onChange={e => setName(e.target.value)} - onKeyDown={onKeyDown} - fullWidth - label={t('car.passengers.add')} - id={`NewPassenger-${id}`} - name={`passenger-${id}`} - /> - <IconButton - color="primary" - edge="end" - size="small" - disabled={!name} - onClick={onSave} - > - <Icon>check</Icon> - </IconButton> - </Box> - <Divider /> - </Box> - ); -}; - -export default Input;
A frontend/containers/PassengersList/Input.tsx

@@ -0,0 +1,87 @@

+import {useState} from 'react'; +import Box from '@material-ui/core/Box'; +import TextField from '@material-ui/core/TextField'; +import IconButton from '@material-ui/core/IconButton'; +import Divider from '@material-ui/core/Divider'; +import Icon from '@material-ui/core/Icon'; +import {useTranslation} from 'react-i18next'; +import {EditComponentPassengerPassengerInput as PassengerInput} from '../../generated/graphql'; +import {makeStyles} from '@material-ui/core'; +import {isValidEmail} from '../../lib/formValidation'; + +interface Props { + addPassenger: (passenger: PassengerInput) => void; + id: number; +} + +const Input = (props: Props) => { + const {addPassenger, id} = props; + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [error, setError] = useState<string>(); + const classes = useStyles({showEmail: !!name}); + const {t} = useTranslation(); + + const onSave = () => { + if (email && !isValidEmail(email)) setError('email'); + else if (name) { + addPassenger({name, email}); + setName(''); + setEmail(''); + setError(null); + } + }; + + const onKeyDown = e => { + if (e.keyCode === 13) onSave(); + }; + + return ( + <Box pb={1}> + <Box display="flex" flexDirection="row" alignItems="center" px={2}> + <TextField + value={name} + onChange={e => setName(e.target.value)} + onKeyDown={onKeyDown} + fullWidth + label={t('car.passengers.add')} + id={`NewPassenger-${id}-name`} + name={`passenger-${id}-name`} + /> + <IconButton + color="primary" + edge="end" + size="small" + disabled={!name} + onClick={onSave} + > + <Icon>check</Icon> + </IconButton> + </Box> + <Box pl={2} pt={1} pr={5} mb={2} className={classes.emailBox}> + <TextField + value={email} + onChange={e => setEmail(e.target.value)} + onKeyDown={onKeyDown} + fullWidth + label={t`car.passengers.email`} + id={`NewPassenger-${id}-email`} + name={`passenger-${id}-email`} + helperText={t`car.passengers.emailHelper`} + error={error === 'email'} + /> + </Box> + <Divider /> + </Box> + ); +}; + +const useStyles = makeStyles(theme => ({ + emailBox: { + transition: 'all 0.3s ease', + maxHeight: ({showEmail}) => (showEmail ? '5rem' : 0), + overflow: 'hidden', + }, +})); + +export default Input;
D frontend/containers/PassengersList/Passenger.js

@@ -1,41 +0,0 @@

-import React from 'react'; -import ListItemAvatar from '@material-ui/core/ListItemAvatar'; -import ListItemIcon from '@material-ui/core/ListItemIcon'; -import ListItemText from '@material-ui/core/ListItemText'; -import Icon from '@material-ui/core/Icon'; -import {makeStyles} from '@material-ui/core/styles'; -import {useTranslation} from 'react-i18next'; - -const Passenger = ({passenger, button}) => { - const {t} = useTranslation(); - const classes = useStyles(); - - return !!passenger ? ( - <> - <ListItemText primary={passenger} /> - {button} - </> - ) : ( - <> - <ListItemAvatar> - <ListItemIcon color="disabled"> - <Icon>person</Icon> - </ListItemIcon> - </ListItemAvatar> - <ListItemText - classes={{ - root: classes.empty, - }} - primary={t('car.passengers.empty')} - /> - </> - ); -}; - -const useStyles = makeStyles(theme => ({ - empty: { - color: theme.palette.text.secondary, - }, -})); - -export default Passenger;
A frontend/containers/PassengersList/Passenger.tsx

@@ -0,0 +1,51 @@

+import {ReactNode} from 'react'; +import ListItemAvatar from '@material-ui/core/ListItemAvatar'; +import ListItemIcon from '@material-ui/core/ListItemIcon'; +import ListItemText from '@material-ui/core/ListItemText'; +import Icon from '@material-ui/core/Icon'; +import {makeStyles} from '@material-ui/core/styles'; +import {useTranslation} from 'react-i18next'; +import {ComponentPassengerPassenger} from '../../generated/graphql'; + +interface Props { + passenger?: ComponentPassengerPassenger; + button?: ReactNode; +} + +const Passenger = (props: Props) => { + const {passenger, button} = props; + const {t} = useTranslation(); + const classes = useStyles(); + + if (passenger) + return ( + <> + <ListItemText primary={passenger.name} /> + {button} + </> + ); + else + return ( + <> + <ListItemAvatar> + <ListItemIcon color="disabled"> + <Icon>person</Icon> + </ListItemIcon> + </ListItemAvatar> + <ListItemText + primary={t('car.passengers.empty')} + classes={{ + root: classes.empty, + }} + /> + </> + ); +}; + +const useStyles = makeStyles(theme => ({ + empty: { + color: theme.palette.text.secondary, + }, +})); + +export default Passenger;
D frontend/containers/PassengersList/index.js

@@ -1,77 +0,0 @@

-import List from '@material-ui/core/List'; -import ListItem from '@material-ui/core/ListItem'; -import IconButton from '@material-ui/core/IconButton'; -import Icon from '@material-ui/core/Icon'; -import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'; -import {makeStyles} from '@material-ui/core/styles'; -import Input from './Input'; -import Passenger from './Passenger'; - -const PassengersList = ({ - passengers, - places, - addPassenger, - icon, - onClick, - onPress, - disabled, -}) => { - const classes = useStyles(); - let list = passengers; - - if (places) { - const emptyList = [...Array(places)]; - list = Array.isArray(passengers) - ? emptyList.map((u, index) => passengers[index]) - : emptyList; - } - - return ( - <div className={classes.container}> - {(places - ? passengers - ? places - passengers.length > 0 - : places > 0 - : true) && ( - <Input addPassenger={addPassenger} id={!!places ? 'Car' : 'Waiting'} /> - )} - <List disablePadding> - {!!list && - list.map((passenger, index) => ( - <ListItem - key={index} - disabled={disabled} - button={!!onPress} - onClick={() => !!onPress && onPress(index)} - > - <Passenger - key={index} - passenger={passenger} - button={getClearButton(index, onClick, icon)} - /> - </ListItem> - ))} - </List> - </div> - ); -}; - -const getClearButton = (index, onClick, icon) => { - return onClick ? ( - <ListItemSecondaryAction> - <IconButton size="small" color="primary" onClick={() => onClick(index)}> - <Icon>{icon}</Icon> - </IconButton> - </ListItemSecondaryAction> - ) : ( - <Icon color="primary">{icon}</Icon> - ); -}; - -const useStyles = makeStyles(theme => ({ - container: { - padding: theme.spacing(1, 0), - }, -})); - -export default PassengersList;
A frontend/containers/PassengersList/index.tsx

@@ -0,0 +1,77 @@

+import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; + +import {makeStyles} from '@material-ui/core/styles'; +import Input from './Input'; +import Passenger from './Passenger'; +import { + ComponentPassengerPassenger, + EditComponentPassengerPassengerInput as PassengerInput, +} from '../../generated/graphql'; +import ClearButton from './ClearButton'; + +interface Props { + passengers: ComponentPassengerPassenger[]; + icon: string; + disabled?: boolean; + places?: number; + onPress?: (passengerId: string) => void; + onClick?: (passengerId: string) => void; + addPassenger: (passenger: PassengerInput) => void; +} + +const PassengersList = (props: Props) => { + const {passengers, places, addPassenger, icon, onClick, onPress, disabled} = + props; + const classes = useStyles(); + let list = passengers; + + if (places) { + const emptyList = [...Array(places)]; + list = Array.isArray(passengers) + ? emptyList.map((u, index) => passengers[index]) + : emptyList; + } + + return ( + <div className={classes.container}> + {(places + ? passengers + ? places - passengers.length > 0 + : places > 0 + : true) && ( + <Input addPassenger={addPassenger} id={!!places ? 'Car' : 'Waiting'} /> + )} + <List disablePadding> + {!!list && + list.map((passenger, index) => ( + <ListItem + key={index} + disabled={disabled} + button={!!onPress} + onClick={() => !!onPress && onPress(passenger.id)} + > + <Passenger + key={index} + passenger={passenger} + button={ + <ClearButton + icon={icon} + onClick={() => onClick && onClick(passenger.id)} + /> + } + /> + </ListItem> + ))} + </List> + </div> + ); +}; + +const useStyles = makeStyles(theme => ({ + container: { + padding: theme.spacing(1, 0), + }, +})); + +export default PassengersList;
M frontend/containers/WaitingList/index.jsfrontend/containers/WaitingList/index.tsx

@@ -24,8 +24,8 @@ const event = useEventStore(s => s.event);

const addToast = useToastStore(s => s.addToast); const {addToEvent} = useAddToEvents(); const [isEditing, toggleEditing] = useReducer(i => !i, false); - const [removing, setRemoving] = useState(null); - const [adding, setAdding] = useState(null); + const [removingPassenger, setRemovingPassenger] = useState(null); + const [addingPassenger, setAddingPassenger] = useState(null); const cars = event?.cars?.length > 0 ? event.cars.slice().sort(sortCars) : []; const [updateEvent] = useUpdateEventMutation(); const [updateCar] = useUpdateCarMutation();

@@ -38,59 +38,65 @@ return count + seats - passengers.length;

}, 0); }, [cars]); - const saveWaitingList = useCallback( - async (waitingList, i18nError) => { + const addPassenger = useCallback( + async passenger => { try { + const waitingList = [...event.waitingList, passenger].map( + ({__typename, ...item}) => item + ); await updateEvent({ - variables: {id: event.id, eventUpdate: {waiting_list: waitingList}}, + variables: {id: event.id, eventUpdate: {waitingList}}, }); addToEvent(event.id); } catch (error) { console.error(error); - addToast(t(i18nError)); + addToast(t('passenger.errors.cant_add_passenger')); } }, - [event, addToEvent] // eslint-disable-line - ); - - const addPassenger = useCallback( - async passenger => - saveWaitingList( - [...(event.waiting_list || []), passenger], - 'passenger.errors.cant_add_passenger' - ), - [event, saveWaitingList] // eslint-disable-line + [event] ); const removePassenger = useCallback( - async index => { - return saveWaitingList( - event.waiting_list.filter((_, i) => i !== index), - 'passenger.errors.cant_remove_passenger' - ); + async passengerIndex => { + try { + const waitingList = event.waitingList + .filter((_, idx) => idx !== passengerIndex) + .map(({__typename, ...item}) => item); + await updateEvent({ + variables: {id: event.id, eventUpdate: {waitingList}}, + }); + addToEvent(event.id); + } catch (error) { + console.error(error); + addToast(t('passenger.errors.cant_remove_passenger')); + } }, - [event, saveWaitingList] // eslint-disable-line + [event] ); const selectCar = useCallback( async car => { try { + const {id, ...passenger} = addingPassenger; + const carPassengers = [...(car.passengers || []), passenger].map( + ({__typename, ...item}) => item + ); await updateCar({ variables: { id: car.id, carUpdate: { - passengers: [ - ...(car.passengers || []), - event.waiting_list[adding], - ], + passengers: carPassengers, }, }, }); + const waitingList = event.waitingList + .filter(item => item.id !== id) + .map(({__typename, ...item}) => item); await updateEvent({ variables: { id: event.id, eventUpdate: { - waiting_list: event.waiting_list.filter((_, i) => i !== adding), + waitingList, }, }, });

@@ -98,17 +104,20 @@ } catch (error) {

console.error(error); addToast(t('passenger.errors.cant_select_car')); } - setAdding(null); + setAddingPassenger(null); }, - [event, adding] // eslint-disable-line + [event, addingPassenger] // eslint-disable-line ); const onPress = useCallback( - index => { - if (isEditing) setRemoving(index); - else setAdding(index); + (passengerId: string) => { + const selectedPassenger = event.waitingList.find( + item => item.id === passengerId + ); + if (isEditing) setRemovingPassenger(selectedPassenger); + else setAddingPassenger(selectedPassenger); }, - [isEditing] + [isEditing, event] ); return (

@@ -119,7 +128,7 @@ <IconButton

size="small" color="primary" className={classes.editBtn} - disabled={!event.waiting_list || !event.waiting_list.length} + disabled={!event.waitingList?.length} onClick={toggleEditing} > {isEditing ? <Icon>check</Icon> : <Icon>edit</Icon>}

@@ -131,7 +140,7 @@ </Typography>

</div> <Divider /> <PassengersList - passengers={event.waiting_list} + passengers={event.waitingList} addPassenger={addPassenger} onPress={onPress} icon={isEditing ? 'close' : 'chevron_right'}

@@ -143,19 +152,19 @@ text={

<Trans i18nKey="passenger.actions.remove_alert" values={{ - name: event.waiting_list ? event.waiting_list[removing] : null, + name: removingPassenger?.name, }} components={{italic: <i />, bold: <strong />}} /> } - open={removing !== null} - onClose={() => setRemoving(null)} - onRemove={() => removePassenger(removing)} + open={!!removingPassenger} + onClose={() => setRemovingPassenger(null)} + onRemove={() => removePassenger(removingPassenger)} /> <CarDialog cars={cars} - open={adding !== null} - onClose={() => setAdding(null)} + open={!!addingPassenger} + onClose={() => setAddingPassenger(null)} onSelect={selectCar} /> </>
M frontend/generated/graphql.tsxfrontend/generated/graphql.tsx

@@ -45,7 +45,7 @@ departure?: Maybe<Scalars['DateTime']>;

phone_number?: Maybe<Scalars['String']>; details?: Maybe<Scalars['String']>; event?: Maybe<Event>; - passengers?: Maybe<Scalars['JSON']>; + passengers?: Maybe<Array<Maybe<ComponentPassengerPassenger>>>; }; export type CarAggregator = {

@@ -127,12 +127,6 @@ key?: Maybe<Scalars['String']>;

connection?: Maybe<CarConnection>; }; -export type CarConnectionPassengers = { - __typename?: 'CarConnectionPassengers'; - key?: Maybe<Scalars['JSON']>; - connection?: Maybe<CarConnection>; -}; - export type CarConnectionPhone_Number = { __typename?: 'CarConnectionPhone_number'; key?: Maybe<Scalars['String']>;

@@ -163,7 +157,6 @@ departure?: Maybe<Array<Maybe<CarConnectionDeparture>>>;

phone_number?: Maybe<Array<Maybe<CarConnectionPhone_Number>>>; details?: Maybe<Array<Maybe<CarConnectionDetails>>>; event?: Maybe<Array<Maybe<CarConnectionEvent>>>; - passengers?: Maybe<Array<Maybe<CarConnectionPassengers>>>; }; export type CarInput = {

@@ -174,11 +167,23 @@ departure?: Maybe<Scalars['DateTime']>;

phone_number?: Maybe<Scalars['String']>; details?: Maybe<Scalars['String']>; event?: Maybe<Scalars['ID']>; - passengers?: Maybe<Scalars['JSON']>; + passengers?: Maybe<Array<Maybe<ComponentPassengerPassengerInput>>>; created_by?: Maybe<Scalars['ID']>; updated_by?: Maybe<Scalars['ID']>; }; +export type ComponentPassengerPassenger = { + __typename?: 'ComponentPassengerPassenger'; + id: Scalars['ID']; + name: Scalars['String']; + email?: Maybe<Scalars['String']>; +}; + +export type ComponentPassengerPassengerInput = { + name: Scalars['String']; + email?: Maybe<Scalars['String']>; +}; + export type Dependency = {

@@ -196,6 +201,7 @@ __typename?: 'EmailDesignerEmailTemplate';

id: Scalars['ID']; created_at: Scalars['DateTime']; updated_at: Scalars['DateTime']; + sourceCodeToTemplateId?: Maybe<Scalars['Int']>; design?: Maybe<Scalars['JSON']>; name?: Maybe<Scalars['String']>; subject?: Maybe<Scalars['String']>;

@@ -206,6 +212,7 @@ tags?: Maybe<Scalars['JSON']>;

}; export type EmailTemplateInput = { + sourceCodeToTemplateId?: Maybe<Scalars['Int']>; design?: Maybe<Scalars['JSON']>; name?: Maybe<Scalars['String']>; subject?: Maybe<Scalars['String']>;

@@ -227,8 +234,8 @@ email: Scalars['String'];

date?: Maybe<Scalars['Date']>; address?: Maybe<Scalars['String']>; position?: Maybe<Scalars['JSON']>; - waiting_list?: Maybe<Scalars['JSON']>; uuid?: Maybe<Scalars['String']>; + waitingList?: Maybe<Array<Maybe<ComponentPassengerPassenger>>>; cars?: Maybe<Array<Maybe<Car>>>; users?: Maybe<Array<Maybe<UsersPermissionsUser>>>; };

@@ -316,12 +323,6 @@ key?: Maybe<Scalars['String']>;

connection?: Maybe<EventConnection>; }; -export type EventConnectionWaiting_List = { - __typename?: 'EventConnectionWaiting_list'; - key?: Maybe<Scalars['JSON']>; - connection?: Maybe<EventConnection>; -}; - export type EventGroupBy = { __typename?: 'EventGroupBy'; id?: Maybe<Array<Maybe<EventConnectionId>>>;

@@ -332,7 +333,6 @@ email?: Maybe<Array<Maybe<EventConnectionEmail>>>;

date?: Maybe<Array<Maybe<EventConnectionDate>>>; address?: Maybe<Array<Maybe<EventConnectionAddress>>>; position?: Maybe<Array<Maybe<EventConnectionPosition>>>; - waiting_list?: Maybe<Array<Maybe<EventConnectionWaiting_List>>>; uuid?: Maybe<Array<Maybe<EventConnectionUuid>>>; };

@@ -343,9 +343,9 @@ date?: Maybe<Scalars['Date']>;

address?: Maybe<Scalars['String']>; cars?: Maybe<Array<Maybe<Scalars['ID']>>>; position?: Maybe<Scalars['JSON']>; - waiting_list?: Maybe<Scalars['JSON']>; users?: Maybe<Array<Maybe<Scalars['ID']>>>; uuid?: Maybe<Scalars['String']>; + waitingList?: Maybe<Array<Maybe<ComponentPassengerPassengerInput>>>; created_by?: Maybe<Scalars['ID']>; updated_by?: Maybe<Scalars['ID']>; newsletter?: Maybe<Scalars['Boolean']>;

@@ -401,7 +401,7 @@ };

-export type Morph = Dependency | Info | UsersPermissionsMe | UsersPermissionsMeRole | UsersPermissionsLoginPayload | UserPermissionsPasswordPayload | Car | CarConnection | CarAggregator | CarAggregatorSum | CarAggregatorAvg | CarAggregatorMin | CarAggregatorMax | CarGroupBy | CarConnectionId | CarConnectionCreated_At | CarConnectionUpdated_At | CarConnectionName | CarConnectionSeats | CarConnectionMeeting | CarConnectionDeparture | CarConnectionPhone_Number | CarConnectionDetails | CarConnectionEvent | CarConnectionPassengers | CreateCarPayload | UpdateCarPayload | DeleteCarPayload | Event | EventConnection | EventAggregator | EventGroupBy | EventConnectionId | EventConnectionCreated_At | EventConnectionUpdated_At | EventConnectionName | EventConnectionEmail | EventConnectionDate | EventConnectionAddress | EventConnectionPosition | EventConnectionWaiting_List | EventConnectionUuid | CreateEventPayload | UpdateEventPayload | DeleteEventPayload | Page | PageConnection | PageAggregator | PageGroupBy | PageConnectionId | PageConnectionCreated_At | PageConnectionUpdated_At | PageConnectionName | PageConnectionContent | PageConnectionType | CreatePagePayload | UpdatePagePayload | DeletePagePayload | Settings | UpdateSettingPayload | DeleteSettingPayload | EmailDesignerEmailTemplate | UploadFile | UploadFileConnection | UploadFileAggregator | UploadFileAggregatorSum | UploadFileAggregatorAvg | UploadFileAggregatorMin | UploadFileAggregatorMax | UploadFileGroupBy | UploadFileConnectionId | UploadFileConnectionCreated_At | UploadFileConnectionUpdated_At | UploadFileConnectionName | UploadFileConnectionAlternativeText | UploadFileConnectionCaption | UploadFileConnectionWidth | UploadFileConnectionHeight | UploadFileConnectionFormats | UploadFileConnectionHash | UploadFileConnectionExt | UploadFileConnectionMime | UploadFileConnectionSize | UploadFileConnectionUrl | UploadFileConnectionPreviewUrl | UploadFileConnectionProvider | UploadFileConnectionProvider_Metadata | DeleteFilePayload | UsersPermissionsPermission | UsersPermissionsRole | UsersPermissionsRoleConnection | UsersPermissionsRoleAggregator | UsersPermissionsRoleGroupBy | UsersPermissionsRoleConnectionId | UsersPermissionsRoleConnectionName | UsersPermissionsRoleConnectionDescription | UsersPermissionsRoleConnectionType | CreateRolePayload | UpdateRolePayload | DeleteRolePayload | UsersPermissionsUser | UsersPermissionsUserConnection | UsersPermissionsUserAggregator | UsersPermissionsUserGroupBy | UsersPermissionsUserConnectionId | UsersPermissionsUserConnectionCreated_At | UsersPermissionsUserConnectionUpdated_At | UsersPermissionsUserConnectionUsername | UsersPermissionsUserConnectionFirstName | UsersPermissionsUserConnectionLastName | UsersPermissionsUserConnectionEmail | UsersPermissionsUserConnectionProvider | UsersPermissionsUserConnectionConfirmed | UsersPermissionsUserConnectionBlocked | UsersPermissionsUserConnectionRole | CreateUserPayload | UpdateUserPayload | DeleteUserPayload; +export type Morph = Dependency | Info | UsersPermissionsMe | UsersPermissionsMeRole | UsersPermissionsLoginPayload | UserPermissionsPasswordPayload | Car | CarConnection | CarAggregator | CarAggregatorSum | CarAggregatorAvg | CarAggregatorMin | CarAggregatorMax | CarGroupBy | CarConnectionId | CarConnectionCreated_At | CarConnectionUpdated_At | CarConnectionName | CarConnectionSeats | CarConnectionMeeting | CarConnectionDeparture | CarConnectionPhone_Number | CarConnectionDetails | CarConnectionEvent | CreateCarPayload | UpdateCarPayload | DeleteCarPayload | Event | EventConnection | EventAggregator | EventGroupBy | EventConnectionId | EventConnectionCreated_At | EventConnectionUpdated_At | EventConnectionName | EventConnectionEmail | EventConnectionDate | EventConnectionAddress | EventConnectionPosition | EventConnectionUuid | CreateEventPayload | UpdateEventPayload | DeleteEventPayload | Page | PageConnection | PageAggregator | PageGroupBy | PageConnectionId | PageConnectionCreated_At | PageConnectionUpdated_At | PageConnectionName | PageConnectionContent | PageConnectionType | CreatePagePayload | UpdatePagePayload | DeletePagePayload | Settings | UpdateSettingPayload | DeleteSettingPayload | EmailDesignerEmailTemplate | UploadFile | UploadFileConnection | UploadFileAggregator | UploadFileAggregatorSum | UploadFileAggregatorAvg | UploadFileAggregatorMin | UploadFileAggregatorMax | UploadFileGroupBy | UploadFileConnectionId | UploadFileConnectionCreated_At | UploadFileConnectionUpdated_At | UploadFileConnectionName | UploadFileConnectionAlternativeText | UploadFileConnectionCaption | UploadFileConnectionWidth | UploadFileConnectionHeight | UploadFileConnectionFormats | UploadFileConnectionHash | UploadFileConnectionExt | UploadFileConnectionMime | UploadFileConnectionSize | UploadFileConnectionUrl | UploadFileConnectionPreviewUrl | UploadFileConnectionProvider | UploadFileConnectionProvider_Metadata | DeleteFilePayload | UsersPermissionsPermission | UsersPermissionsRole | UsersPermissionsRoleConnection | UsersPermissionsRoleAggregator | UsersPermissionsRoleGroupBy | UsersPermissionsRoleConnectionId | UsersPermissionsRoleConnectionName | UsersPermissionsRoleConnectionDescription | UsersPermissionsRoleConnectionType | CreateRolePayload | UpdateRolePayload | DeleteRolePayload | UsersPermissionsUser | UsersPermissionsUserConnection | UsersPermissionsUserAggregator | UsersPermissionsUserGroupBy | UsersPermissionsUserConnectionId | UsersPermissionsUserConnectionCreated_At | UsersPermissionsUserConnectionUpdated_At | UsersPermissionsUserConnectionUsername | UsersPermissionsUserConnectionFirstName | UsersPermissionsUserConnectionLastName | UsersPermissionsUserConnectionEmail | UsersPermissionsUserConnectionProvider | UsersPermissionsUserConnectionConfirmed | UsersPermissionsUserConnectionBlocked | UsersPermissionsUserConnectionRole | UsersPermissionsUserConnectionOnboardingUser | UsersPermissionsUserConnectionOnboardingCreator | CreateUserPayload | UpdateUserPayload | DeleteUserPayload | ComponentPassengerPassenger; export type Mutation = { __typename?: 'Mutation';

@@ -1070,8 +1070,10 @@ resetPasswordToken?: Maybe<Scalars['String']>;

confirmed?: Maybe<Scalars['Boolean']>; blocked?: Maybe<Scalars['Boolean']>; role?: Maybe<Scalars['ID']>; - events?: Maybe<Array<Maybe<Scalars['ID']>>>; confirmationToken?: Maybe<Scalars['String']>; + events?: Maybe<Array<Maybe<Scalars['ID']>>>; + onboardingUser?: Maybe<Scalars['Boolean']>; + onboardingCreator?: Maybe<Scalars['Boolean']>; created_by?: Maybe<Scalars['ID']>; updated_by?: Maybe<Scalars['ID']>; };

@@ -1213,6 +1215,8 @@ provider?: Maybe<Scalars['String']>;

confirmed?: Maybe<Scalars['Boolean']>; blocked?: Maybe<Scalars['Boolean']>; role?: Maybe<UsersPermissionsRole>; + onboardingUser?: Maybe<Scalars['Boolean']>; + onboardingCreator?: Maybe<Scalars['Boolean']>; events?: Maybe<Array<Maybe<Event>>>; };

@@ -1279,6 +1283,18 @@ key?: Maybe<Scalars['String']>;

connection?: Maybe<UsersPermissionsUserConnection>; }; +export type UsersPermissionsUserConnectionOnboardingCreator = { + __typename?: 'UsersPermissionsUserConnectionOnboardingCreator'; + key?: Maybe<Scalars['Boolean']>; + connection?: Maybe<UsersPermissionsUserConnection>; +}; + +export type UsersPermissionsUserConnectionOnboardingUser = { + __typename?: 'UsersPermissionsUserConnectionOnboardingUser'; + key?: Maybe<Scalars['Boolean']>; + connection?: Maybe<UsersPermissionsUserConnection>; +}; + export type UsersPermissionsUserConnectionProvider = { __typename?: 'UsersPermissionsUserConnectionProvider'; key?: Maybe<Scalars['String']>;

@@ -1316,6 +1332,8 @@ provider?: Maybe<Array<Maybe<UsersPermissionsUserConnectionProvider>>>;

confirmed?: Maybe<Array<Maybe<UsersPermissionsUserConnectionConfirmed>>>; blocked?: Maybe<Array<Maybe<UsersPermissionsUserConnectionBlocked>>>; role?: Maybe<Array<Maybe<UsersPermissionsUserConnectionRole>>>; + onboardingUser?: Maybe<Array<Maybe<UsersPermissionsUserConnectionOnboardingUser>>>; + onboardingCreator?: Maybe<Array<Maybe<UsersPermissionsUserConnectionOnboardingCreator>>>; }; export type CreateCarInput = {

@@ -1430,12 +1448,19 @@ departure?: Maybe<Scalars['DateTime']>;

phone_number?: Maybe<Scalars['String']>; details?: Maybe<Scalars['String']>; event?: Maybe<Scalars['ID']>; - passengers?: Maybe<Scalars['JSON']>; + passengers?: Maybe<Array<Maybe<EditComponentPassengerPassengerInput>>>; created_by?: Maybe<Scalars['ID']>; updated_by?: Maybe<Scalars['ID']>; }; +export type EditComponentPassengerPassengerInput = { + id?: Maybe<Scalars['ID']>; + name?: Maybe<Scalars['String']>; + email?: Maybe<Scalars['String']>; +}; + export type EditEmailTemplateInput = { + sourceCodeToTemplateId?: Maybe<Scalars['Int']>; design?: Maybe<Scalars['JSON']>; name?: Maybe<Scalars['String']>; subject?: Maybe<Scalars['String']>;

@@ -1454,9 +1479,9 @@ date?: Maybe<Scalars['Date']>;

address?: Maybe<Scalars['String']>; cars?: Maybe<Array<Maybe<Scalars['ID']>>>; position?: Maybe<Scalars['JSON']>; - waiting_list?: Maybe<Scalars['JSON']>; users?: Maybe<Array<Maybe<Scalars['ID']>>>; uuid?: Maybe<Scalars['String']>; + waitingList?: Maybe<Array<Maybe<EditComponentPassengerPassengerInput>>>; created_by?: Maybe<Scalars['ID']>; updated_by?: Maybe<Scalars['ID']>; };

@@ -1517,8 +1542,10 @@ resetPasswordToken?: Maybe<Scalars['String']>;

confirmed?: Maybe<Scalars['Boolean']>; blocked?: Maybe<Scalars['Boolean']>; role?: Maybe<Scalars['ID']>; - events?: Maybe<Array<Maybe<Scalars['ID']>>>; confirmationToken?: Maybe<Scalars['String']>; + events?: Maybe<Array<Maybe<Scalars['ID']>>>; + onboardingUser?: Maybe<Scalars['Boolean']>; + onboardingCreator?: Maybe<Scalars['Boolean']>; created_by?: Maybe<Scalars['ID']>; updated_by?: Maybe<Scalars['ID']>; old_password?: Maybe<Scalars['String']>;

@@ -1659,8 +1686,11 @@ );

export type CarFieldsFragment = ( { __typename?: 'Car' } - & Pick<Car, 'id' | 'name' | 'seats' | 'meeting' | 'departure' | 'phone_number' | 'details' | 'passengers'> - & { event?: Maybe<( + & Pick<Car, 'id' | 'name' | 'seats' | 'meeting' | 'departure' | 'phone_number' | 'details'> + & { passengers?: Maybe<Array<Maybe<( + { __typename?: 'ComponentPassengerPassenger' } + & Pick<ComponentPassengerPassenger, 'id' | 'name'> + )>>>, event?: Maybe<( { __typename?: 'Event' } & Pick<Event, 'id' | 'name'> )> }

@@ -1717,10 +1747,17 @@ );

export type EventFieldsFragment = ( { __typename?: 'Event' } - & Pick<Event, 'id' | 'uuid' | 'name' | 'email' | 'date' | 'address' | 'position' | 'waiting_list'> - & { cars?: Maybe<Array<Maybe<( + & Pick<Event, 'id' | 'uuid' | 'name' | 'email' | 'date' | 'address' | 'position'> + & { waitingList?: Maybe<Array<Maybe<( + { __typename?: 'ComponentPassengerPassenger' } + & Pick<ComponentPassengerPassenger, 'id' | 'name'> + )>>>, cars?: Maybe<Array<Maybe<( { __typename?: 'Car' } - & Pick<Car, 'id' | 'name' | 'seats' | 'meeting' | 'departure' | 'details' | 'phone_number' | 'passengers'> + & Pick<Car, 'id' | 'name' | 'seats' | 'meeting' | 'departure' | 'details' | 'phone_number'> + & { passengers?: Maybe<Array<Maybe<( + { __typename?: 'ComponentPassengerPassenger' } + & Pick<ComponentPassengerPassenger, 'id' | 'name'> + )>>> } )>>> } );

@@ -1855,7 +1892,10 @@ meeting

departure phone_number details - passengers + passengers { + id + name + } event { id name

@@ -1871,7 +1911,10 @@ email

date address position - waiting_list + waitingList { + id + name + } cars { id name

@@ -1880,7 +1923,10 @@ meeting

departure details phone_number - passengers + passengers { + id + name + } } } `;
M frontend/graphql/car.gqlfrontend/graphql/car.gql

@@ -6,7 +6,10 @@ meeting

departure phone_number details - passengers + passengers { + id + name + } event { id name
M frontend/graphql/event.gqlfrontend/graphql/event.gql

@@ -6,7 +6,10 @@ email

date address position - waiting_list + waitingList { + id + name + } cars { id name

@@ -15,7 +18,10 @@ meeting

departure details phone_number - passengers + passengers { + id + name + } } }
A frontend/lib/formValidation.ts

@@ -0,0 +1,5 @@

+// eslint-disable-next-line +const emailRegex = + /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + +export const isValidEmail = (email: string) => emailRegex.test(email);
M frontend/locales/fr.jsonfrontend/locales/fr.json

@@ -102,7 +102,9 @@ "removed": "La voiture a été supprimée"

}, "passengers": { "empty": "Place disponible", - "add": "Ajouter un passager" + "add": "Ajouter un passager", + "email": "Votre email", + "emailHelper": "Optionnel - Soyez notifié si des voitures sont ajoutées" }, "errors": { "cant_create": "Impossible de créer la voiture",
M frontend/pages/e/[uuid].tsxfrontend/pages/e/[uuid].tsx

@@ -58,6 +58,7 @@ try {

const {id, ...data} = eventUpdate; delete data.__typename; delete data.cars; + delete data.waitingList; await updateEvent({variables: {id, eventUpdate: data}}); setIsEditing(false); } catch (error) {