all repos — caroster @ 5cebc5ee581a8c1bb7674e3b338c56de1cb5d847

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

feat: ✨ Setup onboarding tours
Karian Før karian@octree.ch
Thu, 18 Nov 2021 09:41:12 +0000
commit

5cebc5ee581a8c1bb7674e3b338c56de1cb5d847

parent

0106eb023ccf03f53408d4a3a523b5430f51d898

50 files changed, 793 insertions(+), 255 deletions(-)

jump to
A backend/.strapi-updater.json

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

+{ + "latest": "3.6.8", + "lastUpdateCheck": 1637226107128, + "lastNotification": 1637085344214 +}
M backend/extensions/users-permissions/controllers/User.jsbackend/extensions/users-permissions/controllers/User.js

@@ -23,6 +23,8 @@ password,

old_password, firstName, lastName, + onboardingUser, + onboardingCreator, lang, events, } = body;

@@ -51,6 +53,8 @@ email,

password, firstName, lastName, + onboardingUser, + onboardingCreator, lang, events: updatedEvents, })
M frontend/components/Paper/index.jsfrontend/components/Paper/index.js

@@ -4,6 +4,7 @@ import {makeStyles} from '@material-ui/core/styles';

const Paper = ({className, ...props}) => { const classes = useStyles(); + return ( <PaperMUI classes={{root: classes.root, parent: className}} {...props} /> );
M frontend/containers/Car/HeaderEditing.tsxfrontend/containers/Car/HeaderEditing.tsx

@@ -143,67 +143,68 @@ >

<Icon>done</Icon> </IconButton> <DatePicker - id="NewCarDate" - className={classes.picker} - fullWidth label={t('car.creation.date')} - format="DD/MM/YYYY" + fullWidth + helperText=" " value={date} onChange={setDate} + format="DD/MM/YYYY" + cancelLabel={t('generic.cancel')} + autoFocus + id="NewCarDate" /> <TimePicker - id="NewCarTime" - className={classes.picker} + label={t('car.creation.time')} fullWidth - label={t('car.creation.time')} + helperText=" " value={time} onChange={setTime} + cancelLabel={t('generic.cancel')} ampm={false} minutesStep={5} + id="NewCarTime" /> <TextField label={t('car.creation.name')} fullWidth - autoFocus - margin="dense" + helperText=" " value={name} onChange={e => setName(e.target.value)} + name="name" id="EditCarName" - name="name" /> <TextField label={t('car.creation.phone')} fullWidth - autoFocus - margin="dense" + helperText=" " value={phone} onChange={e => setPhone(e.target.value)} - id="EditCarPhone" name="phone" + id="EditCarPhone" /> <TextField label={t('car.creation.meeting')} fullWidth - margin="dense" multiline - rows={2} + rowsMax={4} + inputProps={{maxLength: 250}} + helperText={`${meeting.length}/250`} value={meeting} onChange={e => setMeeting(e.target.value)} + name="meeting" id="EditCarMeeting" - name="meeting" /> <TextField label={t('car.creation.notes')} fullWidth - margin="dense" + multiline + rowsMax={4} inputProps={{maxLength: 250}} helperText={`${details.length}/250`} - multiline - rows={2} value={details} onChange={e => setDetails(e.target.value)} - id="EditCarDetails" name="details" + id="EditCarDetails" /> <div className={classes.slider}> <Typography variant="caption">{t('car.creation.seats')}</Typography>

@@ -283,9 +284,6 @@ margin: theme.spacing(2, 0),

'& > *:first-child': { marginBottom: theme.spacing(2), }, - }, - picker: { - marginBottom: theme.spacing(2), }, }));
M frontend/containers/CarColumns/AddCar.tsxfrontend/containers/CarColumns/AddCar.tsx

@@ -15,11 +15,12 @@ const {t} = useTranslation();

