diff --git a/package.json b/package.json index fede6c6a..7255ccf8 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,9 @@ "berlin:dev": "pnpm -r --filter berlin dev", "berlin:build": "pnpm -r --filter berlin build", "berlin:preview": "pnpm -r --filter berlin preview", - "format": "prettier --check \"packages/**/*.{ts,tsx,json,md}\"", - "format:fix": "prettier --write \"packages/**/*.{ts,tsx,json,md}\"", - "lint": "eslint ." + "format": "pnpm exec prettier --check \"packages/**/*.{ts,tsx,json,md}\"", + "format:fix": "pnpm exec prettier --write \"packages/**/*.{ts,tsx,json,md}\"", + "lint": "pnpm exec eslint ." }, "keywords": [], "author": "", @@ -59,4 +59,4 @@ "vite": "^5.0.0", "vite-plugin-node-polyfills": "^0.17.0" } -} +} \ No newline at end of file diff --git a/packages/berlin/package.json b/packages/berlin/package.json index 98f43ed6..57c77548 100644 --- a/packages/berlin/package.json +++ b/packages/berlin/package.json @@ -6,8 +6,9 @@ "scripts": { "dev": "vite", "build": "vite build", - "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", - "format": "npx prettier . --write", + "lint": "pnpm exec eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "format": "pnpm exec prettier --check \"src/**/*.{ts,md}\"", + "format:fix": "pnpm exec prettier --write \"src/**/*.{ts,md}\"", "preview": "vite preview" }, "dependencies": { diff --git a/packages/berlin/src/_components/ui/badge.tsx b/packages/berlin/src/_components/ui/badge.tsx new file mode 100644 index 00000000..e736a2ca --- /dev/null +++ b/packages/berlin/src/_components/ui/badge.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/lib/utils'; + +const badgeVariants = cva( + 'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + { + variants: { + variant: { + default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80', + secondary: + 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', + destructive: + 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80', + outline: 'text-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return
; +} + +export { Badge, badgeVariants }; diff --git a/packages/berlin/src/_components/ui/button.tsx b/packages/berlin/src/_components/ui/button.tsx new file mode 100644 index 00000000..8914492f --- /dev/null +++ b/packages/berlin/src/_components/ui/button.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; +import { Slot } from '@radix-ui/react-slot'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/lib/utils'; + +const buttonVariants = cva( + 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90', + destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', + outline: + 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', + secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-9 px-4 py-2', + sm: 'h-8 rounded-md px-3 text-xs', + lg: 'h-10 rounded-md px-8', + icon: 'h-9 w-9', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button'; + return ( + + ); + }, +); +Button.displayName = 'Button'; + +export { Button, buttonVariants }; diff --git a/packages/berlin/src/_components/ui/command.tsx b/packages/berlin/src/_components/ui/command.tsx new file mode 100644 index 00000000..265ce7c6 --- /dev/null +++ b/packages/berlin/src/_components/ui/command.tsx @@ -0,0 +1,145 @@ +'use client'; + +import * as React from 'react'; +import { type DialogProps } from '@radix-ui/react-dialog'; +import { MagnifyingGlassIcon } from '@radix-ui/react-icons'; +import { Command as CommandPrimitive } from 'cmdk'; + +import { cn } from '@/lib/utils'; +import { Dialog, DialogContent } from '@/_components/ui/dialog'; + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Command.displayName = CommandPrimitive.displayName; + +interface CommandDialogProps extends DialogProps {} + +const CommandDialog = ({ children, ...props }: CommandDialogProps) => { + return ( + + + + {children} + + + + ); +}; + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)); + +CommandInput.displayName = CommandPrimitive.Input.displayName; + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandList.displayName = CommandPrimitive.List.displayName; + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)); + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName; + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandGroup.displayName = CommandPrimitive.Group.displayName; + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandSeparator.displayName = CommandPrimitive.Separator.displayName; + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandItem.displayName = CommandPrimitive.Item.displayName; + +const CommandShortcut = ({ className, ...props }: React.HTMLAttributes) => { + return ( + + ); +}; +CommandShortcut.displayName = 'CommandShortcut'; + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +}; diff --git a/packages/berlin/src/_components/ui/dialog.tsx b/packages/berlin/src/_components/ui/dialog.tsx new file mode 100644 index 00000000..828ac9de --- /dev/null +++ b/packages/berlin/src/_components/ui/dialog.tsx @@ -0,0 +1,104 @@ +'use client'; + +import * as React from 'react'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { Cross2Icon } from '@radix-ui/react-icons'; + +import { cn } from '@/lib/utils'; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = 'DialogHeader'; + +const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = 'DialogFooter'; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/packages/berlin/src/_components/ui/popover.tsx b/packages/berlin/src/_components/ui/popover.tsx new file mode 100644 index 00000000..ebbaafc5 --- /dev/null +++ b/packages/berlin/src/_components/ui/popover.tsx @@ -0,0 +1,33 @@ +'use client'; + +import * as React from 'react'; +import * as PopoverPrimitive from '@radix-ui/react-popover'; + +import { cn } from '@/lib/utils'; + +const Popover = PopoverPrimitive.Root; + +const PopoverTrigger = PopoverPrimitive.Trigger; + +const PopoverAnchor = PopoverPrimitive.Anchor; + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( + + + +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; diff --git a/packages/berlin/src/_components/ui/separator.tsx b/packages/berlin/src/_components/ui/separator.tsx new file mode 100644 index 00000000..41062d43 --- /dev/null +++ b/packages/berlin/src/_components/ui/separator.tsx @@ -0,0 +1,26 @@ +'use client'; + +import * as React from 'react'; +import * as SeparatorPrimitive from '@radix-ui/react-separator'; + +import { cn } from '@/lib/utils'; + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => ( + +)); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/packages/berlin/src/components/carousel/Carousel.tsx b/packages/berlin/src/components/carousel/Carousel.tsx new file mode 100644 index 00000000..47f6f4ac --- /dev/null +++ b/packages/berlin/src/components/carousel/Carousel.tsx @@ -0,0 +1,90 @@ +import React, { useState } from 'react'; +import { FlexColumn } from '../containers/FlexColumn.styled'; +import Dots from '../dots'; + +type CarouselStepProps = { + handleStepComplete: () => Promise; + goToPreviousStep: () => void; + isFirstStep: boolean; + isLastStep: boolean; +}; + +type Step = { + render: (props: CarouselStepProps) => React.ReactNode; + isEnabled: boolean; +}; + +type CarouselProps = { + steps: Step[]; + initialStep?: number; + onComplete: () => Promise; +}; + +export function Carousel({ steps, initialStep = 0, onComplete }: CarouselProps) { + const [currentStepIndex, setCurrentStepIndex] = useState(initialStep); + const [isCompletingStep, setIsCompletingStep] = useState(false); + + const enabledSteps = steps.filter((step) => step.isEnabled); + + const goToNextStep = () => { + setCurrentStepIndex((prevIndex) => { + let nextIndex = prevIndex + 1; + while (nextIndex < steps.length && !steps[nextIndex].isEnabled) { + nextIndex++; + } + return nextIndex < steps.length ? nextIndex : prevIndex; + }); + }; + + const goToPreviousStep = () => { + setCurrentStepIndex((prevIndex) => { + let previousIndex = prevIndex - 1; + while (previousIndex >= 0 && !steps[previousIndex].isEnabled) { + previousIndex--; + } + return previousIndex >= 0 ? previousIndex : prevIndex; + }); + }; + + const goToStep = (index: number) => { + if (index >= 0 && index < steps.length && steps[index].isEnabled) { + setCurrentStepIndex(index); + } + }; + + const handleStepComplete = async () => { + if (!isCompletingStep) { + // Check if not already running + setIsCompletingStep(true); // Mark as running + try { + if (currentStepIndex === enabledSteps.length - 1) { + await onComplete(); + } else { + goToNextStep(); + } + } finally { + setIsCompletingStep(false); // Reset after completion + } + } + }; + + const currentStep = steps[currentStepIndex]; + const isFirstStep = currentStepIndex === 0; + const isLastStep = currentStepIndex === enabledSteps.length - 1; + + return ( + + {currentStep.render({ + handleStepComplete, + goToPreviousStep, + isFirstStep, + isLastStep, + })} + step.isEnabled).length} + activeDotIndex={currentStepIndex} + handleClick={goToStep} + /> + + ); +} diff --git a/packages/berlin/src/components/carousel/index.ts b/packages/berlin/src/components/carousel/index.ts new file mode 100644 index 00000000..c0ab1996 --- /dev/null +++ b/packages/berlin/src/components/carousel/index.ts @@ -0,0 +1 @@ +export * from './Carousel'; diff --git a/packages/berlin/src/components/multi-select/MultiSelect.tsx b/packages/berlin/src/components/multi-select/MultiSelect.tsx new file mode 100644 index 00000000..9be62f97 --- /dev/null +++ b/packages/berlin/src/components/multi-select/MultiSelect.tsx @@ -0,0 +1,278 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { CheckIcon, XCircle, ChevronDown, XIcon, WandSparkles } from 'lucide-react'; + +import { cn } from '@/lib/utils'; +import { Separator } from '@/_components/ui/separator'; +import { Button } from '@/_components/ui/button'; +import { Badge } from '@/_components/ui/badge'; +import { Popover, PopoverContent, PopoverTrigger } from '@/_components/ui/popover'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/_components/ui/command'; + +const multiSelectVariants = cva('m-1', { + variants: { + variant: { + default: + 'bg-[var(--color-white)] border border-solid border-[var(--color-black)] py-1 px-3 text-sm', + secondary: + 'border-foreground/10 bg-secondary text-secondary-foreground hover:bg-secondary/80', + destructive: + 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', + inverted: 'inverted', + }, + }, + defaultVariants: { + variant: 'default', + }, +}); + +interface MultiSelectProps + extends React.ButtonHTMLAttributes, + VariantProps { + options: { + label: string; + value: string; + icon?: React.ComponentType<{ className?: string }>; + }[]; + onValueChange: (value: string[]) => void; + defaultValue: string[]; + placeholder?: string; + animation?: number; + maxCount?: number; + asChild?: boolean; + className?: string; +} + +export const MultiSelect = React.forwardRef( + ( + { + options, + onValueChange, + variant, + defaultValue = [], + placeholder = 'Select options', + animation = 0, + maxCount = 3, + className, + ...props + }, + ref, + ) => { + const [isPopoverOpen, setIsPopoverOpen] = React.useState(false); + const [isAnimating, setIsAnimating] = React.useState(false); + + const handleInputKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + setIsPopoverOpen(true); + } else if (event.key === 'Backspace' && !event.currentTarget.value) { + const newSelectedValues = [...defaultValue]; + newSelectedValues.pop(); + onValueChange(newSelectedValues); + } + }; + + const toggleOption = (value: string) => { + const newSelectedValues = defaultValue.includes(value) + ? defaultValue.filter((v) => v !== value) + : [...defaultValue, value]; + onValueChange(newSelectedValues); + }; + + const handleClear = () => { + onValueChange([]); + }; + + const handleTogglePopover = () => { + setIsPopoverOpen((prev) => !prev); + }; + + const clearExtraOptions = () => { + const newSelectedValues = defaultValue.slice(0, maxCount); + onValueChange(newSelectedValues); + }; + + const toggleAll = () => { + if (defaultValue.length === options.length) { + handleClear(); + } else { + const allValues = options.map((option) => option.value); + onValueChange(allValues); + } + }; + + return ( + + + + + setIsPopoverOpen(false)} + > + + + + + No results found. + + + +
+ +
+ (Select All) +
+ {options.map((option) => { + const isSelected = defaultValue.includes(option.value); + return ( + toggleOption(option.value)} + style={{ pointerEvents: 'auto', opacity: 1 }} + className="cursor-pointer rounded-none p-3 text-[var(--color-black)] data-[selected=true]:bg-[var(--color-gray)] data-[selected=true]:text-[var(--color-black)]" + > +
+ +
+ {option.icon && ( + + )} + {option.label} +
+ ); + })} +
+
+
+
+ {animation > 0 && defaultValue.length > 0 && ( + setIsAnimating(!isAnimating)} + /> + )} +
+ ); + }, +); + +MultiSelect.displayName = 'MultiSelect'; diff --git a/packages/berlin/src/components/multi-select/index.ts b/packages/berlin/src/components/multi-select/index.ts new file mode 100644 index 00000000..f1f7f3d3 --- /dev/null +++ b/packages/berlin/src/components/multi-select/index.ts @@ -0,0 +1 @@ +export * from './MultiSelect'; diff --git a/packages/berlin/src/pages/Register.tsx b/packages/berlin/src/pages/Register.tsx index d9b39ded..56cfbc01 100644 --- a/packages/berlin/src/pages/Register.tsx +++ b/packages/berlin/src/pages/Register.tsx @@ -2,7 +2,7 @@ import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useMemo, useState } from 'react'; import ContentLoader from 'react-content-loader'; -import { useForm } from 'react-hook-form'; +import { UseFormReturn, useForm } from 'react-hook-form'; import toast from 'react-hot-toast'; import { useNavigate, useParams } from 'react-router-dom'; @@ -12,6 +12,7 @@ import { GetGroupCategoriesResponse, GetRegistrationsResponseType, GetUsersToGroupsResponse, + deleteUsersToGroups, fetchEvent, fetchEventGroupCategories, fetchGroups, @@ -22,7 +23,6 @@ import { postRegistration, postUsersToGroups, putRegistration, - putUsersToGroups, type GetRegistrationDataResponse, type GetRegistrationFieldsResponse, type GetRegistrationResponseType, @@ -33,19 +33,17 @@ import { import useUser from '../hooks/useUser'; // Components +import Button from '../components/button'; import { FlexColumn } from '../components/containers/FlexColumn.styled'; -import { FlexRow } from '../components/containers/FlexRow.styled'; import { Form } from '../components/containers/Form.styled'; -import { FormInput } from '../components/form'; -import { SafeArea } from '../layout/Layout.styled'; -import { Subtitle } from '../components/typography/Subtitle.styled'; -import Button from '../components/button'; -import Dots from '../components/dots'; -import Label from '../components/typography/Label'; +import { FormInput, SelectInput } from '../components/form'; 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 { Carousel } from '../components/carousel'; function Register() { - const [step, setStep] = useState(null); const { user, isLoading } = useUser(); const { eventId } = useParams(); const [selectedRegistrationFormKey, setSelectedRegistrationFormKey] = useState< @@ -125,21 +123,6 @@ function Register() { }, }); - const showRegistrationForm = ({ - registrationId, - selectedRegistrationFormKey, - }: { - registrationId?: string; - selectedRegistrationFormKey?: string; - }) => { - if (selectedRegistrationFormKey === 'create' && !registrationId) { - // show create registration form - return true; - } - - return registrationId === selectedRegistrationFormKey; - }; - const onRegistrationFormCreate = (newRegistrationId: string) => { // select the newly created registration form setSelectedRegistrationFormKey(newRegistrationId); @@ -149,78 +132,197 @@ function Register() { return Loading...; } - const getRecentStep = (step: number | null, defaultStep: number) => { - if (step === null) { - return defaultStep; + return ( + + + + ); +} + +const CarouselWrapper = ({ + groupCategories, + usersToGroups, + user, + registrations, + selectedRegistrationFormKey, + multipleRegistrationData, + registrationFields, + event, + onRegistrationFormCreate, + setSelectedRegistrationFormKey, +}: { + 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(); + + const queryClient = useQueryClient(); + + const redirectToNextPage = (isApproved: boolean, eventId: string) => { + if (!isApproved) { + navigate(`/events/${eventId}/holding`); } - return step; + navigate(`/events/${eventId}/cycles`); + }; + + const { mutateAsync: postRegistrationMutation, isPending: postRegistrationIsPending } = + 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 { + toast.error('Failed to save registration, please try again'); + } + }, + onError: (error) => { + console.error('Error saving registration:', error); + toast.error('Failed to save registration, please try again'); + }, + }); + + const { mutateAsync: updateRegistrationMutation, isPending: updateRegistrationIsPending } = + useMutation({ + mutationFn: putRegistration, + onSuccess: async (body) => { + if (body) { + toast.success('Registration updated successfully!'); + + await queryClient.invalidateQueries({ + queryKey: ['event', event?.id, 'registrations'], + }); + } else { + toast.error('Failed to update registration, please try again'); + } + }, + onError: (error) => { + console.error('Error updating registration:', error); + toast.error('Failed to update registration, please try again'); + }, + }); + + const handleSubmit = async () => { + const foundRegistration = registrations?.at(0); + + if (foundRegistration) { + await updateRegistrationMutation({ + registrationId: foundRegistration.id || '', + body: { + eventId: event?.id || '', + groupId: null, + status: 'DRAFT', + registrationData: [], + }, + }); + } else { + await postRegistrationMutation({ + body: { + eventId: event?.id || '', + groupId: null, + status: 'DRAFT', + registrationData: [], + }, + }); + } }; return ( - - - {getRecentStep(step, defaultStep) === 0 && - groupCategories - ?.filter((groupCategory) => groupCategory.required) - .map((groupCategory) => ( - setStep(1)} - /> - ))} - {getRecentStep(step, defaultStep) === 1 && ( - <> - { + // query registration to check if it is approved + const registrations = await queryClient.fetchQuery({ + queryKey: ['event', event?.id, 'registrations'], + queryFn: () => fetchRegistrations(event?.id || ''), + }); + + redirectToNextPage( + registrations?.some((reg) => reg.status === 'APPROVED') ?? false, + event?.id || '', + ); + }} + steps={[ + { + isEnabled: (groupCategories?.filter((category) => category.required).length ?? 0) > 0, + render: ({ isLastStep, handleStepComplete }) => ( + { + if (updateRegistrationIsPending || postRegistrationIsPending) { + return; + } + + if (isLastStep) { + await handleSubmit(); + } + + await handleStepComplete(); + }} + /> + ), + }, + { + isEnabled: (registrationFields?.length ?? 0) > 0, + render: ({ handleStepComplete }) => ( + - {createRegistrationForms({ registrations, usersToGroups }).map((form, idx) => { - return ( - - ); - })} - - )} - { - // the user is not allowed to go out of the first step - if (defaultStep == 0) { - return; - } - - setStep(i); - }} - /> - - + ), + }, + ]} + /> ); -} +}; + const sortRegistrationsByCreationDate = (registrations: GetRegistrationResponseType[]) => { return [ ...registrations.sort((a, b) => { @@ -260,104 +362,7 @@ const createRegistrationForms = ({ return registrationForms; }; -const SelectEventGroup = ({ - groupCategory, - userToGroups, - user, - afterSubmit, -}: { - userToGroups: GetUsersToGroupsResponse | null | undefined; - groupCategory: GetGroupCategoriesResponse[number] | null | undefined; - user: GetUserResponse | null | undefined; - afterSubmit?: () => void; -}) => { - // fetch all the groups in the category - // show a select with all the groups - // if the user is in a group in that category, show that group as selected - const [newGroup, setNewGroup] = useState(''); - const queryClient = useQueryClient(); - const { data: groups } = useQuery({ - queryKey: ['group-category', groupCategory?.id, 'groups'], - queryFn: () => fetchGroups({ groupCategoryId: groupCategory?.id ?? '' }), - enabled: !!groupCategory?.id, - }); - - const { mutate: postUsersToGroupsMutation } = useMutation({ - mutationFn: postUsersToGroups, - onSuccess: (body) => { - if (!body) { - return; - } - queryClient.invalidateQueries({ queryKey: ['user', user?.id, 'users-to-groups'] }); - toast.success(`Joined group successfully!`); - afterSubmit?.(); - }, - onError: () => { - toast.error('Something went wrong.'); - }, - }); - - const { mutate: putUsersToGroupsMutation } = useMutation({ - mutationFn: putUsersToGroups, - onSuccess: (body) => { - if (!body) { - return; - } - - if ('errors' in body) { - toast.error(body.errors.join(', ')); - return; - } - - queryClient.invalidateQueries({ queryKey: ['user', user?.id, 'users-to-groups'] }); - toast.success(`Updated group successfully!`); - afterSubmit?.(); - }, - onError: () => { - toast.error('Something went wrong.'); - }, - }); - - const userGroup = userToGroups?.find( - (userToGroup) => userToGroup.group.groupCategory?.id === groupCategory?.id, - ); - - const onSubmit = () => { - if (userGroup && newGroup) { - // update the group - putUsersToGroupsMutation({ - groupId: newGroup, - userToGroupId: userGroup.id, - }); - return; - } - - // create a new group - postUsersToGroupsMutation({ groupId: newGroup }); - }; - - return ( - - - ({ @@ -493,7 +498,7 @@ const SelectRegistrationDropdown = ({ ) ); -}; +} const getDefaultValues = (registrationData: GetRegistrationDataResponse | null | undefined) => { return registrationData?.reduce( @@ -522,7 +527,227 @@ const filterRegistrationFields = ( }); }; -function RegisterForm(props: { +function EventGroupsForm({ + groupCategories, + usersToGroups, + user, + onStepComplete, +}: { + groupCategories: GetGroupCategoriesResponse | null | undefined; + usersToGroups: GetUsersToGroupsResponse | null | undefined; + user: GetUserResponse | null | undefined; + onStepComplete?: () => Promise; +}) { + const queryClient = useQueryClient(); + const form = useForm({ + mode: 'all', + // user to groups keyed by group category id + defaultValues: usersToGroups?.reduce( + (acc, userToGroup) => { + if (!userToGroup.group.groupCategoryId) { + return acc; + } + + if (!acc[userToGroup.groupCategoryId ?? '']) { + acc[userToGroup.group.groupCategoryId] = []; + } + + acc[userToGroup.group.groupCategoryId].push(userToGroup.group.id); + return acc; + }, + {} as Record, + ), + }); + + const { mutateAsync: postUsersToGroupsMutation, isPending: postUsersToGroupsIsLoading } = + useMutation({ + mutationFn: postUsersToGroups, + onSuccess: (body) => { + if (!body) { + return; + } + queryClient.invalidateQueries({ queryKey: ['user', user?.id, 'users-to-groups'] }); + }, + onError: () => { + toast.error('Something went wrong.'); + }, + }); + + const { mutateAsync: deleteUsersToGroupsMutation, isPending: deleteUsersToGroupsIsLoading } = + useMutation({ + mutationFn: deleteUsersToGroups, + onSuccess: (body) => { + if (body) { + if ('errors' in body) { + toast.error(body.errors[0]); + return; + } + + queryClient.invalidateQueries({ queryKey: ['user', user?.id, 'users-to-groups'] }); + } + }, + }); + + const onSubmit = async (values: Record) => { + const formGroupIds = Object.values(values).flat(); + const previousGroupIds = usersToGroups?.map((userToGroup) => userToGroup.group.id) || []; + // add groups that are new + // delete groups that are no longer selected + const groupsToAdd = formGroupIds.filter((groupId) => !previousGroupIds.includes(groupId)); + const groupsToDelete = previousGroupIds.filter((groupId) => !formGroupIds.includes(groupId)); + + try { + await Promise.all( + groupsToAdd.map((groupId) => + postUsersToGroupsMutation({ + groupId, + }), + ), + ); + + await Promise.all( + groupsToDelete.map(async (groupId) => { + const userToGroup = usersToGroups?.find( + (userToGroup) => userToGroup.group.id === groupId, + ); + if (userToGroup) { + await deleteUsersToGroupsMutation({ userToGroupId: userToGroup.id }); + } + }), + ); + + await onStepComplete?.(); + } catch (e) { + console.error('Error saving groups:', e); + } + }; + + return ( + + {groupCategories + ?.filter((groupCategory) => groupCategory.required) + .map((groupCategory) => ( + + ))} + + + ); +} + +function SelectEventGroup({ + groupCategory, + form, +}: { + groupCategory: GetGroupCategoriesResponse[number] | null | undefined; + form: UseFormReturn>; +}) { + const { data: groups } = useQuery({ + queryKey: ['group-category', groupCategory?.id, 'groups'], + queryFn: () => fetchGroups({ groupCategoryId: groupCategory?.id ?? '' }), + enabled: !!groupCategory?.id, + }); + + return ( + ({ value: group.id, name: group.name })) || []} + // group category id is the key for the form + // and the form supports multiple groups hence the array key + name={`${groupCategory?.id}.[0]`} + required + /> + ); +} + +function RegistrationForm({ + registrationFields, + event, + multipleRegistrationData, + registrations, + selectedRegistrationFormKey, + user, + usersToGroups, + onRegistrationFormCreate, + onSelectedRegistrationFormKeyChange, + onStepComplete, +}: { + registrations: GetRegistrationsResponseType | undefined | null; + usersToGroups: GetUsersToGroupsResponse | undefined | null; + selectedRegistrationFormKey: string | undefined; + multipleRegistrationData: Record< + string, + { + data: GetRegistrationDataResponse | null | undefined; + loading: boolean; + } + >; + registrationFields: GetRegistrationFieldsResponse | null | undefined; + user: GetUserResponse | null | undefined; + event: GetEventResponse | null | undefined; + onRegistrationFormCreate?: (newRegistrationId: string) => void; + onSelectedRegistrationFormKeyChange: (key: string) => void; + onStepComplete?: () => void; +}) { + const showRegistrationForm = ({ + registrationId, + selectedRegistrationFormKey, + }: { + registrationId?: string; + selectedRegistrationFormKey?: string; + }) => { + if (selectedRegistrationFormKey === 'create' && !registrationId) { + // show create registration form + return true; + } + + return registrationId === selectedRegistrationFormKey; + }; + + return ( + <> + + {createRegistrationForms({ registrations, usersToGroups }).map((form, idx) => { + return ( + + ); + })} + + ); +} + +function DynamicRegistrationFieldsForm(props: { user: GetUserResponse | null | undefined; usersToGroups: GetUsersToGroupsResponse | null | undefined; registrationFields: GetRegistrationFieldsResponse | null | undefined; @@ -534,8 +759,8 @@ function RegisterForm(props: { isLoading: boolean; onRegistrationFormCreate?: (newRegistrationId: string) => void; registrationData: GetRegistrationDataResponse | null | undefined; + onStepComplete?: () => void; }) { - const navigate = useNavigate(); const queryClient = useQueryClient(); // i want to differentiate between when a group is selected and it is not @@ -574,14 +799,6 @@ function RegisterForm(props: { return sortedFields; }, [props.registrationFields, selectedGroupId, prevSelectGroupId]); - const redirectToNextPage = (isApproved: boolean) => { - if (!isApproved) { - navigate(`/events/${props.event?.id}/holding`); - } - - navigate(`/events/${props.event?.id}/cycles`); - }; - const { mutate: mutateRegistrationData, isPending } = useMutation({ mutationFn: postRegistration, onSuccess: async (body) => { @@ -602,7 +819,7 @@ function RegisterForm(props: { props.onRegistrationFormCreate?.(body.id); - redirectToNextPage(body.status === 'APPROVED'); + props.onStepComplete?.(); } else { toast.error('Failed to save registration, please try again'); } @@ -626,7 +843,7 @@ function RegisterForm(props: { queryKey: ['registrations', props.registrationId, 'registration-data'], }); - redirectToNextPage(body.status === 'APPROVED'); + props.onStepComplete?.(); } else { toast.error('Failed to update registration, please try again'); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de659e56..2cccfb5e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -913,10 +913,12 @@ packages: '@humanwhocodes/config-array@0.11.13': resolution: {integrity: sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==} engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead '@humanwhocodes/config-array@0.11.14': resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead '@humanwhocodes/module-importer@1.0.1': resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} @@ -924,9 +926,11 @@ packages: '@humanwhocodes/object-schema@2.0.1': resolution: {integrity: sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==} + deprecated: Use @eslint/object-schema instead '@humanwhocodes/object-schema@2.0.3': resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} @@ -2599,6 +2603,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported globals@11.12.0: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} @@ -2707,6 +2712,7 @@ packages: inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -3466,6 +3472,7 @@ packages: rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true ripemd160@2.0.2: