diff --git a/packages/api/src/fetchOption.ts b/packages/api/src/fetchOption.ts index 138d29af..9feccc95 100644 --- a/packages/api/src/fetchOption.ts +++ b/packages/api/src/fetchOption.ts @@ -1,9 +1,9 @@ -import { ApiRequest, GetQuestionOptionResponse } from './types'; +import { ApiRequest, GetOptionResponse } from './types'; export async function fetchOption({ serverUrl, optionId, -}: ApiRequest<{ optionId: string }>): Promise { +}: ApiRequest<{ optionId: string }>): Promise { try { const response = await fetch(`${serverUrl}/api/options/${optionId}`, { credentials: 'include', @@ -15,7 +15,7 @@ export async function fetchOption({ throw new Error(`HTTP Error!, Status: ${response.status}`); } - const option = (await response.json()) as { data: GetQuestionOptionResponse }; + const option = (await response.json()) as { data: GetOptionResponse }; return option.data; } catch (error) { console.error('Error fetching option:', error); diff --git a/packages/api/src/postRegistration.ts b/packages/api/src/postRegistration.ts index 365eed51..11d2b267 100644 --- a/packages/api/src/postRegistration.ts +++ b/packages/api/src/postRegistration.ts @@ -5,7 +5,7 @@ export async function postRegistration({ serverUrl, }: ApiRequest<{ body: PostRegistrationRequest; -}>): Promise { +}>): Promise { try { const response = await fetch(`${serverUrl}/api/registrations`, { method: 'POST', @@ -17,6 +17,10 @@ export async function postRegistration({ }); if (!response.ok) { + if (response.status < 500) { + const errors = (await response.json()) as { errors: string[] }; + return errors; + } throw new Error(`HTTP error! Status: ${response.status}`); } diff --git a/packages/api/src/putRegistration.ts b/packages/api/src/putRegistration.ts index 9ddbf0e5..2a658c07 100644 --- a/packages/api/src/putRegistration.ts +++ b/packages/api/src/putRegistration.ts @@ -7,7 +7,7 @@ export async function putRegistration({ }: ApiRequest<{ registrationId: string; body: PutRegistrationRequest; -}>): Promise { +}>): Promise { try { const response = await fetch(`${serverUrl}/api/registrations/${registrationId}`, { method: 'PUT', @@ -19,6 +19,10 @@ export async function putRegistration({ }); if (!response.ok) { + if (response.status < 500) { + const errors = (await response.json()) as { errors: string[] }; + return errors; + } throw new Error(`HTTP error! Status: ${response.status}`); } diff --git a/packages/api/src/types/Events.ts b/packages/api/src/types/Events.ts index c4082712..2ea07899 100644 --- a/packages/api/src/types/Events.ts +++ b/packages/api/src/types/Events.ts @@ -7,6 +7,7 @@ export type GetEventResponse = { createdAt: string; updatedAt: string; description: string | null; + fields: unknown; status: 'OPEN' | 'CLOSED' | 'UPCOMING' | null; }; diff --git a/packages/api/src/types/Options.ts b/packages/api/src/types/Options.ts index 659b519a..8c0dbfe6 100644 --- a/packages/api/src/types/Options.ts +++ b/packages/api/src/types/Options.ts @@ -1,4 +1,4 @@ -export type QuestionOption = { +export type Option = { id: string; createdAt: string; updatedAt: string; @@ -7,11 +7,12 @@ export type QuestionOption = { userId?: string; optionSubTitle?: string; accepted: boolean; + data: unknown; fundingRequest: string; }; -export type GetQuestionOptionRequest = { +export type GetOptionRequest = { optionId: string; }; -export type GetQuestionOptionResponse = QuestionOption; +export type GetOptionResponse = Option; diff --git a/packages/api/src/types/Questions.ts b/packages/api/src/types/Questions.ts index a44ccc38..3e9b1805 100644 --- a/packages/api/src/types/Questions.ts +++ b/packages/api/src/types/Questions.ts @@ -25,4 +25,5 @@ export type Question = { description: string | null; cycleId: string; title: string; + fields: unknown; }; diff --git a/packages/api/src/types/RegistrationData.ts b/packages/api/src/types/RegistrationData.ts index f6d8168f..033f17c7 100644 --- a/packages/api/src/types/RegistrationData.ts +++ b/packages/api/src/types/RegistrationData.ts @@ -13,33 +13,32 @@ export type PostRegistrationRequest = { eventId: string; groupId: string | null; status: RegistrationStatus; - registrationData: { - registrationFieldId: string; - value: string; - }[]; + data: Record< + string, + { + value: string | number | boolean | string[] | null; + type: 'TEXT' | 'TEXTAREA' | 'SELECT' | 'CHECKBOX' | 'MULTI_SELECT' | 'NUMBER'; + fieldId: string; + } + >; }; export type PutRegistrationRequest = { eventId: string; groupId: string | null; status: RegistrationStatus; - registrationData: { - registrationFieldId: string; - value: string; - }[]; + data: Record< + string, + { + value: string | number | boolean | string[] | null; + type: 'TEXT' | 'TEXTAREA' | 'SELECT' | 'CHECKBOX' | 'MULTI_SELECT' | 'NUMBER'; + fieldId: string; + } + >; }; export type PostRegistrationResponse = { - registrationData: - | { - id: string; - createdAt: string; - updatedAt: string; - registrationId: string; - registrationFieldId: string; - value: string; - }[] - | null; + data: unknown | null; id: string; createdAt: string; updatedAt: string; @@ -49,16 +48,7 @@ export type PostRegistrationResponse = { }; export type PutRegistrationResponse = { - registrationData: - | { - id: string; - createdAt: string; - updatedAt: string; - registrationId: string; - registrationFieldId: string; - value: string; - }[] - | null; + registrationData: unknown | null; id: string; createdAt: string; updatedAt: string; diff --git a/packages/api/src/types/Registrations.ts b/packages/api/src/types/Registrations.ts index 768e7645..c3495ba6 100644 --- a/packages/api/src/types/Registrations.ts +++ b/packages/api/src/types/Registrations.ts @@ -7,6 +7,7 @@ export type GetRegistrationResponseType = { groupId: string | null; eventId?: string | undefined; createdAt: string; + data: unknown; updatedAt: string; event?: { id: string; diff --git a/packages/berlin/package.json b/packages/berlin/package.json index 01779163..879af783 100644 --- a/packages/berlin/package.json +++ b/packages/berlin/package.json @@ -63,4 +63,4 @@ "engines": { "node": "^20" } -} \ No newline at end of file +} diff --git a/packages/berlin/src/_components/ui/command.tsx b/packages/berlin/src/_components/ui/command.tsx index 265ce7c6..f19ed548 100644 --- a/packages/berlin/src/_components/ui/command.tsx +++ b/packages/berlin/src/_components/ui/command.tsx @@ -15,7 +15,7 @@ const Command = React.forwardRef< { return ( - + {children} @@ -46,7 +46,7 @@ const CommandInput = React.forwardRef< (({ className, ...props }, ref) => ( )); @@ -113,7 +113,7 @@ const CommandItem = React.forwardRef< ) => { return ( ); diff --git a/packages/berlin/src/_components/ui/dialog.tsx b/packages/berlin/src/_components/ui/dialog.tsx index 828ac9de..44994afe 100644 --- a/packages/berlin/src/_components/ui/dialog.tsx +++ b/packages/berlin/src/_components/ui/dialog.tsx @@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef< {children} - + Close @@ -84,7 +84,7 @@ const DialogDescription = React.forwardRef< >(({ className, ...props }, ref) => ( )); diff --git a/packages/berlin/src/_components/ui/popover.tsx b/packages/berlin/src/_components/ui/popover.tsx index ebbaafc5..6783ddf7 100644 --- a/packages/berlin/src/_components/ui/popover.tsx +++ b/packages/berlin/src/_components/ui/popover.tsx @@ -21,7 +21,7 @@ const PopoverContent = React.forwardRef< align={align} sideOffset={sideOffset} className={cn( - 'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', + 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md outline-none', className, )} {...props} diff --git a/packages/berlin/src/_components/ui/separator.tsx b/packages/berlin/src/_components/ui/separator.tsx index 41062d43..97d71cfe 100644 --- a/packages/berlin/src/_components/ui/separator.tsx +++ b/packages/berlin/src/_components/ui/separator.tsx @@ -14,7 +14,7 @@ const Separator = React.forwardRef< decorative={decorative} orientation={orientation} className={cn( - 'shrink-0 bg-border', + 'bg-border shrink-0', orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]', className, )} diff --git a/packages/berlin/src/components/form/AccountForm.tsx b/packages/berlin/src/components/form/AccountForm.tsx index da9b6c24..2174fe68 100644 --- a/packages/berlin/src/components/form/AccountForm.tsx +++ b/packages/berlin/src/components/form/AccountForm.tsx @@ -15,7 +15,7 @@ import { Subtitle } from '../typography/Subtitle.styled'; import { FlexRowToColumn } from '../containers/FlexRowToColumn.styled'; import { FormTextInput } from '../form-input'; import { z } from 'zod'; -import { returnZodError } from '../../utils/validation'; +import { returnZodError } from '../../utils/zod-error-handler'; type InitialUser = { username: string; diff --git a/packages/berlin/src/components/header/Header.tsx b/packages/berlin/src/components/header/Header.tsx index 7acf0a19..85ac3353 100644 --- a/packages/berlin/src/components/header/Header.tsx +++ b/packages/berlin/src/components/header/Header.tsx @@ -143,8 +143,10 @@ const UserMenu = () => { - - + + + + ); diff --git a/packages/berlin/src/pages/Account.tsx b/packages/berlin/src/pages/Account.tsx index e9a2524a..782a522d 100644 --- a/packages/berlin/src/pages/Account.tsx +++ b/packages/berlin/src/pages/Account.tsx @@ -19,10 +19,13 @@ import { Body } from '../components/typography/Body.styled'; import { useQueries, useQuery } from '@tanstack/react-query'; import Link from '../components/link'; import { Underline } from '../components/typography/Underline.styled'; +import { useNavigate } from 'react-router-dom'; function Account() { - const { user, isLoading: userIsLoading } = useUser(); - const [tab, setTab] = useState<'view' | 'edit'>('view'); + const { user: initialUser, isLoading: userIsLoading } = useUser(); + const isFirstLogin = !initialUser?.username; + const [tab, setTab] = useState<'view' | 'edit'>(isFirstLogin ? 'edit' : 'view'); + const navigate = useNavigate(); if (userIsLoading) { return Loading...; @@ -31,20 +34,24 @@ function Account() { const tabs = { edit: ( { + if (isFirstLogin) { + navigate('/events'); + } + setTab('view'); }} /> ), - view: , + view: , }; return ( diff --git a/packages/berlin/src/pages/Register.tsx b/packages/berlin/src/pages/Register.tsx index d30c2229..6ecd7b7b 100644 --- a/packages/berlin/src/pages/Register.tsx +++ b/packages/berlin/src/pages/Register.tsx @@ -1,7 +1,6 @@ // React and third-party libraries -import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useEffect, useMemo, useState } from 'react'; -import ContentLoader from 'react-content-loader'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMemo, useState } from 'react'; import { UseFormReturn, useForm } from 'react-hook-form'; import toast from 'react-hot-toast'; import { useNavigate, useParams } from 'react-router-dom'; @@ -16,16 +15,11 @@ import { fetchEvent, fetchEventGroupCategories, fetchGroups, - fetchRegistrationData, - fetchRegistrationFields, fetchRegistrations, fetchUsersToGroups, postRegistration, postUsersToGroups, putRegistration, - type GetRegistrationDataResponse, - type GetRegistrationFieldsResponse, - type GetRegistrationResponseType, type GetUserResponse, } from 'api'; @@ -33,22 +27,21 @@ import { import useUser from '../hooks/useUser'; // Components +import { dataSchema, fieldsSchema } from '@/utils/form-validation'; +import { z } from 'zod'; import Button from '../components/button'; import { Carousel } from '../components/carousel'; import { FlexColumn } from '../components/containers/FlexColumn.styled'; import { Form } from '../components/containers/Form.styled'; +import { FormInput, FormSelectInput } from '../components/form-input'; import Select from '../components/select'; import Label from '../components/typography/Label'; import { Subtitle } from '../components/typography/Subtitle.styled'; import { SafeArea } from '../layout/Layout.styled'; -import { FormInput, FormSelectInput } from '../components/form-input'; function Register() { const { user, isLoading } = useUser(); const { eventId } = useParams(); - const [selectedRegistrationFormKey, setSelectedRegistrationFormKey] = useState< - string | undefined - >(); const { data: event } = useQuery({ queryKey: ['event', eventId], @@ -64,16 +57,6 @@ function Register() { enabled: !!eventId, }); - const { data: registrationFields } = useQuery({ - queryKey: ['event', eventId, 'registrations', 'fields'], - queryFn: () => - fetchRegistrationFields({ - eventId: eventId || '', - serverUrl: import.meta.env.VITE_SERVER_URL, - }), - enabled: !!eventId, - }); - const { data: usersToGroups } = useQuery({ queryKey: ['user', user?.id, 'users-to-groups'], queryFn: () => @@ -107,42 +90,6 @@ function Register() { return 0; }, [groupCategories, usersToGroups]); - const multipleRegistrationData = useQueries({ - queries: - registrations?.map((registration) => ({ - queryKey: ['registrations', registration.id, 'registration-data'], - queryFn: () => - fetchRegistrationData({ - registrationId: registration.id || '', - serverUrl: import.meta.env.VITE_SERVER_URL, - }), - enabled: !!registration.id, - })) ?? [], - combine: (results) => { - // return a map of registration id to { data, loading } - return results.reduce( - (acc, result, idx) => { - if (registrations && registrations[idx] && result.data) { - acc[registrations[idx].id || ''] = { - data: result.data, - loading: result.isLoading, - }; - } - return acc; - }, - {} as Record< - string, - { data: GetRegistrationDataResponse | null | undefined; loading: boolean } - >, - ); - }, - }); - - const onRegistrationFormCreate = (newRegistrationId: string) => { - // select the newly created registration form - setSelectedRegistrationFormKey(newRegistrationId); - }; - if (isLoading) { return Loading...; } @@ -155,12 +102,7 @@ function Register() { usersToGroups={usersToGroups} user={user} registrations={registrations} - selectedRegistrationFormKey={selectedRegistrationFormKey} - multipleRegistrationData={multipleRegistrationData} - registrationFields={registrationFields} event={event} - onRegistrationFormCreate={onRegistrationFormCreate} - setSelectedRegistrationFormKey={setSelectedRegistrationFormKey} /> ); @@ -171,30 +113,15 @@ const CarouselWrapper = ({ usersToGroups, user, registrations, - selectedRegistrationFormKey, - multipleRegistrationData, - registrationFields, event, - onRegistrationFormCreate, - setSelectedRegistrationFormKey, + defaultStep, }: { defaultStep: number; groupCategories: GetGroupCategoriesResponse | null | undefined; usersToGroups: GetUsersToGroupsResponse | null | undefined; user: GetUserResponse | null | undefined; registrations: GetRegistrationsResponseType | undefined | null; - selectedRegistrationFormKey: string | undefined; - multipleRegistrationData: Record< - string, - { - data: GetRegistrationDataResponse | null | undefined; - loading: boolean; - } - >; - registrationFields: GetRegistrationFieldsResponse | null | undefined; event: GetEventResponse | null | undefined; - onRegistrationFormCreate: (newRegistrationId: string) => void; - setSelectedRegistrationFormKey: (key: string) => void; }) => { const navigate = useNavigate(); @@ -212,20 +139,26 @@ const CarouselWrapper = ({ useMutation({ mutationFn: postRegistration, onSuccess: async (body) => { - if (body) { - toast.success('Registration saved successfully!'); - await queryClient.invalidateQueries({ - queryKey: ['event', body.eventId, 'registrations'], - }); - - // invalidate user registrations, this is for the 1 event use case - // where the authentication is because you are approved to the event - await queryClient.invalidateQueries({ - queryKey: [user?.id, 'registrations'], - }); - } else { + if (!body) { toast.error('Failed to save registration, please try again'); + return; + } + + if ('errors' in body) { + toast.error(body.errors[0]); + return; } + + toast.success('Registration saved successfully!'); + await queryClient.invalidateQueries({ + queryKey: ['event', body.eventId, 'registrations'], + }); + + // invalidate user registrations, this is for the 1 event use case + // where the authentication is because you are approved to the event + await queryClient.invalidateQueries({ + queryKey: [user?.id, 'registrations'], + }); }, onError: (error) => { console.error('Error saving registration:', error); @@ -263,7 +196,7 @@ const CarouselWrapper = ({ eventId: event?.id || '', groupId: null, status: 'DRAFT', - registrationData: [], + data: {}, }, serverUrl: import.meta.env.VITE_SERVER_URL, }); @@ -273,7 +206,7 @@ const CarouselWrapper = ({ eventId: event?.id || '', groupId: null, status: 'DRAFT', - registrationData: [], + data: {}, }, serverUrl: import.meta.env.VITE_SERVER_URL, }); @@ -282,6 +215,7 @@ const CarouselWrapper = ({ return ( { // query registration to check if it is approved const registrations = await queryClient.fetchQuery({ @@ -323,18 +257,19 @@ const CarouselWrapper = ({ ), }, { - isEnabled: (registrationFields?.length ?? 0) > 0, + isEnabled: + (fieldsSchema.safeParse(event?.fields).success + ? Object.values(fieldsSchema.parse(event?.fields)).length + : 0) > 0, render: ({ handleStepComplete }) => ( ), @@ -344,210 +279,6 @@ const CarouselWrapper = ({ ); }; -const sortRegistrationsByCreationDate = (registrations: GetRegistrationResponseType[]) => { - return [ - ...registrations.sort((a, b) => { - return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); - }), - ]; -}; - -const createRegistrationForms = ({ - registrations, - usersToGroups, -}: { - registrations: GetRegistrationsResponseType | undefined | null; - usersToGroups: GetUsersToGroupsResponse | undefined | null; -}) => { - const sortedRegistrationsByCreationDate = sortRegistrationsByCreationDate(registrations || []); - - const registrationForms: { - key: string | 'create'; - registration?: GetRegistrationResponseType; - group?: GetUsersToGroupsResponse[number]['group']; - mode: 'edit' | 'create'; - }[] = sortedRegistrationsByCreationDate.map((reg) => { - return { - key: reg.id || '', - registration: reg, - group: usersToGroups?.find((userToGroup) => userToGroup.group.id === reg.groupId)?.group, - mode: 'edit', - }; - }); - - registrationForms.push({ - key: 'create', - mode: 'create', - }); - - return registrationForms; -}; - -function SelectRegistrationDropdown({ - usersToGroups, - selectedRegistrationFormKey, - registrations, - onSelectedRegistrationFormKeyChange, - multipleRegistrationData, - registrationFields, -}: { - selectedRegistrationFormKey: string | undefined; - registrations: GetRegistrationsResponseType | undefined | null; - usersToGroups: GetUsersToGroupsResponse | undefined | null; - onSelectedRegistrationFormKeyChange: (key: string) => void; - multipleRegistrationData: Record< - string, - { - data: GetRegistrationDataResponse | null | undefined; - loading: boolean; - } - >; - registrationFields: GetRegistrationFieldsResponse | null | undefined; -}) { - useEffect(() => { - // select the first registration if it exists - // and no registration form is selected - if ( - registrations && - registrations.length && - registrations[0].id && - !selectedRegistrationFormKey - ) { - const firstRegistrationId = sortRegistrationsByCreationDate(registrations)[0].id; - - if (firstRegistrationId) { - onSelectedRegistrationFormKeyChange(firstRegistrationId); - } - } - }, [onSelectedRegistrationFormKeyChange, registrations, selectedRegistrationFormKey]); - - const showRegistrationsSelect = ( - registrations: GetRegistrationsResponseType | null | undefined, - ): boolean => { - // only show select when user has previously registered - return !!registrations && registrations.length > 0; - }; - - const getRegistrationTitle = ({ - multipleRegistrationData, - registration, - registrationFields, - }: { - multipleRegistrationData: Record< - string, - { - data: GetRegistrationDataResponse | null | undefined; - loading: boolean; - } - >; - registration: GetRegistrationResponseType | null | undefined; - registrationFields: GetRegistrationFieldsResponse | null | undefined; - }) => { - const firstField = filterRegistrationFields( - registrationFields, - registration?.groupId ? 'group' : 'user', - )?.sort((a, b) => (a.fieldDisplayRank ?? 0) - (b.fieldDisplayRank ?? 0))[0]; - - if (!firstField) { - return ''; - } - - const registrationData = multipleRegistrationData[registration?.id || '']?.data; - - if (!registrationData) { - return ''; - } - - return ( - registrationData.find((data) => data.registrationFieldId === firstField.id)?.value ?? - 'Untitled' - ); - }; - - const createOptionName = ({ - index, - mode, - groupName, - multipleRegistrationData, - registration, - registrationFields, - }: { - multipleRegistrationData: Record< - string, - { - data: GetRegistrationDataResponse | null | undefined; - loading: boolean; - } - >; - registration: GetRegistrationResponseType | null | undefined; - registrationFields: GetRegistrationFieldsResponse | null | undefined; - mode: 'edit' | 'create'; - index: number; - groupName?: string; - }) => { - if (mode === 'create') { - return 'Create a new proposal'; - } - - return `${index}. ${getRegistrationTitle({ - multipleRegistrationData, - registration, - registrationFields, - })} ${groupName ? `[${groupName}]` : ''}`; - }; - - return ( - showRegistrationsSelect(registrations) && ( - - -