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
2 changes: 1 addition & 1 deletion frontend-v2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@tanstack/react-form": "^0.41.0",
"@tanstack/react-form": "^1.27.7",
"@tanstack/react-query": "^5.66.0",
"@tanstack/react-query-devtools": "^5.66.0",
"@tanstack/react-table": "^8.20.5",
Expand Down
292 changes: 39 additions & 253 deletions frontend-v2/pnpm-lock.yaml

Large diffs are not rendered by default.

41 changes: 41 additions & 0 deletions frontend-v2/src/app/coaching/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { ContentWrapper } from '@/app/content-wrapper'
import { AuthedPageLayout } from '@/app/authed-page-layout'
import { EventForm } from '../../event/event-form'
import { Navbar } from '@/components/nav'
import { ApiClient, API_PATH } from '@/lib/api'
import {
QueryClient,
HydrationBoundary,
dehydrate,
} from '@tanstack/react-query'
import { getCookies } from '@/lib/auth'

type EditCoachingPageProps = {
params: Promise<{ id: string }>
}

export default async function EditCoachingPage({
params,
}: EditCoachingPageProps) {
const { id } = await params
const apiClient = new ApiClient(await getCookies())
const queryClient = new QueryClient()

// Prefetch event data during SSR
await queryClient.prefetchQuery({
queryKey: [API_PATH.EVENT_GET, id],
queryFn: () => apiClient.getEvent(Number(id)),
})

return (
<AuthedPageLayout pageName="EditConnection_beta">
<HydrationBoundary state={dehydrate(queryClient)}>
<Navbar />
<ContentWrapper size="md" className="gap-8">
<h1 className="text-3xl font-bold">Coaching</h1>
<EventForm mode="connection" />
</ContentWrapper>
</HydrationBoundary>
</AuthedPageLayout>
)
}
19 changes: 19 additions & 0 deletions frontend-v2/src/app/coaching/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { ContentWrapper } from '@/app/content-wrapper'
import { AuthedPageLayout } from '@/app/authed-page-layout'
import { EventForm } from '../event/event-form'
import { Navbar } from '@/components/nav'
import { Suspense } from 'react'

