all repos — caroster @ 27332dc9778a133a03349f1ebc56af222fff16f0

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

feat: :sparkles: Add Plus option during event creation flow

#549
Tim Izzo tim@octree.ch
Wed, 18 Dec 2024 14:15:53 +0100
commit

27332dc9778a133a03349f1ebc56af222fff16f0

parent

10d46b89199cd58d202c134bd807b1473ae66bf5

M backend/src/api/event/content-types/event/lifecycles.tsbackend/src/api/event/content-types/event/lifecycles.ts

@@ -19,7 +19,7 @@ data.users = [userCreator.id];

} }, async afterCreate({ result }) { - if (!result.isReturnEvent) + if (!result.isReturnEvent && !result.unpaid) await strapi .service("api::email.email") .sendEmailNotif(result.email, "EventCreated", result.lang, {
M backend/src/api/event/content-types/event/schema.jsonbackend/src/api/event/content-types/event/schema.json

@@ -96,6 +96,10 @@ "linkedEvent": {

"type": "relation", "relation": "oneToOne", "target": "api::event.event" + }, + "unpaid": { + "type": "boolean", + "default": false } } }
M backend/src/api/stripe/controllers/stripe.tsbackend/src/api/stripe/controllers/stripe.ts

@@ -19,16 +19,16 @@

try { const payload = ctx.request.body[Symbol.for("unparsedBody")]; const sig = ctx.request.headers["stripe-signature"]; - const event = stripe.webhooks.constructEvent( + const stripeEvent = stripe.webhooks.constructEvent( payload, sig, ENDPOINT_SECRET ); - if (event.type === "checkout.session.completed") { - strapi.service("api::stripe.stripe").enableModule(event); + if (stripeEvent.type === "checkout.session.completed") { + strapi.service("api::stripe.stripe").enableModule(stripeEvent); } else strapi.log.warn( - `[Stripe] Received webhook of type ${event.type} (ignored)` + `[Stripe] Received webhook of type ${stripeEvent.type} (ignored)` ); ctx.body = "ok"; } catch (err) {
M backend/src/api/stripe/services/stripe.tsbackend/src/api/stripe/services/stripe.ts

@@ -43,7 +43,7 @@ ? [...event.enabled_modules, moduleProduct.name]

: [moduleProduct.name]; await strapi.db.query("api::event.event").update({ where: { uuid: eventUuid }, - data: { enabled_modules: enabledModules }, + data: { enabled_modules: enabledModules, unpaid: false }, }); strapi.log.info( `Module '${moduleProduct.name}' enabled for event ${eventUuid}`

@@ -52,7 +52,9 @@

if (event.creator) strapi.entityService.create("api::notification.notification", { data: { - type: moduleProduct.notificationType, + type: event.unpaid // unpaid before update + ? "EventCreated" + : moduleProduct.notificationType, event, user: event.creator, },
M backend/types/generated/contentTypes.d.tsbackend/types/generated/contentTypes.d.ts

@@ -422,6 +422,7 @@ 'api::event.event',

'oneToMany', 'api::travel.travel' >; + unpaid: Attribute.Boolean & Attribute.DefaultTo<false>; updatedAt: Attribute.DateTime; updatedBy: Attribute.Relation< 'api::event.event',
D frontend/containers/CreateEvent/Step1.tsx

@@ -1,127 +0,0 @@

-import React, {useState, useEffect, useMemo} from 'react'; -import Typography from '@mui/material/Typography'; -import TextField from '@mui/material/TextField'; -import Button from '@mui/material/Button'; -import Checkbox from '@mui/material/Checkbox'; -import Box from '@mui/material/Box'; -import FormControlLabel from '@mui/material/FormControlLabel'; -import NextLink from 'next/link'; -import CardActions from '@mui/material/CardActions'; -import {useTheme} from '@mui/material/styles'; -import {useTranslation} from 'next-i18next'; -import {useSession} from 'next-auth/react'; -import useDebounce from '../../hooks/useDebounce'; -import {isValidEmail} from '../../lib/formValidation'; - -const Step1 = ({nextStep, event, addToEvent}) => { - const theme = useTheme(); - const {t} = useTranslation(); - const session = useSession(); - const user = session?.data?.user; - const isAuthenticated = session.status === 'authenticated'; - - // States - const [name, setName] = useState(event.name ?? ''); - const [email, setEmail] = useState(event.email ?? ''); - const [emailIsValid, setEmailIsValid] = useState(false); - const [newsletter, setNewsletter] = useState(false); - const debouncedEmail = useDebounce(email, 400); - - useEffect(() => { - setEmailIsValid(isValidEmail(debouncedEmail)); - }, [debouncedEmail]); - - const canSubmit = useMemo(() => { - const n = name.length > 0; - const e = email.length > 0 && emailIsValid; - return isAuthenticated ? n : n && e; - }, [name, email, emailIsValid, isAuthenticated]); - - const onNext = submitEvent => { - if (submitEvent.preventDefault) submitEvent.preventDefault(); - addToEvent({ - name, - email: isAuthenticated ? user.email : email, - newsletter: isAuthenticated ? true : newsletter, - }); - nextStep(); - return false; - }; - - return ( - <Box component="form" onSubmit={onNext}> - <TextField - label={t('event.creation.name')} - fullWidth - autoFocus - margin="dense" - variant="standard" - value={name} - onChange={e => setName(e.target.value)} - id="NewEventName" - name="name" - /> - {!isAuthenticated && ( - <> - <TextField - label={t('event.creation.creator_email')} - fullWidth - variant="standard" - value={email} - onChange={e => setEmail(e.target.value)} - name="email" - type="email" - id="NewEventEmail" - /> - <FormControlLabel - sx={{marginTop: theme.spacing(2)}} - label={t('event.creation.newsletter')} - control={ - <Checkbox - name="newsletter" - color="primary" - id="NewEventNewsletter" - checked={newsletter} - onChange={e => setNewsletter(e.target.checked)} - /> - } - /> - </> - )} - <Button - sx={{marginTop: theme.spacing(2)}} - type="submit" - variant="contained" - color="primary" - fullWidth - disabled={!canSubmit} - aria-disabled={!canSubmit} - > - {t('event.creation.next')} - </Button> - - {!isAuthenticated && ( - <CardActions - sx={{ - marginTop: theme.spacing(1), - justifyContent: 'space-evenly', - textAlign: 'center', - }} - > - <NextLink href="/auth/login" passHref> - <Button variant="text"> - {t('event.creation.addFromAccount.actions.register')} - </Button> - </NextLink> - <NextLink href="/auth/login" passHref> - <Button color="primary"> - {t('event.creation.addFromAccount.actions.login')} - </Button> - </NextLink> - </CardActions> - )} - </Box> - ); -}; - -export default Step1;
D frontend/containers/CreateEvent/Step2.tsx

@@ -1,111 +0,0 @@

-import {useState} from 'react'; -import moment from 'moment'; -import TextField from '@mui/material/TextField'; -import Button from '@mui/material/Button'; -import CircularProgress from '@mui/material/CircularProgress'; -import Box from '@mui/material/Box'; -import {useTheme} from '@mui/material/styles'; -import {useRouter} from 'next/router'; -import {DatePicker} from '@mui/x-date-pickers/DatePicker'; -import {useTranslation} from 'next-i18next'; -import useToastStore from '../../stores/useToastStore'; -import PlaceInput from '../PlaceInput'; -import {EventEntity, EventInput} from '../../generated/graphql'; - -interface Props { - event: EventInput; - addToEvent: (eventData: EventInput) => void; - createEvent: (eventData: EventInput) => Promise<EventEntity>; -} - -const Step2 = ({event, addToEvent, createEvent}: Props) => { - const {t} = useTranslation(); - const theme = useTheme(); - const router = useRouter(); - const addToast = useToastStore(s => s.addToast); - - // States - const [date, setDate] = useState(null); - const [address, setAddress] = useState(event.address ?? ''); - const [longitude, setLongitude] = useState(event.longitude); - const [latitude, setLatitude] = useState(event.latitude); - const [description, setDescription] = useState(event.description ?? ''); - const [loading, setLoading] = useState(false); - - const onCreate = async evt => { - evt.preventDefault?.(); - if (loading) return; - setLoading(true); - const eventData = { - date: date ? moment(date).format('YYYY-MM-DD') : null, - address, - longitude, - latitude, - description, - }; - addToEvent(eventData); - const result = await createEvent(eventData); - if (!result) addToast(t('event.errors.cant_create')); - else router.push(`/e/${result.attributes.uuid}`); - setLoading(false); - return; - }; - - return ( - <Box component="form" onSubmit={onCreate}> - <DatePicker - slotProps={{textField: {fullWidth: true, variant: 'standard'}}} - format="DD/MM/YYYY" - label={t('event.creation.date')} - value={date} - onChange={setDate} - /> - <PlaceInput - label={t('event.fields.address')} - textFieldProps={{sx: {mt: 2}}} - place={address} - latitude={event.latitude} - longitude={event.longitude} - onSelect={({place, latitude, longitude}) => { - setAddress(place); - setLatitude(latitude); - setLongitude(longitude); - }} - /> - <TextField - label={t('event.creation.description')} - fullWidth - multiline - sx={{mt: 2}} - variant="standard" - maxRows={4} - inputProps={{maxLength: 250}} - helperText={ - description.length === 0 - ? t('event.creation.description_helper') - : `${description.length}/250` - } - value={description} - onChange={e => setDescription(e.target.value)} - name="address" - /> - <Button - disabled={loading} - sx={{marginTop: theme.spacing(2)}} - variant="contained" - color="primary" - fullWidth - type="submit" - id="NewEventSubmit" - > - {loading ? ( - <CircularProgress size={20} color="primary" /> - ) : ( - t('generic.create') - )} - </Button> - </Box> - ); -}; - -export default Step2;
D frontend/containers/CreateEvent/index.tsx

@@ -1,56 +0,0 @@

-import {useState, useReducer} from 'react'; -import useAddToEvents from '../../hooks/useAddToEvents'; -import Step1 from './Step1'; -import Step2 from './Step2'; -import { - EventInput, - ProfileDocument, - useCreateEventMutation, -} from '../../generated/graphql'; -import useLocale from '../../hooks/useLocale'; - -const STEPS = [Step1, Step2]; - -const CreateEvent = () => { - const [step, setStep] = useState(0); - const [event, addToEvent] = useReducer(eventReducer, {} as EventInput); - const [sendEvent] = useCreateEventMutation(); - const {addToEvent: addToUserEvents} = useAddToEvents(); - const {locale} = useLocale(); - const Step = STEPS[step]; - - const createEvent = async (eventData: EventInput) => { - try { - const {data} = await sendEvent({ - variables: { - eventData: { - ...event, - ...eventData, - lang: locale, - }, - }, - refetchQueries: [ProfileDocument], - }); - const createdEvent = data.createEvent.data; - addToUserEvents(createdEvent.id); - return createdEvent; - } catch (err) { - console.error(err); - } - }; - - return ( - <Step - event={event} - addToEvent={addToEvent} - createEvent={createEvent} - nextStep={() => setStep(step + 1)} - previousStep={() => setStep(step - 1)} - id="NewEvent" - /> - ); -}; - -const eventReducer = (state, item) => ({...state, ...item}); - -export default CreateEvent;
D frontend/containers/CreateEvent/store.ts

@@ -1,26 +0,0 @@

-import {create} from 'zustand'; - -type Event = { - name?: string; - email?: string; - newsletter?: boolean; - date?: string; - address?: string; -}; - -type State = { - event: Event; - setEventFields: (fields: Event) => void; - reset: () => void; -}; - -const useEventStore = create<State>((set, get) => ({ - event: {}, - setEventFields: fields => { - const event = get().event; - set({event: {...event, ...fields}}); - }, - reset: () => set({event: {}}), -})); - -export default useEventStore;
A frontend/containers/EventTypeCard/BasicAction.tsx

@@ -0,0 +1,52 @@

+import {Button} from '@mui/material'; +import {useTranslation} from 'react-i18next'; +import useEventCreationStore from '../../stores/useEventCreationStore'; +import {ProfileDocument, useCreateEventMutation} from '../../generated/graphql'; +import useLocale from '../../hooks/useLocale'; +import useAddToEvents from '../../hooks/useAddToEvents'; +import {useRouter} from 'next/router'; +import {useSession} from 'next-auth/react'; + +type Props = {}; + +const BasicAction = (props: Props) => { + const {t} = useTranslation(); + const router = useRouter(); + const {locale} = useLocale(); + const event = useEventCreationStore(s => s.event); + const [createEvent] = useCreateEventMutation(); + const {addToEvent: addToUserEvents} = useAddToEvents(); + const session = useSession(); + const profile = session?.data?.profile; + + const onClick = async () => { + try { + const {data} = await createEvent({ + variables: { + eventData: { + ...event, + lang: locale, + email: event.email || profile?.email, + }, + }, + refetchQueries: [ProfileDocument], + }); + const createdEvent = data.createEvent.data; + addToUserEvents(createdEvent.id); + useEventCreationStore.persist.clearStorage(); + router.push(`/${locale}/e/${createdEvent.attributes.uuid}`); + } catch (error) { + console.error(error); + } + }; + + return ( + <Button + fullWidth + variant="outlined" + onClick={onClick} + >{t`event.creation.basic.button`}</Button> + ); +}; + +export default BasicAction;
A frontend/containers/EventTypeCard/PlusAction.tsx

@@ -0,0 +1,65 @@

+import {Button} from '@mui/material'; +import {useSession} from 'next-auth/react'; +import Link from 'next/link'; +import {useTranslation} from 'react-i18next'; +import useLocale from '../../hooks/useLocale'; +import useEventCreationStore from '../../stores/useEventCreationStore'; +import {ProfileDocument, useCreateEventMutation} from '../../generated/graphql'; +import useAddToEvents from '../../hooks/useAddToEvents'; +import {setCookie} from '../../lib/cookies'; + +type Props = { + paymentLink: string; +}; + +const PlusAction = (props: Props) => { + const {paymentLink} = props; + const {t} = useTranslation(); + const session = useSession(); + const isAuthenticated = session.status === 'authenticated'; + const {locale} = useLocale(); + const event = useEventCreationStore(s => s.event); + const [createEvent] = useCreateEventMutation(); + const {addToEvent: addToUserEvents} = useAddToEvents(); + const profile = session?.data?.profile; + + const onClick = async () => { + try { + const {data} = await createEvent({ + variables: { + eventData: { + ...event, + lang: locale, + email: event.email || profile?.email, + unpaid: true, + }, + }, + refetchQueries: [ProfileDocument], + }); + const createdEvent = data.createEvent.data; + addToUserEvents(createdEvent.id); + useEventCreationStore.persist.clearStorage(); + setCookie('redirectPath', `/${locale}/e/${createdEvent.attributes.uuid}`); + window.location.href = `${paymentLink}?client_reference_id=${createdEvent.attributes.uuid}&locale=${locale}&prefilled_email=${profile?.email}`; + } catch (error) { + console.error(error); + } + }; + + if (isAuthenticated) + return ( + <Button + fullWidth + variant="outlined" + onClick={onClick} + >{t`event.creation.plus.button`}</Button> + ); + else + return ( + <Link href={`/auth/login?redirectPath=/new/type/`} passHref> + <Button variant="outlined" fullWidth>{t`signin.title`}</Button> + </Link> + ); +}; + +export default PlusAction;
A frontend/containers/EventTypeCard/index.tsx

@@ -0,0 +1,67 @@

+import {Box, Paper, Typography} from '@mui/material'; +import {useTranslation} from 'react-i18next'; +import BasicAction from './BasicAction'; +import PlusAction from './PlusAction'; +import {Module} from '../../generated/graphql'; + +type Props = { + type: 'basic' | 'plus'; + moduleConfig?: Module; +}; + +const EventTypeCard = (props: Props) => { + const {type, moduleConfig} = props; + const {t} = useTranslation(); + + return ( + <Box + component={Paper} + p={2} + width={1} + display="flex" + flexDirection="column" + justifyContent="space-between" + > + <Typography color="primary" variant="h5"> + {t(`event.creation.${type}.title`)} + </Typography> + <Typography color="textSecondary"> + {t(`event.creation.${type}.subtitle`)} + </Typography> + {type === 'basic' && ( + <Box display="flex" alignItems="baseline" pt={3.75} pb={2}> + <Typography fontSize={64} lineHeight={1}> + 0 + </Typography> + <Typography fontSize={24} lineHeight={1}> + € + </Typography> + </Box> + )} + {type === 'plus' && ( + <Box py={2}> + <Typography fontSize={14} lineHeight={1}> + {t`event.creation.plus.fromPrice`} + </Typography> + <Box display="flex" alignItems="baseline"> + <Typography fontSize={64} lineHeight={1}> + {moduleConfig?.caroster_plus_price} + </Typography> + <Typography fontSize={24} lineHeight={1}> + € + </Typography> + </Box> + </Box> + )} + <Typography color="textSecondary" pb={3}> + {t(`event.creation.${type}.description`)} + </Typography> + {type === 'basic' && <BasicAction />} + {type === 'plus' && ( + <PlusAction paymentLink={moduleConfig?.caroster_plus_payment_link} /> + )} + </Box> + ); +}; + +export default EventTypeCard;
M frontend/generated/graphql.tsxfrontend/generated/graphql.tsx

@@ -153,6 +153,7 @@ name: Scalars['String']['output'];

passengers?: Maybe<PassengerRelationResponseCollection>; travels?: Maybe<TravelRelationResponseCollection>; tripAlerts?: Maybe<TripAlertEntityResponseCollection>; + unpaid?: Maybe<Scalars['Boolean']['output']>; updatedAt?: Maybe<Scalars['DateTime']['output']>; uuid?: Maybe<Scalars['String']['output']>; waitingPassengers?: Maybe<PassengerRelationResponseCollection>;

@@ -205,6 +206,7 @@ not?: InputMaybe<EventFiltersInput>;

or?: InputMaybe<Array<InputMaybe<EventFiltersInput>>>; passengers?: InputMaybe<PassengerFiltersInput>; travels?: InputMaybe<TravelFiltersInput>; + unpaid?: InputMaybe<BooleanFilterInput>; updatedAt?: InputMaybe<DateTimeFilterInput>; users?: InputMaybe<UsersPermissionsUserFiltersInput>; uuid?: InputMaybe<StringFilterInput>;

@@ -227,6 +229,7 @@ name?: InputMaybe<Scalars['String']['input']>;

newsletter?: InputMaybe<Scalars['Boolean']['input']>; passengers?: InputMaybe<Array<InputMaybe<Scalars['ID']['input']>>>; travels?: InputMaybe<Array<InputMaybe<Scalars['ID']['input']>>>; + unpaid?: InputMaybe<Scalars['Boolean']['input']>; users?: InputMaybe<Array<InputMaybe<Scalars['ID']['input']>>>; uuid?: InputMaybe<Scalars['String']['input']>; };

@@ -538,6 +541,7 @@ };

export type MutationCreateTravelArgs = { + createVehicle?: InputMaybe<Scalars['Boolean']['input']>; data: TravelInput; };
A frontend/layouts/EventCreation.tsx

@@ -0,0 +1,56 @@

+import {Paper} from '@mui/material'; +import Layout from './Centered'; +import Logo from '../components/Logo'; +import LanguagesIcon from '../containers/Languages/Icon'; +import {useTranslation} from 'react-i18next'; +import {useSession} from 'next-auth/react'; +import {useRouter} from 'next/router'; +import {ReactNode} from 'react'; + +interface Props { + announcement?: string; + children: ReactNode; +} + +const EventCreationLayout = (props: Props) => { + const {t} = useTranslation(); + const router = useRouter(); + const session = useSession(); + const isAuthenticated = session.status === 'authenticated'; + + const menuActions = isAuthenticated + ? [ + { + label: t('menu.profile'), + onClick: () => router.push('/profile'), + id: 'ProfileTabs', + }, + {divider: true}, + { + label: t('menu.dashboard'), + onClick: () => router.push('/dashboard'), + id: 'SeeDashboardTabs', + }, + ] + : [ + { + label: t('menu.login'), + onClick: () => router.push('/auth/login'), + id: 'LoginTabs', + }, + ]; + + return ( + <Layout + menuTitle={t('event.creation.title')} + displayMenu={isAuthenticated} + menuActions={menuActions} + {...props} + > + {props.children} + {!isAuthenticated && <LanguagesIcon displayMenu={false} />} + </Layout> + ); +}; + +export default EventCreationLayout;
M frontend/locales/en.jsonfrontend/locales/en.json

@@ -49,6 +49,16 @@ "event.creation.name": "Event name",

"event.creation.newsletter": "Keep me informed of developments in Caroster by e-mail", "event.creation.next": "Next", "event.creation.title": "New event", + "event.creation.chooseType": "Choose the type of event that best suits your needs", + "event.creation.basic.title": "Caroster Basic", + "event.creation.basic.subtitle": "Perfect for small events", + "event.creation.basic.description": "Caroster Basic provides a simple and accessible platform for everyone. Ideal for easily organizing your carpooling.", + "event.creation.basic.button": "Create my Caroster Basic", + "event.creation.plus.title": "Caroster Plus", + "event.creation.plus.subtitle": "For events of 100+ people", + "event.creation.plus.description": "Caroster Plus offers a secure platform with administrator roles. Ideal for large events.", + "event.creation.plus.button": "Go to payment options", + "event.creation.plus.fromPrice": "from", "event.details": "Information", "event.details.modify": "Modify", "event.details.save": "Save",
M frontend/locales/fr.jsonfrontend/locales/fr.json

@@ -45,6 +45,16 @@ "event.creation.name": "Nom de l'événement",

"event.creation.newsletter": "Me tenir informé des évolutions de Caroster par e-mail", "event.creation.next": "Suivant", "event.creation.title": "Nouvel évènement", + "event.creation.chooseType": "Choisissez le type d’événement le mieux adapté à vos besoins", + "event.creation.basic.title": "Caroster Basic", + "event.creation.basic.subtitle": "Parfait pour les petits événements", + "event.creation.basic.description": "Caroster Basic offre une plateforme simple et accessible à tous. Idéal pour organiser simplement vos covoiturages.", + "event.creation.basic.button": "Créer mon Caroster Basic ", + "event.creation.plus.title": "Caroster Plus", + "event.creation.plus.subtitle": "Pour les événements de +100 personnes", + "event.creation.plus.description": "Caroster Plus offre une plateforme sécurisée avec des rôles d’administrateurs. Idéal pour les grands événements.", + "event.creation.plus.button": "Vers les options de paiement", + "event.creation.plus.fromPrice": "dès", "event.details": "Informations", "event.details.modify": "Modifier", "event.details.save": "Enregistrer",

@@ -186,6 +196,7 @@ "signin.email": "Email",

"signin.errors.CredentialsSignin": "Le lien de connexion est échu. Merci d'entrer votre email pour recevoir un nouveau lien.", "signin.errors.GoogleAccount": "Cette adresse email est liée à Google. Merci d'utilisation la connexion avec Google.", "signin.login": "$t(menu.login)", + "signin.sendLink": "Envoyer le lien de connexion", "signin.check_email": "Un lien de connexion vous a été envoyé par e-mail. Veuillez vérifier vos e-mail pour terminer la connexion.", "signin.or": "OU", "signin.password": "Mot de passe",
M frontend/pages/auth/login.tsxfrontend/pages/auth/login.tsx

@@ -75,7 +75,7 @@ variant="contained"

disabled={!email} onClick={handleSubmit} > - {t('signin.login')} + {t('signin.sendLink')} </Button> <Typography align="center">{t('signin.or')}</Typography> <LoginGoogle />
D frontend/pages/new.tsx

@@ -1,75 +0,0 @@

-import {useRouter} from 'next/router'; -import {useTranslation} from 'next-i18next'; -import Layout from '../layouts/Centered'; -import CreateEvent from '../containers/CreateEvent'; -import LanguagesIcon from '../containers/Languages/Icon'; -import Logo from '../components/Logo'; -import {useSession} from 'next-auth/react'; -import pageUtils from '../lib/pageUtils'; -import theme from '../theme'; -import Paper from '@mui/material/Paper'; - -interface PageProps { - announcement?: string; -} - -const Home = (props: PageProps) => { - const {t} = useTranslation(); - const router = useRouter(); - const session = useSession(); - const isAuthenticated = session.status === 'authenticated'; - const isReady = session.status !== 'loading'; - - const noUserMenuActions = [ - { - label: t('menu.login'), - onClick: () => router.push('/auth/login'), - id: 'LoginTabs', - }, - ]; - - const loggedMenuActions = [ - { - label: t('menu.profile'), - onClick: () => router.push('/profile'), - id: 'ProfileTabs', - }, - {divider: true}, - { - label: t('menu.dashboard'), - onClick: () => router.push('/dashboard'), - id: 'SeeDashboardTabs', - }, - ]; - - const menuActions = isAuthenticated ? loggedMenuActions : noUserMenuActions; - - if (!isReady) return null; - - return ( - <Layout - menuTitle={t('event.creation.title')} - menuActions={menuActions} - displayMenu={isAuthenticated} - {...props} - > - <Paper - sx={{ - padding: theme.spacing(2), - width: '480px', - maxWidth: '100%', - display: 'block', - margin: '0 auto', - }} - > - <Logo /> - <CreateEvent /> - </Paper> - {!isAuthenticated && <LanguagesIcon displayMenu={false} />} - </Layout> - ); -}; - -export const getServerSideProps = pageUtils.getServerSideProps(); - -export default Home;
A frontend/pages/new/details.tsx

@@ -0,0 +1,90 @@

+import pageUtils from '../../lib/pageUtils'; +import Layout from '../../layouts/EventCreation'; +import {DatePicker} from '@mui/x-date-pickers/DatePicker'; +import {useTranslation} from 'react-i18next'; +import useEventCreationStore from '../../stores/useEventCreationStore'; +import {Button, Paper, Stack, TextField} from '@mui/material'; +import PlaceInput from '../../containers/PlaceInput'; +import NextLink from 'next/link'; +import moment from 'moment'; +import Logo from '../../components/Logo'; +import {useEffect} from 'react'; +import {useRouter} from 'next/router'; + +const NewEventDetails = () => { + const {t} = useTranslation(); + const router = useRouter(); + const event = useEventCreationStore(s => s.event); + const setField = useEventCreationStore(s => s.setField); + + useEffect(() => { + if (!event.name) router.push('/new'); + }, [event.name, router]); + + return ( + <Layout> + <Paper + sx={{ + p: 2, + width: '480px', + maxWidth: '100%', + display: 'block', + margin: '0 auto', + }} + > + <Logo /> + <Stack spacing={2}> + <DatePicker + slotProps={{textField: {fullWidth: true, variant: 'standard'}}} + format="DD/MM/YYYY" + label={t('event.creation.date')} + value={event.date ? moment(event.date, 'YYYY-MM-DD') : null} + onChange={value => setField('date', value?.format('YYYY-MM-DD'))} + /> + <PlaceInput + label={t('event.fields.address')} + place={event.address} + latitude={event.latitude} + longitude={event.longitude} + onSelect={({place, latitude, longitude}) => { + setField('address', place); + setField('latitude', latitude); + setField('longitude', longitude); + }} + /> + <TextField + fullWidth + multiline + maxRows={4} + variant="standard" + label={t('event.creation.description')} + slotProps={{htmlInput: {maxLength: 250}}} + helperText={ + event.description?.length > 0 + ? `${event.description.length}/250` + : t('event.creation.description_helper') + } + value={event.description} + onChange={e => setField('description', e.target.value)} + name="address" + /> + <NextLink href="/new/type" passHref> + <Button + variant="contained" + color="primary" + fullWidth + type="submit" + id="NewEventSubmit" + > + {t('event.creation.next')} + </Button> + </NextLink> + </Stack> + </Paper> + </Layout> + ); +}; + +export const getServerSideProps = pageUtils.getServerSideProps(); + +export default NewEventDetails;
A frontend/pages/new/index.tsx

@@ -0,0 +1,125 @@

+import pageUtils from '../../lib/pageUtils'; +import Layout from '../../layouts/EventCreation'; +import { + Button, + CardActions, + Checkbox, + FormControlLabel, + Paper, + Stack, + TextField, +} from '@mui/material'; +import useEventCreationStore from '../../stores/useEventCreationStore'; +import {useTranslation} from 'react-i18next'; +import {useSession} from 'next-auth/react'; +import {useMemo} from 'react'; +import {isValidEmail} from '../../lib/formValidation'; +import NextLink from 'next/link'; +import Logo from '../../components/Logo'; + +interface Props { + announcement?: string; +} + +const NewEvent = (props: Props) => { + const {t} = useTranslation(); + const session = useSession(); + const isAuthenticated = session.status === 'authenticated'; + const event = useEventCreationStore(s => s.event); + const setField = useEventCreationStore(s => s.setField); + + const canSubmit = useMemo(() => { + const nameIsOk = event.name?.length > 0; + const emailIsOk = event.email?.length > 0 && isValidEmail(event.email); + return isAuthenticated ? nameIsOk : nameIsOk && emailIsOk; + }, [event, , isAuthenticated]); + + return ( + <Layout {...props}> + <Paper + sx={{ + p: 2, + width: '480px', + maxWidth: '100%', + display: 'block', + margin: '0 auto', + }} + > + <Logo /> + <Stack spacing={2}> + <TextField + label={t('event.creation.name')} + fullWidth + autoFocus + margin="dense" + variant="standard" + value={event.name} + onChange={e => setField('name', e.target.value)} + id="NewEventName" + name="name" + /> + {!isAuthenticated && ( + <> + <TextField + label={t('event.creation.creator_email')} + fullWidth + variant="standard" + value={event.email} + onChange={e => setField('email', e.target.value)} + name="email" + type="email" + id="NewEventEmail" + /> + <FormControlLabel + label={t('event.creation.newsletter')} + control={ + <Checkbox + name="newsletter" + color="primary" + id="NewEventNewsletter" + checked={event.newsletter} + onChange={e => setField('newsletter', e.target.checked)} + /> + } + /> + </> + )} + <NextLink href="/new/details" passHref> + <Button + fullWidth + variant="contained" + color="primary" + disabled={!canSubmit} + aria-disabled={!canSubmit} + > + {t('event.creation.next')} + </Button> + </NextLink> + {!isAuthenticated && ( + <CardActions + sx={{ + justifyContent: 'space-evenly', + textAlign: 'center', + }} + > + <NextLink href="/auth/login" passHref> + <Button variant="text"> + {t('event.creation.addFromAccount.actions.register')} + </Button> + </NextLink> + <NextLink href="/auth/login" passHref> + <Button color="primary"> + {t('event.creation.addFromAccount.actions.login')} + </Button> + </NextLink> + </CardActions> + )} + </Stack> + </Paper> + </Layout> + ); +}; + +export const getServerSideProps = pageUtils.getServerSideProps(); + +export default NewEvent;
A frontend/pages/new/type.tsx

@@ -0,0 +1,47 @@

+import pageUtils from '../../lib/pageUtils'; +import Layout from '../../layouts/EventCreation'; +import {useTranslation} from 'react-i18next'; +import {Box, Typography, useMediaQuery, useTheme} from '@mui/material'; +import EventTypeCard from '../../containers/EventTypeCard'; +import {useEffect} from 'react'; +import {useRouter} from 'next/router'; +import useEventCreationStore from '../../stores/useEventCreationStore'; +import {useModuleQuery} from '../../generated/graphql'; +import useLocale from '../../hooks/useLocale'; + +const NewEventType = () => { + const {t} = useTranslation(); + const router = useRouter(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('md')); + const event = useEventCreationStore(s => s.event); + const {locale} = useLocale(); + const {data: moduleData} = useModuleQuery({variables: {locale}}); + const moduleConfig = moduleData?.module?.data?.attributes; + + useEffect(() => { + if (!event.name) router.push('/new'); + }, [event.name, router]); + + return ( + <Layout> + <Box display="flex" justifyContent="center"> + <Typography + variant="h3" + align="center" + maxWidth={400} + >{t`event.creation.chooseType`}</Typography> + </Box> + <Box display="flex" gap={4} py={4} flexWrap={isMobile && 'wrap'}> + <EventTypeCard type="basic" /> + {moduleConfig?.caroster_plus_payment_link && ( + <EventTypeCard type="plus" moduleConfig={moduleConfig} /> + )} + </Box> + </Layout> + ); +}; + +export const getServerSideProps = pageUtils.getServerSideProps(); + +export default NewEventType;
A frontend/stores/useEventCreationStore.ts

@@ -0,0 +1,26 @@

+import {create} from 'zustand'; +import {persist, createJSONStorage} from 'zustand/middleware'; +import {EventInput} from '../generated/graphql'; + +interface State { + event: Partial<EventInput>; + setField: (fieldName: keyof EventInput, value: unknown) => void; +} + +const useEventCreationStore = create<State>()( + persist( + (set, get) => ({ + event: {}, + setField: (field, value) => { + const currentEvent = get().event; + set({event: {...currentEvent, [field]: value}}); + }, + }), + { + name: 'event-creation', + storage: createJSONStorage(() => localStorage), + } + ) +); + +export default useEventCreationStore;