Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions apps/shared/src/registrations/schemas.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import { z } from "zod";
import { RegistrationStatus } from "./constants.js";

export const RegistrationAnswerSchema = z.record(
z.string(),
z.string().or(z.array(z.string())).optional()
);
export const RegistrationAnswerSchema = z.record(z.string(), z.string());
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The schema change removes support for array values (z.array(z.string())) from RegistrationAnswerSchema, but the buildRegistrationSchema function in event-registration-form-dialog.tsx doesn't handle multi-select fields. If multi-select fields were previously supported or are planned, this breaking change could cause data loss or errors. If array values are no longer needed, ensure this is intentional and that no existing registrations use array values.

Suggested change
export const RegistrationAnswerSchema = z.record(z.string(), z.string());
export const RegistrationAnswerSchema = z.record(
z.string(),
z.union([z.string(), z.array(z.string())]),
);

Copilot uses AI. Check for mistakes.

export const RegistrationContractSchema = z.object({
answers: RegistrationAnswerSchema.optional().default({}),
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"@clerk/tanstack-react-start": "^0.27.8",
"@clerk/themes": "^2.4.46",
"@events.comp-soc.com/shared": "workspace:*",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
Expand Down
19 changes: 3 additions & 16 deletions apps/web/src/components/event-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,14 @@ import { Link } from '@tanstack/react-router'
import { ArrowUpRight, CalendarIcon, MapPin } from 'lucide-react'
import type { Event } from '@events.comp-soc.com/shared'
import { SigBadge } from '@/components/sigs-badge.tsx'
import { formatEventDate } from '@/lib/utils.ts'

interface EventCardProps {
event: Event
}

function EventCard({ event }: EventCardProps) {
const dateObj = new Date(event.date)

const formattedDate = dateObj.toLocaleDateString('en-GB', {
weekday: 'short',
day: 'numeric',
month: 'short',
})

const formattedTime = dateObj.toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
const { full: date } = formatEventDate(event.date)

return (
<Link
Expand All @@ -44,9 +33,7 @@ function EventCard({ event }: EventCardProps) {
<div className="flex flex-col gap-1 sm:gap-1.5 mt-2 sm:mt-3 text-xs sm:text-sm text-neutral-400">
<div className="flex items-center gap-1.5 sm:gap-2">
<CalendarIcon className="w-3 h-3 sm:w-3.5 sm:h-3.5 shrink-0" />
<span className="truncate">
{formattedDate} - {formattedTime}
</span>
<span className="truncate">{date}</span>
</div>
<div className="flex items-center gap-1.5 sm:gap-2">
<MapPin className="w-3 h-3 sm:w-3.5 sm:h-3.5 shrink-0" />
Expand Down
278 changes: 278 additions & 0 deletions apps/web/src/components/forms/event-registration-form-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
import { useForm } from '@tanstack/react-form'

import * as z from 'zod'

import { RegistrationAnswerSchema } from '@events.comp-soc.com/shared'
import type {
CustomField,
RegistrationFormAnswer,
} from '@events.comp-soc.com/shared'

import {
Field,
FieldError,
FieldGroup,
FieldLabel,
} from '@/components/ui/field.tsx'
import { Input } from '@/components/ui/input.tsx'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select.tsx'
import { Textarea } from '@/components/ui/textarea.tsx'
import { Button } from '@/components/ui/button.tsx'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog.tsx'

function buildRegistrationSchema(formStructure: Array<CustomField>) {
const schemaShape: Record<string, z.ZodTypeAny> = {}

formStructure.forEach((field) => {
let fieldSchema: z.ZodTypeAny

switch (field.type) {
case 'input':
case 'textarea':
fieldSchema = field.required
? z.string().min(1, `${field.label} is required`)
: z.string().optional()
break
case 'select':
if (field.options && field.options.length > 0) {
fieldSchema = field.required
? z.enum(field.options as [string, ...Array<string>], {
error: () => ({ message: `${field.label} is required` }),
})
: z.enum(field.options as [string, ...Array<string>]).optional()
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a select field is optional and no value is selected, the form will submit an empty string '' as the value. However, z.enum().optional() expects either undefined or one of the enum values, not an empty string. This will cause validation errors when submitting optional select fields without a selection. Consider using .or(z.literal('')) or filtering out empty strings before validation.

Suggested change
: z.enum(field.options as [string, ...Array<string>]).optional()
: z
.enum(field.options as [string, ...Array<string>])
.or(z.literal(''))
.optional()

Copilot uses AI. Check for mistakes.
} else {
fieldSchema = z.string().optional()
}
break
default:
fieldSchema = z.string().optional()
}

schemaShape[field.id] = fieldSchema
})

return z.object(schemaShape)
}

function buildDefaultValues(formStructure: Array<CustomField>) {
const defaultValues: Record<string, string> = {}

formStructure.forEach((field) => {
defaultValues[field.id] = ''
})

return defaultValues
}

function EventRegistrationFormDialog({
onFormSubmit,
formStructure,
isLoading = false,
isOpen,
onOpenChange,
eventTitle,
}: {
onFormSubmit: (value: RegistrationFormAnswer) => void
formStructure: Array<CustomField>
isLoading?: boolean
isOpen: boolean
onOpenChange: () => void
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The onOpenChange prop type is defined as () => void, but the Dialog component from Radix UI expects (open: boolean) => void. This type mismatch means the component won't receive the proper boolean value indicating whether the dialog should be open or closed. Update the type to onOpenChange: (open: boolean) => void to match the Dialog API.

Suggested change
onOpenChange: () => void
onOpenChange: (open: boolean) => void

Copilot uses AI. Check for mistakes.
eventTitle: string
}) {
const RegistrationSchema = buildRegistrationSchema(formStructure)
const defaultValues: z.infer<typeof RegistrationSchema> =
buildDefaultValues(formStructure)

const form = useForm({
defaultValues: defaultValues,
validators: {
onSubmit: RegistrationSchema,
},
onSubmit: ({ value }) => {
const mappedData = RegistrationAnswerSchema.parse(value)
onFormSubmit(mappedData)
},
})

const renderField = (field: CustomField) => {
switch (field.type) {
case 'input':
return (
<form.Field
key={field.id}
name={field.id}
children={(formField) => {
const isInvalid =
formField.state.meta.isTouched && !formField.state.meta.isValid
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={formField.name}>
{field.label}
{field.required && (
<span className="text-destructive">*</span>
)}
</FieldLabel>
<Input
id={formField.name}
name={formField.name}
disabled={isLoading}
value={(formField.state.value as string) || ''}
onBlur={formField.handleBlur}
onChange={(e) => formField.handleChange(e.target.value)}
aria-invalid={isInvalid}
placeholder={`Enter ${field.label.toLowerCase()}`}
autoComplete="off"
/>
{isInvalid && (
<FieldError errors={formField.state.meta.errors} />
)}
</Field>
)
}}
/>
)

case 'textarea':
return (
<form.Field
key={field.id}
name={field.id}
children={(formField) => {
const isInvalid =
formField.state.meta.isTouched && !formField.state.meta.isValid
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={formField.name}>
{field.label}
{field.required && (
<span className="text-destructive">*</span>
)}
</FieldLabel>
<Textarea
id={formField.name}
name={formField.name}
disabled={isLoading}
value={(formField.state.value as string) || ''}
onBlur={formField.handleBlur}
onChange={(e) => formField.handleChange(e.target.value)}
aria-invalid={isInvalid}
placeholder={`Enter ${field.label.toLowerCase()}`}
rows={4}
/>
{isInvalid && (
<FieldError errors={formField.state.meta.errors} />
)}
</Field>
)
}}
/>
)

case 'select':
return (
<form.Field
key={field.id}
name={field.id}
children={(formField) => {
const isInvalid =
formField.state.meta.isTouched && !formField.state.meta.isValid
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={formField.name}>
{field.label}
{field.required && (
<span className="text-destructive">*</span>
)}
</FieldLabel>
<Select
name={formField.name}
disabled={isLoading}
value={(formField.state.value as string) || ''}
onValueChange={(value) => formField.handleChange(value)}
>
<SelectTrigger aria-invalid={isInvalid}>
<SelectValue
placeholder={`Select ${field.label.toLowerCase()}`}
/>
</SelectTrigger>
<SelectContent>
{field.options?.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
{isInvalid && (
<FieldError errors={formField.state.meta.errors} />
)}
</Field>
)
}}
/>
)

default:
return null
}
}

return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<form
id="registration-form"
onSubmit={(e) => {
e.preventDefault()
void form.handleSubmit()
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Register for {eventTitle}</DialogTitle>
</DialogHeader>
<DialogDescription>
Please fill out the additional details below to secure your spot.
</DialogDescription>
<div className="max-h-[65vh] overflow-auto py-5">
<FieldGroup>
{formStructure.length === 0 ? (
<div className="text-center py-8">
<p className="text-sm text-muted-foreground">
No registration fields configured for this event.
</p>
</div>
) : (
<>{formStructure.map((field) => renderField(field))}</>
)}
</FieldGroup>
</div>
<DialogFooter className="justify-start!">
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a typo in the CSS class name: "justify-start!" should be "!justify-start" following Tailwind's important modifier syntax. The exclamation mark should precede the utility class name, not follow it.

Suggested change
<DialogFooter className="justify-start!">
<DialogFooter className="!justify-start">

Copilot uses AI. Check for mistakes.
<Button
type="submit"
form="registration-form"
className="w-full md:max-w-fit"
disabled={isLoading}
>
{isLoading ? 'Submitting...' : 'Submit Registration'}
</Button>
</DialogFooter>
</DialogContent>
</form>
Comment on lines +235 to +273
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The form element is placed outside the DialogContent, which may cause issues with form submission and keyboard navigation. In Radix UI Dialog, portal-based content can have accessibility and focus management issues when form elements are structured this way. Consider placing the form element inside the DialogContent or wrapping just the DialogContent with the form to ensure proper behavior.

Copilot uses AI. Check for mistakes.
</Dialog>
)
}

export default EventRegistrationFormDialog
Loading