export default async function CoachingPage() {
return (
<AuthedPageLayout pageName="NewConnection_beta">
<Navbar />
<ContentWrapper size="md" className="gap-8">
<h1 className="text-3xl font-bold">Coaching</h1>
<Suspense fallback={<div>Loading form...</div>}>
<EventForm mode="connection" />
</Suspense>
</ContentWrapper>
</AuthedPageLayout>
)
}
2 changes: 1 addition & 1 deletion frontend-v2/src/app/content-wrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const ContentWrapper = (props: {
return (
<div
className={cn(
'bg-white w-full lg:rounded-md py-6 px-10 shadow-2xl backdrop-blur-md bg-opacity-95 lg:mt-6 lg:mx-auto flex flex-col',
'bg-white w-full lg:rounded-md py-6 px-4 md:px-10 shadow-2xl backdrop-blur-md bg-opacity-95 lg:mt-6 lg:mx-auto flex flex-col',
contentWrapperClass[props.size],
props.className,
)}
Expand Down
39 changes: 39 additions & 0 deletions frontend-v2/src/app/event/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { ContentWrapper } from '@/app/content-wrapper'
import { AuthedPageLayout } from '@/app/authed-page-layout'
import { EventForm } from '../event-form'
import { Navbar } from '@/components/nav'
import { ApiClient, API_PATH } from '@/lib/api'
import {
QueryClient,
HydrationBoundary,
dehydrate,
} from '@tanstack/react-query'
import { getCookies } from '@/lib/auth'

type EditEventPageProps = {
params: Promise<{ id: string }>
}

export default async function EditEventPage({ params }: EditEventPageProps) {
const { id } = await params
const apiClient = new ApiClient(await getCookies())
const queryClient = new QueryClient()

// Prefetch event data during SSR
await queryClient.prefetchQuery({
queryKey: [API_PATH.EVENT_GET, id],
queryFn: () => apiClient.getEvent(Number(id)),
})

return (
<AuthedPageLayout pageName="EditEvent_beta">
<HydrationBoundary state={dehydrate(queryClient)}>
<Navbar />
<ContentWrapper size="md" className="gap-8">
<h1 className="text-3xl font-bold">Attendance</h1>
<EventForm mode="event" />
</ContentWrapper>
</HydrationBoundary>
</AuthedPageLayout>
)
}
52 changes: 52 additions & 0 deletions frontend-v2/src/app/event/activist-registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
type ActivistRecord = {
name: string
email?: string
phone?: string
}

/**
* Encapsulates basic activist data and provides lookup methods.
*/
export class ActivistRegistry {
private activists: ActivistRecord[]
private activistsByName: Map<string, ActivistRecord>

constructor(activists: ActivistRecord[]) {
this.activists = activists
this.activistsByName = new Map(activists.map((a) => [a.name, a]))
}

getActivist(name: string): ActivistRecord | null {
return this.activistsByName.get(name) ?? null
}

getSuggestions(input: string, maxResults = 10): string[] {
const trimmedInput = input.trim()
if (!trimmedInput.length) {
return []
}

return this.activists
.filter(({ name }) => nameFilter(name, input))
.slice(0, maxResults)
.map((a) => a.name)
}
}

/**
* Filters text based on a flexible name matching pattern.
* Treats whitespace as a wildcard allowing any characters in between,
* enabling partial and out-of-order matching (e.g., "john doe" matches "John Q. Doe").
* Matching is case-insensitive.
*
* @param text - The text to search within
* @param input - The search pattern
* @returns true if the pattern matches the text
*/
function nameFilter(text: string, input: string): boolean {
const pattern = input
.trim()
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // escape special regex chars
.replace(/ +/g, '.*') // whitespace matches anything
return new RegExp(pattern, 'i').test(text)
}
177 changes: 177 additions & 0 deletions frontend-v2/src/app/event/attendee-input-field.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { KeyboardEvent, useState } from 'react'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { MailX, PhoneMissed, UserRoundPlus, Check } from 'lucide-react'
import { AnyFieldApi } from '@tanstack/react-form'
import { ActivistRegistry } from './activist-registry'

type AttendeeInputFieldProps = {
field: AnyFieldApi
index: number
isFocused: boolean
inputRef: (el: HTMLInputElement | null) => void
onFocus: (index: number) => void
onAdvanceFocus: () => void
onChange: () => void
registry: ActivistRegistry
checkForDuplicate: (value: string, index: number) => boolean
}

export const AttendeeInputField = ({
field,
index,
isFocused,
inputRef,
onFocus,
onAdvanceFocus,
onChange,
registry,
checkForDuplicate,
}: AttendeeInputFieldProps) => {
const [suggestions, setSuggestions] = useState<string[]>([])
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1)

const handleInputChange = (value: string) => {
field.handleChange(value)
setSuggestions(registry.getSuggestions(value))
setSelectedSuggestionIndex(-1)
onChange()
}

const handleSelectSuggestion = (value: string) => {
field.handleChange(value)
field.handleBlur()
field.validate('change')
setSuggestions([])
setSelectedSuggestionIndex(-1)
onAdvanceFocus()
}

const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case 'ArrowDown': {
e.preventDefault()
setSelectedSuggestionIndex((prev) =>
prev === suggestions.length - 1 ? 0 : prev + 1,
)
return
}
case 'ArrowUp': {
e.preventDefault()
setSelectedSuggestionIndex((prev) =>
prev === 0 ? suggestions.length - 1 : prev - 1,
)
return
}
case 'Escape': {
setSuggestions([])
return
}
case 'Enter': {
e.preventDefault()
const trimmedValue: string = field.state.value?.trim() ?? ''
if (!trimmedValue.length) {
return
}
const selectedValue =
selectedSuggestionIndex >= 0
? suggestions[selectedSuggestionIndex]
: trimmedValue
handleSelectSuggestion(selectedValue)
return
}
case 'Tab': {
if (e.shiftKey) {
return
}
const trimmedValue = field.state.value?.trim() ?? ''
if (!trimmedValue.length) {
return
}
e.preventDefault()
const selectedValue =
selectedSuggestionIndex >= 0
? suggestions[selectedSuggestionIndex]
: trimmedValue
handleSelectSuggestion(selectedValue)
}
}
}

const trimmedName = field.state.value?.trim() ?? ''
const isDuplicate = !!trimmedName && checkForDuplicate(trimmedName, index)
const activist = registry.getActivist(trimmedName)
const isNewName = !!trimmedName && !activist
const isExisting = !!trimmedName && !!activist
const isMissingEmail = isExisting && !activist.email
const isMissingPhone = isExisting && !activist.phone
const hasAllInfo = isExisting && !isMissingEmail && !isMissingPhone
const isError = !!field.state.meta.errors[0]

return (
<div className="relative">
<div className="flex items-center gap-2">
<div className="relative w-full">
<div className="relative">
<Input
ref={inputRef}
value={field.state.value ?? ''}
onChange={(e) => handleInputChange(e.target.value)}
onKeyDown={handleKeyDown}
name="attendee"
placeholder=""
className={cn(
'w-full transition-colors duration-300 border-2',
isDuplicate || isError
? 'text-red-500 border-red-500 focus:border-red-500'
: isNewName
? 'border-purple-500 focus:border-transparent'
: '',
)}
autoComplete="off"
onFocus={() => onFocus(index)}
onBlur={() => {
field.handleBlur()
setSuggestions([])
}}
/>
<div className="right-0 top-0 bottom-0 h-full pointer-events-none absolute flex gap-2 items-center p-1.5 opacity-80">
{hasAllInfo && <Check className="text-green-500" />}
{isNewName && <UserRoundPlus className="text-purple-500" />}
{isMissingEmail && <MailX className="text-orange-500" />}
{isMissingPhone && <PhoneMissed className="text-orange-500" />}
</div>
</div>
{isFocused && !!suggestions.length && (
<ul className="absolute z-10 mt-1 w-full rounded-md border border-gray-200 bg-white shadow-lg">
{suggestions.map((suggestion, i) => (
<li
key={suggestion}
className={cn(
'cursor-pointer px-4 py-2 hover:bg-gray-100',
i === selectedSuggestionIndex ? 'bg-neutral-100' : '',
)}
onMouseDown={(e) => {
// Use onMouseDown instead of onClick to fire before onBlur.
e.preventDefault()
handleSelectSuggestion(suggestion)
}}
>
{suggestion}
</li>
))}
</ul>
)}
</div>
</div>
{field.state.meta.errors[0] && (
<p className="text-sm text-red-500 mt-1">
{field.state.meta.errors[0]?.message}
</p>
)}
{isDuplicate && (
<p className="text-sm text-red-500 mt-1">Duplicate entry</p>
)}
</div>
)
}
Loading