return ( <Container maxWidth="sm" className={classes.container}> <Button + className="tour_car_add1" + classes={{containedSecondary: classes.button}} fullWidth variant="contained" color="secondary" onClick={toggleNewCar} - classes={{containedSecondary: classes.button}} > {t('car.creation.title')} </Button>
M frontend/containers/CarColumns/CustomArrow.tsxfrontend/containers/CarColumns/CustomArrow.tsx

@@ -40,10 +40,10 @@ justifyContent: 'center',

transition: 'background-color 0.3s ease, box-shadow 0.3s ease', '&:not(.slick-disabled)': { backgroundColor: 'rgba(255,255,255,1)', - boxShadow: '0 0 4px rgb(1 1 1 / 20%)', + boxShadow: '0 0 6px rgb(1 1 1 / 20%)', }, '&:not(.slick-disabled):hover': { - boxShadow: '0 0 0 rgb(1 1 1 / 20%)', + boxShadow: '0 0 1px rgb(1 1 1 / 20%)', }, '&::before': { fontSize: 23,
M frontend/containers/CarColumns/Dots.tsxfrontend/containers/CarColumns/Dots.tsx

@@ -1,8 +1,11 @@

import {createPortal} from 'react-dom'; import Box from '@material-ui/core/Box'; -const Dots = ({children}) => - createPortal( +const Dots = ({children}) => { + const element = document.getElementById('slider-dots'); + if (!element) return null; + + return createPortal( <Box className="slick-dots" component="ul"

@@ -16,5 +19,6 @@ </Box>

</Box>, document.getElementById('slider-dots') ); +}; export default Dots;
M frontend/containers/CarColumns/index.tsxfrontend/containers/CarColumns/index.tsx

@@ -1,9 +1,11 @@

+import {useEffect, useRef} from 'react'; import {makeStyles} from '@material-ui/core/styles'; import Container from '@material-ui/core/Container'; import Slider from 'react-slick'; -import WaitingList from '../WaitingList'; -import useEventStore from '../../stores/useEventStore'; import {Car as CarType} from '../../generated/graphql'; +import useEventStore from '../../stores/useEventStore'; +import useTourStore from '../../stores/useTourStore'; +import WaitingList from '../WaitingList'; import Car from '../Car'; import AddCar from './AddCar'; import sliderSettings from './_SliderSettings';

@@ -15,13 +17,22 @@

const CarColumns = (props: Props) => { const event = useEventStore(s => s.event); const {cars} = event || {}; + const slider = useRef(null); + const isCreator = useTourStore(s => s.isCreator); + const step = useTourStore(s => s.step); + const prev = useTourStore(s => s.prev); const classes = useStyles(); + // On tour step changes : component update + useEffect(() => { + tourStep(prev, step, isCreator, slider.current); + }, [step]); + return ( <div className={classes.container}> <div className={classes.dots} id="slider-dots" /> <div className={classes.slider}> - <Slider {...sliderSettings}> + <Slider ref={slider} {...sliderSettings}> <Container maxWidth="sm" className={classes.slide}> <WaitingList /> </Container>

@@ -40,6 +51,21 @@ </Slider>

</div> </div> ); +}; + +const tourStep = (prev, step, isCreator, slider) => { + const fromTo = (step1, step2) => prev === step1 && step === step2; + const first = () => slider?.slickGoTo(0, true); + const last = () => + slider?.slickGoTo(slider?.innerSlider.state.slideCount, true); + + if (isCreator) { + if (fromTo(2, 3) || fromTo(4, 3)) first(); + else if (fromTo(3, 4)) last(); + } else { + if (fromTo(1, 2)) first(); + else if (fromTo(0, 1) || fromTo(2, 1)) last(); + } }; const sortCars = (a: CarType, b: CarType) => {
M frontend/containers/CreateEvent/Step1.jsfrontend/containers/CreateEvent/Step1.js

@@ -47,7 +47,7 @@

return ( <form onSubmit={onNext}> <TextField - label={t('event.creation.event_name')} + label={t('event.creation.name')} fullWidth autoFocus margin="dense"

@@ -61,12 +61,11 @@ <>

<TextField label={t('event.creation.creator_email')} fullWidth - margin="dense" value={email} onChange={e => setEmail(e.target.value)} - id="NewEventEmail" name="email" type="email" + id="NewEventEmail" /> <FormControlLabel className={classes.newsletter}
M frontend/containers/CreateEvent/Step2.jsfrontend/containers/CreateEvent/Step2.js

@@ -39,26 +39,27 @@

return ( <form onSubmit={onCreate}> <DatePicker - id="NewEventDate" fullWidth label={t('event.creation.date')} - format="DD/MM/YYYY" value={date} onChange={setDate} + format="DD/MM/YYYY" + cancelLabel={t('generic.cancel')} clearable clearLabel={t('generic.clear')} - cancelLabel={t('generic.cancel')} + id="NewEventDate" /> <TextField label={t('event.creation.address')} fullWidth - margin="dense" multiline - rows={4} + rowsMax={4} + inputProps={{maxLength: 250}} + helperText={`${address.length}/250`} value={address} onChange={e => setAddress(e.target.value)} + name="address" id="NewEventAddress" - name="address" /> <Button disabled={loading}
M frontend/containers/DashboardEmpty/index.jsfrontend/containers/DashboardEmpty/index.js

@@ -1,14 +1,14 @@

+import {useRouter} from 'next/router'; +import {makeStyles} from '@material-ui/core/styles'; +import Container from '@material-ui/core/Container'; import Card from '@material-ui/core/Card'; import CardActions from '@material-ui/core/CardActions'; import CardContent from '@material-ui/core/CardContent'; import Typography from '@material-ui/core/Typography'; -import Container from '@material-ui/core/Container'; import Button from '@material-ui/core/Button'; import {useTranslation} from 'react-i18next'; -import {makeStyles} from '@material-ui/core/styles'; -import {useRouter} from 'next/router'; -const EmptyDashboard = () => { +const DashboardEmpty = () => { const {t} = useTranslation(); const router = useRouter(); const classes = useStyles();

@@ -43,7 +43,9 @@ );

}; const useStyles = makeStyles(theme => ({ - container: {paddingTop: theme.spacing(8)}, + container: { + paddingTop: theme.spacing(8), + }, })); -export default EmptyDashboard; +export default DashboardEmpty;
M frontend/containers/DashboardEvents/EventCard.jsfrontend/containers/DashboardEvents/EventCard.js

@@ -1,10 +1,10 @@

+import Link from 'next/link'; import Card from '@material-ui/core/Card'; import CardActions from '@material-ui/core/CardActions'; import CardContent from '@material-ui/core/CardContent'; import Typography from '@material-ui/core/Typography'; import Button from '@material-ui/core/Button'; import {useTranslation} from 'react-i18next'; -import Link from 'next/link'; const EventCard = ({event}) => { const {t} = useTranslation();

@@ -15,9 +15,7 @@ <CardContent>

<Typography gutterBottom variant="h6" component="h3"> {event.name} </Typography> - <Typography variant="overline"> - {t('event.fields.starts_on')} - </Typography> + <Typography variant="overline">{t('event.fields.date')}</Typography> <Typography variant="body2" gutterBottom> {event.date || t('event.fields.empty')} </Typography>
M frontend/containers/DashboardEvents/index.jsfrontend/containers/DashboardEvents/index.js

@@ -1,12 +1,16 @@

-import Grid from '@material-ui/core/Grid'; import {makeStyles} from '@material-ui/core/styles'; +import Grid from '@material-ui/core/Grid'; import {useTranslation} from 'react-i18next'; import EventCard from './EventCard'; import Section from './Section'; -const DashboardEvents = ({futureEvents, noDateEvents, pastEvents}) => { - const classes = useStyles(); +const DashboardEvents = ({ + futureEvents = [], + noDateEvents = [], + pastEvents = [], +}) => { const {t} = useTranslation(); + const classes = useStyles(); return ( <Grid container className={classes.root} spacing={4}>

@@ -53,9 +57,10 @@

const useStyles = makeStyles(theme => ({ root: { flexGrow: 1, - maxWidth: '90rem', width: '100%', + maxWidth: '90rem', margin: '0 auto', + paddingBottom: theme.spacing(10), }, }));
M frontend/containers/EventBar/index.jsfrontend/containers/EventBar/index.js

@@ -1,32 +1,42 @@

import {useEffect, useState, useReducer} from 'react'; +import {useRouter} from 'next/router'; +import Link from 'next/link'; +import {makeStyles} from '@material-ui/core/styles'; import AppBar from '@material-ui/core/AppBar'; import Toolbar from '@material-ui/core/Toolbar'; import Typography from '@material-ui/core/Typography'; +import IconButton from '@material-ui/core/IconButton'; import Avatar from '@material-ui/core/Avatar'; -import IconButton from '@material-ui/core/IconButton'; import Icon from '@material-ui/core/Icon'; -import {makeStyles} from '@material-ui/core/styles'; +import clsx from 'clsx'; import {useTranslation} from 'react-i18next'; -import {useRouter} from 'next/router'; -import Link from 'next/link'; -import EventMenu from '../EventMenu'; -import EventDetails from '../EventDetails'; import useAuthStore from '../../stores/useAuthStore'; import useEventStore from '../../stores/useEventStore'; +import useTourStore from '../../stores/useTourStore'; import useProfile from '../../hooks/useProfile'; import useSettings from '../../hooks/useSettings'; +import EventMenu from '../EventMenu'; +import EventDetails from '../EventDetails'; -const EventBar = ({event, onAdd, onSave, onShare}) => { +const EventBar = ({event, onAdd, onSave, onShare, onTourRestart}) => { const {t} = useTranslation(); const router = useRouter(); const [detailsOpen, toggleDetails] = useReducer(i => !i, false); const [anchorEl, setAnchorEl] = useState(null); const isEditing = useEventStore(s => s.isEditing); const setIsEditing = useEventStore(s => s.setIsEditing); - const classes = useStyles({detailsOpen}); const token = useAuthStore(s => s.token); const {user} = useProfile(); const settings = useSettings(); + const isCreator = useTourStore(s => s.isCreator); + const prev = useTourStore(s => s.prev); + const step = useTourStore(s => s.step); + const classes = useStyles({detailsOpen}); + + // On tour step changes : component update + useEffect(() => { + tourStep(prev, step, isCreator, toggleDetails); + }, [step]); useEffect(() => { if (!detailsOpen) setIsEditing(false);

@@ -60,6 +70,12 @@ label: t('menu.register'),

onClick: signUp, id: 'SignUpTab', }, + {divider: true}, + { + label: t('menu.tour'), + onClick: onTourRestart, + id: 'TourTab', + }, ]; const loggedMenuActions = [

@@ -74,6 +90,11 @@ onClick: goProfile,

id: 'ProfileTab', }, {divider: true}, + { + label: t('menu.tour'), + onClick: onTourRestart, + id: 'TourTab', + }, ]; const menuActions = token ? loggedMenuActions : noUserMenuActions;

@@ -83,9 +104,9 @@ : [];

return ( <AppBar + className={classes.appbar} position="static" color="primary" - className={classes.appbar} id={(isEditing && 'EditEvent') || (detailsOpen && 'Details') || 'Menu'} > <Toolbar>

@@ -98,6 +119,7 @@ {event.name}

</Typography> {detailsOpen && ( <IconButton + className="tour_event_edit" color="inherit" edge="end" id="HeaderAction"

@@ -122,20 +144,20 @@ </IconButton>

) : ( <> <IconButton + className={classes.shareIcon} color="inherit" edge="end" id="ShareBtn" onClick={toggleDetails} - className={classes.shareIcon} > <Icon>share</Icon> </IconButton> <IconButton + className={clsx(classes.iconButtons, 'tour_event_infos')} color="inherit" edge="end" id="ShareBtn" onClick={toggleDetails} - className={classes.iconButtons} > <Icon>information_outline</Icon> </IconButton>

@@ -178,6 +200,17 @@ <EventDetails toggleDetails={toggleDetails} onShare={onShare} />

)} </AppBar> ); +}; + +const tourStep = (prev, step, isCreator, toggleDetails) => { + const fromTo = (step1, step2) => prev === step1 && step === step2; + + if (isCreator) { + if (fromTo(1, 0) || fromTo(0, 1) || fromTo(3, 2) || fromTo(2, 3)) + toggleDetails(); + } else { + if (fromTo(4, 3) || fromTo(3, 4)) toggleDetails(); + } }; const useStyles = makeStyles(theme => ({
M frontend/containers/EventDetails/index.jsfrontend/containers/EventDetails/index.js

@@ -14,12 +14,12 @@ import {caroster} from '../../theme';

const EventDetails = ({onShare}) => { const {t} = useTranslation(); - const classes = useStyles(); const event = useEventStore(s => s.event); const setEventUpdate = useEventStore(s => s.setEventUpdate); const isEditing = useEventStore(s => s.isEditing); const shareInput = useRef(null); const idPrefix = isEditing ? 'EditEvent' : 'Event'; + const classes = useStyles(); if (!event) return null;

@@ -31,30 +31,30 @@ {isEditing && (

<div className={classes.section}> <Typography variant="h6">{t('event.fields.name')}</Typography> <TextField + fullWidth value={event.name} onChange={e => setEventUpdate({name: e.target.value})} - fullWidth + name="name" id="EditEventName" - name="name" /> </div> )} - <Typography variant="h6">{t('event.fields.starts_on')}</Typography> + <Typography variant="h6">{t('event.fields.date')}</Typography> {isEditing ? ( <DatePicker - id={`${idPrefix}Date`} fullWidth - label={t('event.creation.date')} - format="DD/MM/YYYY" + placeholder={t('event.fields.date_placeholder')} value={event.date} onChange={date => setEventUpdate({ date: !date ? null : moment(date).format('YYYY-MM-DD'), }) } + format="DD/MM/YYYY" + cancelLabel={t('generic.cancel')} clearable clearLabel={t('generic.clear')} - cancelLabel={t('generic.cancel')} + id={`${idPrefix}Date`} /> ) : ( <Typography variant="body1" id={`${idPrefix}Date`}>

@@ -68,12 +68,14 @@ <div className={classes.section}>

<Typography variant="h6">{t('event.fields.address')}</Typography> {isEditing ? ( <TextField + fullWidth + multiline + rowsMax={4} + inputProps={{maxLength: 250}} + helperText={`${event.address.length}/250`} defaultValue={event.address} value={event.address} onChange={e => setEventUpdate({address: e.target.value})} - fullWidth - multiline - rows={4} id={`${idPrefix}Address`} name="address" />

@@ -114,6 +116,7 @@ id="ShareLink"

/> <Button + className={'tour_event_share'} variant="outlined" startIcon={<Icon>share</Icon>} onClick={() => {
M frontend/containers/Fab/index.jsfrontend/containers/Fab/index.js

@@ -3,13 +3,18 @@ import Icon from '@material-ui/core/Icon';

import FabMui from '@material-ui/core/Fab'; import {makeStyles} from '@material-ui/core/styles'; -const Fab = ({open, children = null, ...props}) => { +const Fab = ({open = false, children = null, ...props}) => { const variant = children ? 'extended' : 'round'; const classes = useStyles({open, variant}); return ( <div className={classes.container}> - <FabMui color="secondary" variant={variant} {...props}> + <FabMui + className="tour_car_add2" + color="secondary" + variant={variant} + {...props} + > <Icon className={classes.icon}>add</Icon> {children} </FabMui>

@@ -20,9 +25,9 @@

const useStyles = makeStyles(theme => ({ container: ({open}) => ({ position: 'fixed', - bottom: open ? -theme.spacing(8) : theme.spacing(3), right: theme.spacing(3), transition: 'all 0.3s ease', + bottom: open ? -theme.spacing(8) : theme.spacing(3), transform: open ? 'rotate(45deg)' : '', zIndex: theme.zIndex.speedDial, }),
M frontend/containers/GenericMenu/Toolbar.jsfrontend/containers/GenericToolbar/GenericMenu.js

@@ -4,7 +4,7 @@ import Typography from '@material-ui/core/Typography';

import Menu from '@material-ui/core/Menu'; import MenuItem from '@material-ui/core/MenuItem'; -const Toolbar = ({anchorEl, setAnchorEl, actions = []}) => { +const GenericMenu = ({anchorEl, setAnchorEl, actions = []}) => { const classes = useStyles(); if (actions.length === 0) return null; return (

@@ -73,4 +73,4 @@ margin: theme.spacing(1, 2),

'&:focus': {outline: 0}, }, })); -export default Toolbar; +export default GenericMenu;
M frontend/containers/GenericMenu/index.jsfrontend/containers/GenericToolbar/index.js

@@ -1,19 +1,19 @@

import {useState, useEffect, useMemo} from 'react'; +import {useRouter} from 'next/router'; +import {makeStyles} from '@material-ui/core/styles'; import AppBar from '@material-ui/core/AppBar'; import Toolbar from '@material-ui/core/Toolbar'; import Typography from '@material-ui/core/Typography'; import IconButton from '@material-ui/core/IconButton'; import Avatar from '@material-ui/core/Avatar'; import Icon from '@material-ui/core/Icon'; -import {makeStyles} from '@material-ui/core/styles'; -import GenericToolbar from './Toolbar'; import {useTranslation} from 'react-i18next'; -import {useRouter} from 'next/router'; import useAuthStore from '../../stores/useAuthStore'; import useProfile from '../../hooks/useProfile'; import useSettings from '../../hooks/useSettings'; +import GenericMenu from './GenericMenu'; -const GenericMenu = ({title, actions = [], goBack = null}) => { +const GenericToolbar = ({title, actions = [], goBack = null}) => { const {t} = useTranslation(); const router = useRouter(); const [anchorEl, setAnchorEl] = useState(null);

@@ -47,7 +47,7 @@ }, []);

return ( <AppBar - position="static" + position="fixed" color="primary" className={classes.appbar} id="Menu"

@@ -86,7 +86,7 @@ <Icon>more_vert</Icon>

)} </IconButton> - <GenericToolbar + <GenericMenu anchorEl={anchorEl} setAnchorEl={setAnchorEl} actions={[

@@ -109,12 +109,9 @@ container: {

padding: theme.spacing(2), }, appbar: { - overflow: 'hidden', height: theme.mixins.toolbar.minHeight, transition: 'height 0.3s ease', zIndex: theme.zIndex.appBar, - position: 'fixed', - top: 0, }, name: { flexGrow: 1,

@@ -134,4 +131,4 @@ color: theme.palette.common.white,

}, })); -export default GenericMenu; +export default GenericToolbar;
M frontend/containers/Languages/index.tsxfrontend/containers/Languages/index.tsx

@@ -22,13 +22,18 @@ const {profile, connected} = useProfile();

const [updateProfile] = useUpdateMeMutation(); useEffect(() => { + if (navigator?.language === 'en') + setLanguage(Enum_Userspermissionsuser_Lang.En); + }, []); + + useEffect(() => { const momentLang = language === 'FR' ? 'fr-ch' : 'en'; moment.locale(momentLang); i18n.changeLanguage(language?.toLowerCase()); }, [language]); useEffect(() => { - if (profile) setLanguage(profile.lang); + if (profile?.lang) setLanguage(profile.lang); }, [profile]); const handleClick = event => {

@@ -52,7 +57,7 @@ };

return ( <> - <Box p={1} position="fixed" bottom={0} left={0} zIndex={1050}> + <Box position="fixed" bottom={0} left={0} zIndex={1050} p={1}> <IconButton color="primary" aria-label="Languages"
M frontend/containers/NewCarDialog/index.tsxfrontend/containers/NewCarDialog/index.tsx

@@ -88,75 +88,83 @@ >

<form onSubmit={onCreate}> <DialogTitle>{t('car.creation.title')}</DialogTitle> <DialogContent> - <TextField - label={t('car.creation.name')} - value={name} - onChange={e => setName(e.target.value)} - fullWidth - autoFocus - id="NewCarName" - name="name" - /> <DatePicker - id="NewCarDateTime" - className={classes.picker} - fullWidth label={t('car.creation.date')} - format="DD/MM/YYYY" + fullWidth + helperText=" " value={date} onChange={setDate} + format="DD/MM/YYYY" + cancelLabel={t('generic.cancel')} + autoFocus + id="NewCarDateTime" /> <TimePicker - id="NewCarTime" - className={classes.picker} + label={t('car.creation.time')} fullWidth - label={t('car.creation.time')} + helperText=" " value={time} onChange={setTime} + cancelLabel={t('generic.cancel')} ampm={false} minutesStep={5} - /> - <Typography variant="caption">{t('car.creation.seats')}</Typography> - <Slider - value={seats} - onChange={(e, value) => setSeats(value)} - step={1} - min={1} - max={MARKS.length} - marks={MARKS} - valueLabelDisplay="auto" + id="NewCarTime" /> <TextField - label={t('car.creation.meeting')} - value={meeting} - onChange={e => setMeeting(e.target.value)} + label={t('car.creation.name')} fullWidth - margin="dense" - id="NewCarMeeting" - name="meeting" + helperText=" " + value={name} + onChange={e => setName(e.target.value)} + name="name" + id="NewCarName" /> <TextField label={t('car.creation.phone')} + fullWidth + helperText=" " value={phone} onChange={e => setPhone(e.target.value)} - fullWidth - margin="dense" + name="phone" id="NewCarPhone" - name="phone" + /> + <TextField + label={t('car.creation.meeting')} + fullWidth + multiline + rowsMax={4} + inputProps={{maxLength: 250}} + helperText={`${meeting.length}/250`} + value={meeting} + onChange={e => setMeeting(e.target.value)} + name="meeting" + id="NewCarMeeting" /> <TextField label={t('car.creation.notes')} - value={details} - onChange={e => setDetails(e.target.value)} fullWidth - margin="dense" + multiline + rowsMax={4} inputProps={{maxLength: 250}} helperText={`${details.length}/250`} - multiline - rows={4} + value={details} + onChange={e => setDetails(e.target.value)} + name="details" id="NewCarDetails" - name="details" /> + <div className={classes.slider}> + <Typography variant="caption">{t('car.creation.seats')}</Typography> + <Slider + value={seats} + onChange={(e, value) => setSeats(value)} + step={1} + marks={MARKS} + min={1} + max={MARKS.length} + valueLabelDisplay="auto" + id="NewCarSeats" + /> + </div> </DialogContent> <DialogActions> <Button

@@ -193,9 +201,8 @@ label: value,

})); const useStyles = makeStyles(theme => ({ - picker: { - marginTop: theme.spacing(3), - marginBottom: theme.spacing(3), + slider: { + marginTop: theme.spacing(2), }, }));
M frontend/containers/SignInForm/index.jsfrontend/containers/SignInForm/index.js

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

import {useState, useMemo, useEffect} from 'react'; -import {useTranslation} from 'react-i18next'; -import TextField from '@material-ui/core/TextField'; import {useRouter} from 'next/router'; import RouterLink from 'next/link'; +import {makeStyles} from '@material-ui/core/styles'; +import TextField from '@material-ui/core/TextField'; import Button from '@material-ui/core/Button'; import Link from '@material-ui/core/Link'; import Typography from '@material-ui/core/Typography';

@@ -10,15 +10,14 @@ import CardContent from '@material-ui/core/CardContent';

import CircularProgress from '@material-ui/core/CircularProgress'; import FormHelperText from '@material-ui/core/FormHelperText'; import CardActions from '@material-ui/core/CardActions'; -import {makeStyles} from '@material-ui/core/styles'; -import useLoginForm from '../../hooks/useLoginForm'; +import {useTranslation} from 'react-i18next'; import useToastsStore from '../../stores/useToastStore'; import useLoginWithProvider from '../../hooks/useLoginWithProvider'; +import useLoginForm from '../../hooks/useLoginForm'; import useAddToEvents from '../../hooks/useAddToEvents'; const SignIn = () => { const {t} = useTranslation(); - const classes = useStyles(); const router = useRouter(); const {loginWithProvider} = useLoginWithProvider(); const [error, setError] = useState('');

@@ -27,6 +26,7 @@ const [password, setPassword] = useState('');

const addToast = useToastsStore(s => s.addToast); const {login, loading} = useLoginForm(email, password); const {saveStoredEvents} = useAddToEvents(); + const classes = useStyles(); const canSubmit = useMemo( () => [email, password].filter(s => s.length < 4).length === 0,
M frontend/containers/WaitingList/index.tsxfrontend/containers/WaitingList/index.tsx

@@ -1,22 +1,23 @@

import {useReducer, useState, useMemo, useCallback} from 'react'; +import {makeStyles} from '@material-ui/core/styles'; import Typography from '@material-ui/core/Typography'; import IconButton from '@material-ui/core/IconButton'; import Icon from '@material-ui/core/Icon'; import Paper from '@material-ui/core/Paper'; import Divider from '@material-ui/core/Divider'; -import {makeStyles} from '@material-ui/core/styles'; +import clsx from 'clsx'; import {Trans, useTranslation} from 'react-i18next'; -import useAddToEvents from '../../hooks/useAddToEvents'; -import PassengersList from '../PassengersList'; -import RemoveDialog from '../RemoveDialog'; -import CarDialog from './CarDialog'; -import useToastStore from '../../stores/useToastStore'; -import useEventStore from '../../stores/useEventStore'; import { useUpdateEventMutation, useUpdateCarMutation, ComponentPassengerPassenger, } from '../../generated/graphql'; +import useToastStore from '../../stores/useToastStore'; +import useEventStore from '../../stores/useEventStore'; +import useAddToEvents from '../../hooks/useAddToEvents'; +import PassengersList from '../PassengersList'; +import RemoveDialog from '../RemoveDialog'; +import CarDialog from './CarDialog'; const WaitingList = () => { const classes = useStyles();

@@ -65,8 +66,8 @@ const waitingList = event.waitingList

.filter(passenger => passenger.id !== removingPassenger?.id) .map(({__typename, ...item}) => item); await updateEvent({ - refetchQueries: ['eventByUUID'], variables: {uuid: event.uuid, eventUpdate: {waitingList}}, + refetchQueries: ['eventByUUID'], }); addToEvent(event.id); } catch (error) {

@@ -102,6 +103,7 @@ eventUpdate: {

waitingList, }, }, + refetchQueries: ['eventByUUID'], }); } catch (error) { console.error(error);

@@ -126,7 +128,7 @@

return ( <> <Paper className={classes.root}> - <div className={classes.header}> + <div className={clsx(classes.header, 'tour_waiting_list')}> <IconButton size="small" color="primary"

@@ -177,7 +179,8 @@

const sortCars = (a, b) => { const dateA = new Date(a.departure).getTime(); const dateB = new Date(b.departure).getTime(); - if (dateA === dateB) return new Date(a.createdAt) - new Date(b.createdAt); + if (dateA === dateB) + return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); else return dateA - dateB; };
A frontend/containers/WelcomeDialog/index.js

@@ -0,0 +1,50 @@

+import React from 'react'; +import {makeStyles} from '@material-ui/core/styles'; +import Dialog from '@material-ui/core/Dialog'; +import CardMedia from '@material-ui/core/CardMedia'; +import DialogActions from '@material-ui/core/DialogActions'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogContentText from '@material-ui/core/DialogContentText'; +import Button from '@material-ui/core/Button'; +import {useTranslation} from 'react-i18next'; +import useTourStore from '../../stores/useTourStore'; + +const WelcomeDialog = () => { + const {t} = useTranslation(); + const showWelcome = useTourStore(s => s.showWelcome); + const setTour = useTourStore(s => s.setTour); + const classes = useStyles(); + + return ( + <Dialog open={showWelcome}> + <CardMedia + className={classes.media} + image="/assets/Caroster_Octree_Social.jpg" + /> + <DialogContent> + <DialogContentText>{t('tour.welcome.text')}</DialogContentText> + </DialogContent> + <DialogActions> + <Button onClick={() => setTour({showWelcome: false})} id="TourCancel"> + {t('tour.welcome.nope')} + </Button> + <Button + onClick={() => + setTour({showWelcome: false, run: false, step: 0, prev: -1}) + } + id="TourConfirm" + > + {t('tour.welcome.onboard')} + </Button> + </DialogActions> + </Dialog> + ); +}; + +const useStyles = makeStyles({ + media: { + height: 240, + }, +}); + +export default WelcomeDialog;
M frontend/generated/graphql.tsxfrontend/generated/graphql.tsx

@@ -206,7 +206,6 @@ __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']>;

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

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

@@ -1487,7 +1485,6 @@ email?: Maybe<Scalars['String']>;

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

@@ -1855,7 +1852,7 @@ );

export type UserFieldsFragment = ( { __typename?: 'UsersPermissionsUser' } - & Pick<UsersPermissionsUser, 'id' | 'username' | 'email' | 'confirmed' | 'lastName' | 'firstName' | 'lang'> + & Pick<UsersPermissionsUser, 'id' | 'username' | 'email' | 'confirmed' | 'lastName' | 'firstName' | 'lang' | 'onboardingUser' | 'onboardingCreator'> & { events?: Maybe<Array<Maybe<( { __typename?: 'Event' } & Pick<Event, 'id' | 'uuid' | 'name' | 'date' | 'address'>

@@ -1957,6 +1954,8 @@ confirmed

lastName firstName lang + onboardingUser + onboardingCreator events { id uuid
M frontend/graphql/user.gqlfrontend/graphql/user.gql

@@ -6,6 +6,8 @@ confirmed

lastName firstName lang + onboardingUser + onboardingCreator events { id uuid
M frontend/hooks/useAddToEvents.tsfrontend/hooks/useAddToEvents.ts

@@ -56,7 +56,7 @@ });

} else addEvent(eventId); }; - return {saveStoredEvents, addToEvent}; + return {eventsToBeAdded, saveStoredEvents, addToEvent}; }; export default useAddToEvents;
M frontend/hooks/useProfile.jsfrontend/hooks/useProfile.js

@@ -5,24 +5,23 @@

const useProfile = () => { const token = useAuthStore(s => s.token); const user = useAuthStore(s => s.user); - const [profile, setProfile] = useState(null); + const [profile, setProfile] = useState(undefined); const [fetchProfile, {data}] = useProfileLazyQuery(); useEffect(() => { - if (token) { - fetchProfile(); - } + if (token) fetchProfile(); }, [token]); useEffect(() => { if (data) setProfile(data.me?.profile); + else setProfile(null); }, [data]); return { profile, connected: !!token, user: user, - isReady: typeof profile !== 'undefined', + notReady: typeof profile === 'undefined', }; };
A frontend/hooks/useTour.ts

@@ -0,0 +1,139 @@

+import {useEffect, useMemo} from 'react'; +import {useTranslation} from 'react-i18next'; +import {CallBackProps, STATUS, EVENTS, ACTIONS} from 'react-joyride'; +import {useUpdateMeMutation} from '../generated/graphql'; +import useOnboardingStore from '../stores/useOnboardingStore'; +import useTourStore from '../stores/useTourStore'; +import useEventStore from '../stores/useEventStore'; +import useAddToEvents from '../hooks/useAddToEvents'; +import useProfile from './useProfile'; + +const STEP_SETTINGS = { + disableBeacon: true, + disableOverlayClose: true, + hideCloseButton: true, + hideFooter: false, + spotlightClicks: false, + showSkipButton: true, + styles: { + options: { + zIndex: 10000, + }, + }, +}; + +const useTour = () => { + const {t} = useTranslation(); + const isCreator = useTourStore(s => s.isCreator); + const run = useTourStore(s => s.run); + const step = useTourStore(s => s.step); + const prev = useTourStore(s => s.prev); + const setTour = useTourStore(s => s.setTour); + const onboardingUser = useOnboardingStore(s => s.onboardingUser); + const onboardingCreator = useOnboardingStore(s => s.onboardingCreator); + const setOnboarding = useOnboardingStore(s => s.setOnboarding); + const {profile, notReady} = useProfile(); + const event = useEventStore(s => s.event); + const {eventsToBeAdded} = useAddToEvents(); + const [updateProfile] = useUpdateMeMutation(); + + // Check if user is the event creator + useEffect(() => { + if (notReady || !event) return; + + if (profile) { + setTour({isCreator: profile.events.map(e => e.id).includes(event?.id)}); + } else { + setTour({isCreator: eventsToBeAdded.includes(event?.id)}); + } + }, [notReady, event, eventsToBeAdded, profile]); + + const steps = useMemo(() => { + if (isCreator === null) return []; + return isCreator + ? [ + {content: t`tour.creator.step1`, target: '.tour_event_infos'}, + {content: t`tour.creator.step2`, target: '.tour_event_edit'}, + {content: t`tour.creator.step3`, target: '.tour_event_share'}, + {content: t`tour.creator.step4`, target: '.tour_waiting_list'}, + {content: t`tour.creator.step5`, target: '.tour_car_add1'}, + {content: t`tour.creator.step6`, target: '.tour_car_add2'}, + ].map(step => ({...step, ...STEP_SETTINGS})) + : [ + {content: t`tour.user.step1`, target: '.tour_car_add2'}, + {content: t`tour.user.step2`, target: '.tour_car_add1'}, + {content: t`tour.user.step3`, target: '.tour_waiting_list'}, + {content: t`tour.user.step4`, target: '.tour_event_infos'}, + {content: t`tour.user.step5`, target: '.tour_event_share'}, + ].map(step => ({...step, ...STEP_SETTINGS})); + }, [isCreator]); + + // Init tour + useEffect(() => { + const hasOnboarded = () => { + if (isCreator) return profile?.onboardingCreator || onboardingCreator; + else return profile?.onboardingUser || onboardingUser; + }; + if (!hasOnboarded() && steps.length > 0) setTour({showWelcome: true}); + else setTour({showWelcome: false}); + }, [steps, isCreator, onboardingCreator, onboardingUser, profile]); + + // On step change : wait for the UI a little and run it + useEffect(() => { + let timer; + if (step >= 0 && step !== prev) + timer = setTimeout(() => setTour({run: true}), 250); + return () => clearTimeout(timer); + }, [step]); + + const onFinish = () => { + if (profile) { + if (isCreator && !profile.onboardingCreator) + updateProfile({variables: {userUpdate: {onboardingCreator: true}}}); + else if (!isCreator && !profile.onboardingUser) + updateProfile({variables: {userUpdate: {onboardingUser: true}}}); + } else { + if (isCreator && !onboardingCreator) + setOnboarding({onboardingCreator: true}); + else if (!isCreator && !onboardingUser) + setOnboarding({onboardingUser: true}); + } + }; + + const onTourRestart = () => { + setTour({showWelcome: true}); + }; + + const onTourChange = (data: CallBackProps) => { + const {action, index, type, status} = data; + + if ( + ([EVENTS.STEP_AFTER, EVENTS.TARGET_NOT_FOUND] as string[]).includes(type) + ) { + if (action === ACTIONS.CLOSE) { + setTour({run: false, step: -1, prev: -1}); + } else { + setTour({ + run: false, + step: index + (action === ACTIONS.PREV ? -1 : 1), + prev: index, + }); + } + } else if ( + ([STATUS.FINISHED, STATUS.SKIPPED] as string[]).includes(status) + ) { + setTour({run: false, step: -1, prev: -1}); + if (status === STATUS.FINISHED) onFinish(); + } + }; + + return { + run, + steps, + step, + onTourChange, + onTourRestart, + }; +}; + +export default useTour;
M frontend/layouts/Centered.jsfrontend/layouts/Centered.js

@@ -1,25 +1,23 @@

+import {makeStyles} from '@material-ui/core/styles'; import Container from '@material-ui/core/Container'; import DefaultLayout from './Default'; -import {makeStyles} from '@material-ui/core/styles'; const CenteredLayout = ({children, ...props}) => { const classes = useStyles(); return ( - <DefaultLayout {...props}> - <div className={classes.layout}> - <Container maxWidth="sm">{children}</Container> - </div> + <DefaultLayout className={classes.layout} {...props}> + <Container maxWidth="sm">{children}</Container> </DefaultLayout> ); }; -const useStyles = makeStyles(theme => ({ +const useStyles = makeStyles(() => ({ layout: { + minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', - minHeight: '100vh', }, }));
M frontend/layouts/Default.tsxfrontend/layouts/Default.tsx

@@ -1,7 +1,8 @@

import {ReactNode} from 'react'; import {Helmet} from 'react-helmet'; -import GenericMenu from '../containers/GenericMenu'; import useGTM from '../hooks/useGTM'; +import GenericToolbar from '../containers/GenericToolbar'; +import Languages from '../containers/Languages'; interface Props { children: ReactNode;

@@ -17,25 +18,30 @@ const DefaultLayout = (props: Props) => {

const { children, className, - menuTitle = 'Caroster', - menuActions, pageTitle = undefined, displayMenu = true, - goBack = () => {}, + menuTitle = 'Caroster', + menuActions, + goBack = null, } = props; useGTM(); return ( - <> + <div className={className}> <Helmet> <title>{pageTitle || menuTitle}</title> </Helmet> {displayMenu && (menuTitle || menuActions) && ( - <GenericMenu title={menuTitle} actions={menuActions} goBack={goBack} /> + <GenericToolbar + title={menuTitle} + actions={menuActions} + goBack={goBack} + /> )} - <div className={className}>{children}</div> - </> + {children} + <Languages /> + </div> ); };
M frontend/lib/apolloClient.tsfrontend/lib/apolloClient.ts

@@ -12,6 +12,7 @@ // https://github.com/vercel/next.js/tree/canary/examples/with-apollo

// https://github.com/vercel/next.js/tree/canary/examples/layout-component // https://www.apollographql.com/docs/react/networking/authentication/ // https://www.apollographql.com/docs/react/data/error-handling/ +// https://www.apollographql.com/docs/react/caching/cache-field-behavior/#the-merge-function export const APOLLO_STATE_PROP_NAME = '__APOLLO_STATE__'; let apolloClient;

@@ -59,7 +60,16 @@ typePolicies: {

Event: { fields: { waitingList: { - merge(existing, incoming) { + merge(_, incoming) { + return incoming; + }, + }, + }, + }, + Car: { + fields: { + passengers: { + merge(_, incoming) { return incoming; }, },
M frontend/locales/en.jsonfrontend/locales/en.json

@@ -24,11 +24,41 @@ "languages": {

"fr": "Français", "en": "English" }, + "tour": { + "welcome": { + "text": "Welcome to Caroster! Would you like to take a feature tour?", + "nope": "Later", + "onboard": "Show me" + }, + "creator": { + "step1": "The event information can be modified from this menu.", + "step2": "The event can be edited by clicking on the edit button.", + "step3": "Share the event now by copying the link and sharing it by email or in a messaging group.", + "step4": "The waiting list includes passengers who do not yet have a seat in a car.", + "step5": "New cars are added from the right column.", + "step6": "A new car can also be added directly from the floating action button." + }, + "user": { + "step1": "A new car can be added directly from the floating action button.", + "step2": "It is also possible to add a car from the right column.", + "step3": "Would you like a place in a car? Register on the waiting list or directly in a car.", + "step4": "The event information can be accessed from this menu.", + "step5": "Share the event now by copying the link and sharing it by email or in a group messaging." + } + }, + "joyride": { + "back": "Back", + "close": "Close", + "last": "Finish", + "next": "Next", + "skip": "Skip" + }, "menu": { - "logout": "Logout", "about": "About Caroster", + "tour": "Caroster tour", "dashboard": "Dashboard", "login": "Login", + "logout": "Logout", "register": "Sign-Up", "new_event": "Create a caroster", "profile": "Profile"

@@ -37,9 +67,10 @@ "event": {

"title": "{{title}} - Caroster", "not_found": "Project not found", "fields": { - "starts_on": "Event date", - "address": "Event address", "name": "Name of the event", + "date": "Event date", + "date_placeholder": "DD/MM/YYYY", + "address": "Event address", "empty": "Not specified", "link": "Share link", "link_desc": "Share this link to invite people to carpool",

@@ -47,10 +78,10 @@ "share": "Copy link"

}, "creation": { "title": "New event", - "event_name": "Event name", - "creator_email": "Your e-mail", + "name": "Event name", "date": "Date of the event", "address": "Address of the event", + "creator_email": "Your e-mail", "next": "Next", "newsletter": "Keep me informed of developments in Caroster by e-mail", "actions": {
M frontend/locales/fr.jsonfrontend/locales/fr.json

@@ -24,11 +24,41 @@ "languages": {

"fr": "Français", "en": "English" }, + "tour": { + "welcome": { + "text": "Bienvenue sur Caroster ! Voulez-vous faire un tour des fonctionnalités ?", + "nope": "Plus tard", + "onboard": "Montrez-moi" + }, + "creator": { + "step1": "Les informations de l'événement peuvent être modifiées depuis ce menu.", + "step2": "L'événement peut être édité en cliquant sur le bouton d'édition.", + "step3": "Partager dès maintenant l'événement en copiant le lien et en le partageant par email ou dans un groupe de messagerie.", + "step4": "La liste d'attente regroupe les passagers qui n'ont pas encore de place dans une voiture.", + "step5": "Les nouvelles voitures sont ajouté depuis la colonne de droite.", + "step6": "Une nouvelle voiture peut également être ajouté directement depuis le bouton d'action flottant." + }, + "user": { + "step1": "Une nouvelle voiture peut être ajoutée directement depuis le bouton d'action flottant.", + "step2": "Il est également possible d'ajouter une voiture depuis la colonne de droite.", + "step3": "Vous aimeriez une place dans une voiture ? Inscrivez-vous dans la liste d'attente ou directement dans une voiture.", + "step4": "Les informations de l'événement sont accessibles depuis ce menu.", + "step5": "Partager dès maintenant l'événement en copiant le lien et en le partageant par email ou dans un groupe de messagerie." + } + }, + "joyride": { + "back": "Retour", + "close": "Fermer", + "last": "Terminer", + "next": "Suivant", + "skip": "Passer" + }, "menu": { - "logout": "Se déconnecter", "about": "À propos de Caroster", + "tour": "Tour de Caroster", "dashboard": "Tableau de bord", "login": "Se connecter", + "logout": "Se déconnecter", "register": "Créer un compte", "new_event": "Créer un caroster", "profile": "Profil"

@@ -37,9 +67,10 @@ "event": {

"title": "{{title}} - Caroster", "not_found": "Projet introuvable", "fields": { - "starts_on": "Date de l'événement", - "address": "Adresse de l'événement", "name": "Nom de l'événement", + "date": "Date de l'événement", + "date_placeholder": "DD/MM/YYYY", + "address": "Adresse de l'événement", "empty": "Non précisé", "link": "Lien de partage", "link_desc": "Partager ce lien pour inviter les gens à faire du covoiturage",

@@ -47,10 +78,10 @@ "share": "Copier le lien"

}, "creation": { "title": "Nouvel évènement", - "event_name": "Nom de l'événement", - "creator_email": "Votre e-mail", + "name": "Nom de l'événement", "date": "Date de l'événement", "address": "Adresse de l'événement", + "creator_email": "Votre e-mail", "next": "Suivant", "newsletter": "Me tenir informé des évolutions de Caroster par e-mail", "actions": {
M frontend/package.jsonfrontend/package.json

@@ -26,6 +26,7 @@ "react": "^17.0.2",

"react-dom": "^17.0.2", "react-helmet": "^6.1.0", "react-i18next": "^11.11.0", + "react-joyride": "^2.3.2", "react-slick": "^0.28.1", "typescript": "^4.1.3", "zustand": "^3.3.1"
M frontend/pages/_app.tsxfrontend/pages/_app.tsx

@@ -9,7 +9,6 @@ import MomentUtils from '@date-io/moment';

import {useApollo} from '../lib/apolloClient'; import {Enum_Userspermissionsuser_Lang} from '../generated/graphql'; import useLangStore from '../stores/useLangStore'; -import Languages from '../containers/Languages'; import Metas from '../containers/Metas'; import Toasts from '../components/Toasts'; import theme from '../theme';

@@ -45,7 +44,6 @@ >

<CssBaseline /> <Component {...pageProps} /> <Toasts /> - <Languages /> </MuiPickersUtilsProvider> </ThemeProvider> </ApolloProvider>
M frontend/pages/auth/confirm.tsxfrontend/pages/auth/confirm.tsx

@@ -1,13 +1,13 @@

-import Layout from '../../layouts/Centered'; import Card from '@material-ui/core/Card'; import CardMedia from '@material-ui/core/CardMedia'; -import Logo from '../../components/Logo'; -import {useTranslation} from 'react-i18next'; import Button from '@material-ui/core/Button'; import CardContent from '@material-ui/core/CardContent'; import CardActionArea from '@material-ui/core/CardActions'; import CardActions from '@material-ui/core/CardActions'; import Typography from '@material-ui/core/Typography'; +import {useTranslation} from 'react-i18next'; +import Layout from '../../layouts/Centered'; +import Logo from '../../components/Logo'; const Confirm = () => { const {t} = useTranslation();
M frontend/pages/auth/login.tsxfrontend/pages/auth/login.tsx

@@ -1,14 +1,14 @@

import {useEffect} from 'react'; -import {useTranslation} from 'react-i18next'; import {useRouter} from 'next/router'; import CardMedia from '@material-ui/core/CardMedia'; import Divider from '@material-ui/core/Divider'; import Card from '@material-ui/core/Card'; +import {useTranslation} from 'react-i18next'; +import useAuthStore from '../../stores/useAuthStore'; import Layout from '../../layouts/Centered'; import Logo from '../../components/Logo'; import SignInForm from '../../containers/SignInForm'; import LoginGoogle from '../../containers/LoginGoogle'; -import useAuthStore from '../../stores/useAuthStore'; const Login = () => { const {t} = useTranslation();
M frontend/pages/auth/lost-password.tsxfrontend/pages/auth/lost-password.tsx

@@ -1,6 +1,6 @@

-import LostPasswordContainer from '../../containers/LostPassword'; -import Layout from '../../layouts/Centered'; import {useTranslation} from 'react-i18next'; +import Layout from '../../layouts/Centered'; +import LostPasswordContainer from '../../containers/LostPassword'; const LostPassword = () => { const {t} = useTranslation();
M frontend/pages/auth/register.tsxfrontend/pages/auth/register.tsx

@@ -3,9 +3,9 @@ import CardMedia from '@material-ui/core/CardMedia';

import Divider from '@material-ui/core/Divider'; import {useTranslation} from 'react-i18next'; import Layout from '../../layouts/Centered'; -import Logo from '../../components/Logo'; import SignUpForm from '../../containers/SignUpForm'; import LoginGoogle from '../../containers/LoginGoogle'; +import Logo from '../../components/Logo'; const SignUp = () => { const {t} = useTranslation();
M frontend/pages/auth/reset.tsxfrontend/pages/auth/reset.tsx

@@ -1,9 +1,9 @@

import {useState} from 'react'; -import {useTranslation} from 'react-i18next'; import {useRouter} from 'next/router'; +import {useTranslation} from 'react-i18next'; +import useToastStore from '../../stores/useToastStore'; import Layout from '../../layouts/Centered'; import ResetPasswordContainer from '../../containers/ResetPassword'; -import useToastStore from '../../stores/useToastStore'; import {useResetPasswordMutation} from '../../generated/graphql'; const ResetPassword = () => {
M frontend/pages/dashboard.tsxfrontend/pages/dashboard.tsx

@@ -1,23 +1,23 @@

import {useMemo, useEffect} from 'react'; -import LayoutDefault from '../layouts/Default'; +import {useRouter} from 'next/router'; +import {makeStyles} from '@material-ui/core/styles'; import moment from 'moment'; -import {useRouter} from 'next/router'; -import Loading from '../containers/Loading'; +import {useTranslation} from 'react-i18next'; +import useAuthStore from '../stores/useAuthStore'; +import useProfile from '../hooks/useProfile'; +import LayoutDefault from '../layouts/Default'; import DashboardEvents from '../containers/DashboardEvents'; import DashboardEmpty from '../containers/DashboardEmpty'; +import Loading from '../containers/Loading'; import Fab from '../containers/Fab'; -import {useTranslation} from 'react-i18next'; -import {makeStyles} from '@material-ui/core/styles'; -import useProfile from '../hooks/useProfile'; -import useAuthStore from '../stores/useAuthStore'; const Dashboard = () => { + const {t} = useTranslation(); + const router = useRouter(); const isAuth = useAuthStore(s => !!s.token); - const {profile, isReady} = useProfile(); - const router = useRouter(); - const {t} = useTranslation(); + const {profile, notReady} = useProfile(); + const {events} = profile || {}; const classes = useStyles(); - const {events = []} = profile || {}; useEffect(() => { if (!isAuth) router.push('/');

@@ -26,7 +26,7 @@

const pastEvents = useMemo( () => events - .filter(({date}) => date && moment(date).isBefore(moment(), 'day')) + ?.filter(({date}) => date && moment(date).isBefore(moment(), 'day')) .sort(sortDesc), [events] );

@@ -34,21 +34,17 @@

const futureEvents = useMemo( () => events - .filter(({date}) => date && moment(date).isSameOrAfter(moment(), 'day')) + ?.filter( + ({date}) => date && moment(date).isSameOrAfter(moment(), 'day') + ) .sort(sortDesc), [events] ); - const noDateEvents = useMemo(() => events.filter(({date}) => !date), [ - events, - ]); - - if (!isAuth || !isReady) - return ( - <LayoutDefault menuTitle={t('dashboard.title')}> - <Loading /> - </LayoutDefault> - ); + const noDateEvents = useMemo( + () => events?.filter(({date}) => !date), + [events] + ); const menuActions = [ {

@@ -63,13 +59,20 @@ id: 'ProfileTabs',

}, ]; + if (!events || !isAuth || notReady) + return ( + <LayoutDefault menuTitle={t('dashboard.title')}> + <Loading /> + </LayoutDefault> + ); + return ( <LayoutDefault className={classes.root} menuActions={menuActions} menuTitle={t('dashboard.title')} > - {!events || events.length === 0 ? ( + {events.length === 0 ? ( <DashboardEmpty /> ) : ( <DashboardEvents

@@ -89,8 +92,8 @@ const sortDesc = ({date: dateA}, {date: dateB}) => dateB.localeCompare(dateA);

const useStyles = makeStyles(theme => ({ root: { - marginTop: theme.mixins.toolbar.minHeight, - height: '100vh', + minHeight: '100vh', + paddingTop: theme.mixins.toolbar.minHeight, }, }));
M frontend/pages/e/[uuid].tsxfrontend/pages/e/[uuid].tsx

@@ -1,22 +1,27 @@

import {useState, useReducer, useEffect} from 'react'; +import {useTheme} from '@material-ui/core/styles'; import {useTranslation} from 'react-i18next'; +import Joyride from 'react-joyride'; +import {initializeApollo} from '../../lib/apolloClient'; +import useToastStore from '../../stores/useToastStore'; +import useEventStore from '../../stores/useEventStore'; +import useTour from '../../hooks/useTour'; import Layout from '../../layouts/Default'; -import Fab from '../../containers/Fab'; +import AddToMyEventDialog from '../../containers/AddToMyEventDialog'; import CarColumns from '../../containers/CarColumns'; import NewCarDialog from '../../containers/NewCarDialog'; -import AddToMyEventDialog from '../../containers/AddToMyEventDialog'; +import WelcomeDialog from '../../containers/WelcomeDialog'; import EventBar from '../../containers/EventBar'; -import useToastStore from '../../stores/useToastStore'; -import {initializeApollo} from '../../lib/apolloClient'; -import ErrorPage from '../_error'; +import Loading from '../../containers/Loading'; +import Fab from '../../containers/Fab'; import { useUpdateEventMutation, Event as EventType, useEventByUuidQuery, EventByUuidDocument, + EditEventInput, } from '../../generated/graphql'; -import useEventStore from '../../stores/useEventStore'; -import Loading from '../../containers/Loading'; +import ErrorPage from '../_error'; const POLL_INTERVAL = 10000;

@@ -26,8 +31,8 @@ eventUUID: string;

} const EventPage = props => { - const {event} = props; const {t} = useTranslation(); + const {event} = props; if (!event) return <ErrorPage statusCode={404} title={t`event.not_found`} />;

@@ -37,6 +42,7 @@

const Event = (props: Props) => { const {eventUUID} = props; const {t} = useTranslation(); + const theme = useTheme(); const addToast = useToastStore(s => s.addToast); const setEvent = useEventStore(s => s.setEvent); const eventUpdate = useEventStore(s => s.event);

@@ -48,6 +54,7 @@ const {data: {eventByUUID: event} = {}} = useEventByUuidQuery({

pollInterval: POLL_INTERVAL, variables: {uuid: eventUUID}, }); + const {run, steps, step, onTourChange, onTourRestart} = useTour(); useEffect(() => { if (event) setEvent(event as EventType);

@@ -56,12 +63,9 @@

const onSave = async e => { try { const {uuid, ...data} = eventUpdate; - delete data.id; - delete data.__typename; - delete data.cars; - delete data.waitingList; + const {id, __typename, cars, users, waitingList, ...input} = data; await updateEvent({ - variables: {uuid, eventUpdate: data}, + variables: {uuid, eventUpdate: input as EditEventInput}, refetchQueries: ['eventByUUID'], }); setIsEditing(false);

@@ -73,7 +77,7 @@ };

const onShare = async () => { if (!event) return null; - // If navigator as share capability + // If navigator share capability if (!!navigator.share) return await navigator.share({ title: `Caroster ${event.name}`,

@@ -100,14 +104,39 @@ event={event}

onAdd={setIsAddToMyEvent} onSave={onSave} onShare={onShare} + onTourRestart={onTourRestart} /> <CarColumns toggleNewCar={toggleNewCar} /> - <Fab onClick={toggleNewCar} open={openNewCar} aria-label="add-car" /> + <Fab open={openNewCar} onClick={toggleNewCar} aria-label="add-car" /> <NewCarDialog open={openNewCar} toggle={toggleNewCar} /> <AddToMyEventDialog event={event} open={isAddToMyEvent} onClose={() => setIsAddToMyEvent(false)} + /> + <WelcomeDialog /> + <Joyride + run={run} + steps={steps} + stepIndex={step} + callback={onTourChange} + locale={t('joyride', {returnObjects: true})} + continuous={true} + showProgress={true} + disableScrolling={true} + disableScrollParentFix={true} + scrollToFirstStep={false} + floaterProps={{ + disableAnimation: true, + }} + styles={{ + options: { + primaryColor: theme.palette.primary.main, + }, + tooltipContent: { + whiteSpace: 'pre-wrap', + }, + }} /> </Layout> );
M frontend/pages/index.tsxfrontend/pages/index.tsx

@@ -1,15 +1,15 @@

import {useRouter} from 'next/router'; +import {useTranslation} from 'react-i18next'; +import useProfile from '../hooks/useProfile'; import Layout from '../layouts/Centered'; +import CreateEvent from '../containers/CreateEvent'; import Paper from '../components/Paper'; import Logo from '../components/Logo'; -import CreateEvent from '../containers/CreateEvent'; -import {useTranslation} from 'react-i18next'; -import useAuthStore from '../stores/useAuthStore'; const Home = () => { + const {t} = useTranslation(); const router = useRouter(); - const {t} = useTranslation(); - const {token} = useAuthStore(); + const {notReady, profile} = useProfile(); const noUserMenuActions = [ {

@@ -37,13 +37,15 @@ id: 'ProfileTabs',

}, ]; - const menuActions = token ? loggedMenuActions : noUserMenuActions; + const menuActions = !!profile ? loggedMenuActions : noUserMenuActions; + + if (notReady) return null; return ( <Layout menuTitle={t('event.creation.title')} menuActions={menuActions} - displayMenu={!!token} + displayMenu={!!profile} > <Paper className={null}> <Logo />
M frontend/pages/profile.tsxfrontend/pages/profile.tsx

@@ -1,12 +1,12 @@

import {useEffect} from 'react'; -import Layout from '../layouts/Centered'; +import {useRouter} from 'next/router'; import {useTranslation} from 'react-i18next'; -import {useRouter} from 'next/router'; +import {useUpdateMeMutation, EditUserInput} from '../generated/graphql'; +import useAuthStore from '../stores/useAuthStore'; +import useProfile from '../hooks/useProfile'; import Loading from '../containers/Loading'; import Profile from '../containers/Profile'; -import useProfile from '../hooks/useProfile'; -import useAuthStore from '../stores/useAuthStore'; -import {useUpdateMeMutation, EditUserInput} from '../generated/graphql'; +import Layout from '../layouts/Centered'; const ProfilePage = () => { const router = useRouter();
M frontend/stores/useLangStore.tsxfrontend/stores/useLangStore.ts

@@ -17,7 +17,6 @@ setLanguage: language => set({language}),

}), { name: STORAGE_KEY, - getStorage: () => sessionStorage, } ) );
A frontend/stores/useOnboardingStore.ts

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

+import create from 'zustand'; +import {persist} from 'zustand/middleware'; + +const STORAGE_KEY = 'caroster-onboarding'; + +type State = { + onboardingUser: boolean; + onboardingCreator: boolean; + setOnboarding: (onboarding: any) => void; +}; + +const useOnboardingStore = create<State>( + persist( + set => ({ + onboardingUser: false, + onboardingCreator: false, + setOnboarding: onboarding => set(s => ({...s, ...onboarding})), + }), + { + name: STORAGE_KEY, + } + ) +); + +export default useOnboardingStore;
A frontend/stores/useTourStore.ts

@@ -0,0 +1,21 @@

+import create from 'zustand'; + +type State = { + showWelcome: boolean; + isCreator: boolean | null; + run: boolean; + step: number; + prev: number; + setTour: (tour: any) => void; +}; + +const useTourStore = create<State>(set => ({ + showWelcome: false, + isCreator: null, + run: false, + step: -1, + prev: -1, + setTour: tour => set(s => ({...s, ...tour})), +})); + +export default useTourStore;
M frontend/theme.jsfrontend/theme.js

@@ -32,6 +32,15 @@ },

}, }, }, + breakpoints: { + values: { + xs: 0, + sm: 720, + md: 960, + lg: 1280, + xl: 1920, + }, + }, }; export default createMuiTheme(caroster);
M frontend/yarn.lockfrontend/yarn.lock

@@ -3031,6 +3031,11 @@ integrity sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=

dependencies: mimic-response "^1.0.0" +deep-diff@^1.0.2: + version "1.0.2" + resolved "https://npm-8ee.hidora.com/deep-diff/-/deep-diff-1.0.2.tgz#afd3d1f749115be965e89c63edc7abb1506b9c26" + integrity sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg== + deep-extend@^0.6.0: version "0.6.0" resolved "https://npm-8ee.hidora.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"

@@ -3595,6 +3600,11 @@ integrity sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==

dependencies: md5.js "^1.3.4" safe-buffer "^5.1.1" + +exenv@^1.2.2: + version "1.2.2" + resolved "https://npm-8ee.hidora.com/exenv/-/exenv-1.2.2.tgz#2ae78e85d9894158670b03d47bec1f03bd91bb9d" + integrity sha1-KueOhdmJQVhnCwPUe+wfA72Ru50= external-editor@^3.0.3: version "3.1.0"

@@ -4388,6 +4398,11 @@ version "1.1.3"

resolved "https://npm-8ee.hidora.com/is-in-browser/-/is-in-browser-1.1.3.tgz#56ff4db683a078c6082eb95dad7dc62e1d04f835" integrity sha1-Vv9NtoOgeMYILrldrX3GLh0E+DU= +is-lite@^0.8.1: + version "0.8.1" + resolved "https://npm-8ee.hidora.com/is-lite/-/is-lite-0.8.1.tgz#a9bd03c90ea723d450c78c991b84f78e7e3126f9" + integrity sha512-ekSwuewzOmwFnzzAOWuA5fRFPqOeTrLIL3GWT7hdVVi+oLuD+Rau8gCmkb94vH5hjXc1Q/CfIW/y/td1RrNQIg== + is-lower-case@^2.0.2: version "2.0.2" resolved "https://npm-8ee.hidora.com/is-lower-case/-/is-lower-case-2.0.2.tgz#1c0884d3012c841556243483aa5d522f47396d2a"

@@ -5248,6 +5263,16 @@ version "1.4.0"

resolved "https://npm-8ee.hidora.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= +nested-property@1.0.1: + version "1.0.1" + resolved "https://npm-8ee.hidora.com/nested-property/-/nested-property-1.0.1.tgz#2001105b5c69413411b876bba9b86f4316af613f" + integrity sha512-BnBBoo/8bBNRdAnJc7+m79oWk7dXwW1+vCesaEQhfDGVwXGLMvmI4NwYgLTW94R/x+R2s/yr2g/hB/4w/YSAvA== + +nested-property@^4.0.0: + version "4.0.0" + resolved "https://npm-8ee.hidora.com/nested-property/-/nested-property-4.0.0.tgz#a67b5a31991e701e03cdbaa6453bc5b1011bb88d" + integrity sha512-yFehXNWRs4cM0+dz7QxCd06hTbWbSkV0ISsqBfkntU6TOY4Qm3Q88fRRLOddkGh2Qq6dZvnKVAahfhjcUvLnyA== + next-pwa@^5.2.21: version "5.2.21" resolved "https://npm-8ee.hidora.com/next-pwa/-/next-pwa-5.2.21.tgz#fb71ba35b1a984ec6641c5def64ca8c0ab9c2b0f"

@@ -5783,6 +5808,11 @@ version "1.16.1-lts"

resolved "https://npm-8ee.hidora.com/popper.js/-/popper.js-1.16.1-lts.tgz#cf6847b807da3799d80ee3d6d2f90df8a3f50b05" integrity sha512-Kjw8nKRl1m+VrSFCoVGPph93W/qrSO7ZkqPpTf7F4bk/sqcfWK019dWBUpE/fBOsOQY1dks/Bmcbfn1heM/IsA== +popper.js@^1.16.0: + version "1.16.1" + resolved "https://npm-8ee.hidora.com/popper.js/-/popper.js-1.16.1.tgz#2a223cb3dc7b6213d740e40372be40de43e65b1b" + integrity sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ== + postcss@8.2.13: version "8.2.13" resolved "https://npm-8ee.hidora.com/postcss/-/postcss-8.2.13.tgz#dbe043e26e3c068e45113b1ed6375d2d37e2129f"

@@ -5949,6 +5979,18 @@ version "3.2.0"

resolved "https://npm-8ee.hidora.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb" integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA== +react-floater@^0.7.3: + version "0.7.3" + resolved "https://npm-8ee.hidora.com/react-floater/-/react-floater-0.7.3.tgz#f57947960682586866ec21540e73c9049ca9f787" + integrity sha512-d1wAEph+xRxQ0RJ3woMmYLlZHTaCIsja7Bv6JNo2ezsVUgdMan4CxOR4Do4/xgpmRFfsQMdlygexLAZZypWirw== + dependencies: + deepmerge "^4.2.2" + exenv "^1.2.2" + is-lite "^0.8.1" + popper.js "^1.16.0" + react-proptype-conditional-require "^1.0.4" + tree-changes "^0.5.1" + react-helmet@^6.1.0: version "6.1.0" resolved "https://npm-8ee.hidora.com/react-helmet/-/react-helmet-6.1.0.tgz#a750d5165cb13cf213e44747502652e794468726"

@@ -5972,11 +6014,32 @@ version "17.0.2"

resolved "https://npm-8ee.hidora.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== -react-is@^16.7.0, react-is@^16.8.1: +react-is@^16.13.1, react-is@^16.7.0, react-is@^16.8.1: version "16.13.1" resolved "https://npm-8ee.hidora.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== +react-joyride@^2.3.2: + version "2.3.2" + resolved "https://npm-8ee.hidora.com/react-joyride/-/react-joyride-2.3.2.tgz#7a5407b9032ab399c3c624ab21e60ddd40c12d56" + integrity sha512-MPYvHdxF/ylBZXJkKzyR3nTd+jydPeTKVwQDg8y7Ctl5H0UPgvF3uCzfieyJqE/JnK2wJU4QcjkontqBU1g7Xg== + dependencies: + deep-diff "^1.0.2" + deepmerge "^4.2.2" + exenv "^1.2.2" + is-lite "^0.8.1" + nested-property "^4.0.0" + react-floater "^0.7.3" + react-is "^16.13.1" + scroll "^3.0.1" + scrollparent "^2.0.1" + tree-changes "^0.7.1" + +react-proptype-conditional-require@^1.0.4: + version "1.0.4" + resolved "https://npm-8ee.hidora.com/react-proptype-conditional-require/-/react-proptype-conditional-require-1.0.4.tgz#69c2d5741e6df5e08f230f36bbc2944ee1222555" + integrity sha1-acLVdB5t9eCPIw82u8KUTuEiJVU= + react-refresh@0.8.3: version "0.8.3" resolved "https://npm-8ee.hidora.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f"

@@ -6356,6 +6419,16 @@ dependencies:

"@types/json-schema" "^7.0.6" ajv "^6.12.5" ajv-keywords "^3.5.2" + +scroll@^3.0.1: + version "3.0.1" + resolved "https://npm-8ee.hidora.com/scroll/-/scroll-3.0.1.tgz#d5afb59fb3592ee3df31c89743e78b39e4cd8a26" + integrity sha512-pz7y517OVls1maEzlirKO5nPYle9AXsFzTMNJrRGmT951mzpIBy7sNHOg5o/0MQd/NqliCiWnAi0kZneMPFLcg== + +scrollparent@^2.0.1: + version "2.0.1" + resolved "https://npm-8ee.hidora.com/scrollparent/-/scrollparent-2.0.1.tgz#715d5b9cc57760fb22bdccc3befb5bfe06b1a317" + integrity sha1-cV1bnMV3YPsivczDvvtb/gaxoxc= scuid@^1.1.0: version "1.1.0"

@@ -6960,6 +7033,22 @@ resolved "https://npm-8ee.hidora.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09"

integrity sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk= dependencies: punycode "^2.1.0" + +tree-changes@^0.5.1: + version "0.5.1" + resolved "https://npm-8ee.hidora.com/tree-changes/-/tree-changes-0.5.1.tgz#e31cc8a0f56c8c401f0a88243d9165dbea4f570c" + integrity sha512-O873xzV2xRZ6N059Mn06QzmGKEE21LlvIPbsk2G+GS9ZX5OCur6PIwuuh0rWpAPvLWQZPj0XObyG27zZyLHUzw== + dependencies: + deep-diff "^1.0.2" + nested-property "1.0.1" + +tree-changes@^0.7.1: + version "0.7.1" + resolved "https://npm-8ee.hidora.com/tree-changes/-/tree-changes-0.7.1.tgz#fa8810cbe417e80b9a42c4b018f934c7ad8fa156" + integrity sha512-sPIt8PKDi0OQTglr7lsetcB9DU19Ls/ZgFSjFvK6DWJGisAn4sOxtjpmQfuqjexQE4UU9U53LNmataL1kRJ3Uw== + dependencies: + fast-deep-equal "^3.1.3" + is-lite "^0.8.1" ts-invariant@^0.7.0: version "0.7.3"