hensei-web/components/raids/RaidCombobox/index.tsx
Justin Edmund e4b7f0c356
Fix TypeScript errors for production build (#436)
## Summary
Fixed multiple TypeScript errors that were preventing the production
build from completing on Railway.

## Changes Made

### Nullable Type Fixes
- Fixed `searchParams.toString()` calls with optional chaining (`?.`)
and fallback values
- Fixed `pathname` nullable access in UpdateToastClient
- Added fallbacks for undefined values in translation interpolations

### Type Consistency Fixes
- Fixed recency parameter handling (string from URL, converted to number
internally)
- Removed duplicate local interface definitions for Party and User types
- Fixed Party type mismatches by using global type definitions

### API Route Error Handling
- Fixed error type checking in catch blocks for login/signup routes
- Added proper type guards for axios error objects

### Component Props Fixes
- Fixed RadixSelect.Trigger by removing invalid placeholder prop
- Fixed Toast and Tooltip components by using Omit to exclude
conflicting content type
- Added missing onAdvancedFilter prop to FilterBar components
- Fixed PartyFooter props with required parameters

## Test Plan
- [x] Fixed all TypeScript compilation errors locally
- [ ] Production build should complete successfully on Railway
- [ ] All affected components should function correctly

🤖 Generated with [Claude Code](https://claude.ai/code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-04 01:47:10 -07:00

581 lines
16 KiB
TypeScript

'use client'
import { createRef, useCallback, useEffect, useState } from 'react'
import { getCookie } from 'cookies-next'
import { useTranslations } from 'next-intl'
import classNames from 'classnames'
import { Command, CommandGroup, CommandInput } from 'cmdk'
import Popover from '~components/common/Popover'
import SegmentedControl from '~components/common/SegmentedControl'
import Segment from '~components/common/Segment'
import RaidItem from '~components/raids/RaidItem'
import Tooltip from '~components/common/Tooltip'
import Button from '~components/common/Button'
import ArrowIcon from '~public/icons/Arrow.svg'
import CrossIcon from '~public/icons/Cross.svg'
import styles from './index.module.scss'
const NUM_SECTIONS = 3
const NUM_ELEMENTS = 5
enum Sort {
ASCENDING,
DESCENDING,
}
// Set up empty raid for "All raids"
const untitledGroup: RaidGroup = {
id: '0',
name: {
en: '',
ja: '',
},
section: 0,
order: 0,
extra: false,
guidebooks: false,
raids: [],
difficulty: 0,
hl: false,
}
// Set up empty raid for "All raids"
const allRaidsOption: Raid = {
id: '0',
name: {
en: 'All battles',
ja: '全てのバトル',
},
group: untitledGroup,
slug: 'all',
level: 0,
element: 0,
}
interface Props {
showAllRaidsOption: boolean
currentRaid?: Raid
defaultRaid?: Raid
raidGroups: RaidGroup[]
minimal?: boolean
tabIndex?: number
size?: 'small' | 'medium' | 'large'
onChange?: (raid?: Raid) => void
onBlur?: (event: React.ChangeEvent<HTMLSelectElement>) => void
}
const RaidCombobox = (props: Props) => {
// Set up locale from cookie
const locale = (getCookie('NEXT_LOCALE') as string) || 'en'
// Set up translations
const t = useTranslations('common')
// Component state
const [open, setOpen] = useState(false)
const [sort, setSort] = useState<Sort>(Sort.DESCENDING)
const [scrolled, setScrolled] = useState(false)
// Data state
const [currentSection, setCurrentSection] = useState(1)
const [query, setQuery] = useState('')
const [sections, setSections] = useState<RaidGroup[][]>()
const [currentRaid, setCurrentRaid] = useState<Raid>()
const [tabIndex, setTabIndex] = useState(NUM_ELEMENTS + 1)
// Data
const [farmingRaid, setFarmingRaid] = useState<Raid>()
// Refs
const listRef = createRef<HTMLDivElement>()
const inputRef = createRef<HTMLInputElement>()
const sortButtonRef = createRef<HTMLButtonElement>()
// Classes
const comboboxClasses = classNames({
[styles.combobox]: true,
[styles.raid]: true,
})
const raidsClasses = classNames({
[styles.raids]: true,
[styles.searching]: query !== '',
})
// ----------------------------------------------
// Methods: Lifecycle Hooks
// ----------------------------------------------
// Fetch all raids on mount
useEffect(() => {
const sections: [RaidGroup[], RaidGroup[], RaidGroup[]] = [[], [], []]
props.raidGroups.forEach((group) => {
if (group.section > 0) sections[group.section - 1].push(group)
})
if (props.raidGroups[0]) {
setFarmingRaid(props.raidGroups[0].raids[0])
}
setSections(sections)
}, [])
// Set current section when the current raid changes
useEffect(() => {
if (props.currentRaid) {
setCurrentRaid(props.currentRaid)
setCurrentSection(props.currentRaid.group.section)
} else {
setCurrentRaid(undefined)
setCurrentSection(1)
}
}, [props.currentRaid])
// Scroll to the top of the list when the user switches tabs
useEffect(() => {
if (listRef.current) {
listRef.current.scrollTop = 0
}
}, [currentSection])
useEffect(() => {
setTabIndex(NUM_ELEMENTS + 1)
}, [currentSection])
// ----------------------------------------------
// Methods: Event Handlers
// ----------------------------------------------
// Handle Escape key press event
const handleEscapeKeyPressed = useCallback(() => {
if (listRef.current) {
listRef.current.focus()
}
}, [open, currentRaid, sortButtonRef])
// Handle Arrow key press event by focusing the list item above or below the current one based on the direction
const handleArrowKeyPressed = useCallback(
(direction: 'Up' | 'Down') => {
const current = listRef.current?.querySelector(
'.raid:focus'
) as HTMLElement | null
if (current) {
let next: Element | null | undefined
if (direction === 'Down' && !current.nextElementSibling) {
const nextParent =
current.parentElement?.parentElement?.nextElementSibling
next = nextParent?.querySelector('.raid')
} else if (direction === 'Up' && !current.previousElementSibling) {
const previousParent =
current.parentElement?.parentElement?.previousElementSibling
next = previousParent?.querySelector('.raid:last-child')
} else {
next =
direction === 'Up'
? current.previousElementSibling
: current.nextElementSibling
}
if (next) {
;(next as HTMLElement).focus()
}
}
},
[open, currentRaid, listRef]
)
// Scroll to an item in the list when it is selected
const scrollToItem = useCallback(
(node: HTMLElement | null) => {
if (!scrolled && open && currentRaid && listRef.current && node) {
const { top: listTop } = listRef.current.getBoundingClientRect()
const { top: itemTop } = node.getBoundingClientRect()
listRef.current.scrollTop = itemTop - listTop
node.focus()
setScrolled(true)
}
},
[scrolled, open, currentRaid, listRef]
)
// Reverse the sort order
function reverseSort() {
if (sort === Sort.ASCENDING) setSort(Sort.DESCENDING)
else setSort(Sort.ASCENDING)
}
const handleSortButtonKeyDown = (
event: React.KeyboardEvent<HTMLButtonElement>
) => {
// If the tab key is pressed without the Shift key, focus the raid list
if (event.key === 'Tab' && !event.shiftKey) {
if (listRef.current) {
listRef.current.focus()
}
}
}
const handleListKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Tab' && !event.shiftKey) {
event.preventDefault()
if (inputRef.current) {
inputRef.current.focus()
}
} else if (event.key === 'Tab' && event.shiftKey) {
event.preventDefault()
if (sortButtonRef.current) {
sortButtonRef.current.focus()
}
}
// If the enter key is pressed, focus the first raid item in the list
else if (event.key === 'Enter') {
event.preventDefault()
if (listRef.current) {
const raid = listRef.current.querySelector('.raid')
if (raid) {
;(raid as HTMLElement).focus()
}
}
}
}
// Handle value change for the raid selection
function handleValueChange(raid: Raid) {
setCurrentRaid(raid)
setOpen(false)
setScrolled(false)
if (props.onChange) props.onChange(raid)
}
// Toggle the open state of the combobox
function toggleOpen() {
if (open) {
if (
currentRaid &&
currentRaid.slug !== 'all' &&
currentRaid.group.section > 0
) {
setCurrentSection(currentRaid.group.section)
}
setScrolled(false)
}
setOpen(!open)
}
// Clear the search query
function clearSearch() {
setQuery('')
}
// ----------------------------------------------
// Methods: Rendering
// ----------------------------------------------
// Renders each raid section
function renderRaidSections() {
return Array.from({ length: NUM_SECTIONS }, (_, i) => renderRaidSection(i))
}
// Renders the specified raid section
function renderRaidSection(section: number) {
const currentSection = sections?.[section]
if (!currentSection) return
const sortedGroups = currentSection.sort((a, b) => {
return sort === Sort.ASCENDING ? a.order - b.order : b.order - a.order
})
return sortedGroups.map((group, i) => renderRaidGroup(section, i))
}
// Renders the specified raid group
function renderRaidGroup(section: number, index: number) {
if (!sections?.[section]?.[index]) return
const group = sections[section][index]
const options = generateRaidItems(group.raids)
const groupClassName = classNames({
[styles.group]: true,
[styles.hidden]: group.section !== currentSection,
})
const heading = (
<div className={styles.label}>
{group.name[locale]}
<div className={styles.separator} />
</div>
)
return (
<CommandGroup
data-section={group.section}
className={groupClassName}
key={group.name[locale].toLowerCase().replace(' ', '-')}
heading={heading}
>
{options}
</CommandGroup>
)
}
// Render the ungrouped raid group
function renderUngroupedRaids() {
let ungroupedRaids = farmingRaid ? [farmingRaid] : []
if (props.showAllRaidsOption) {
ungroupedRaids.push(allRaidsOption)
}
const options = generateRaidItems(ungroupedRaids)
return (
<CommandGroup
data-section={untitledGroup.section}
className={classNames({
[styles.group]: true,
})}
key="ungrouped-raids"
>
{options}
</CommandGroup>
)
}
// Generates a list of RaidItem components from the specified raids
function generateRaidItems(raids: Raid[]) {
return raids
.sort((a, b) => {
if (a.element > 0 && b.element > 0) return a.element - b.element
if (a.name.en.includes('NM') && b.name.en.includes('NM'))
return a.level - b.level
return a.name.en.localeCompare(b.name.en)
})
.map((item, i) => renderRaidItem(item, i))
}
// Renders a RaidItem component for the specified raid
function renderRaidItem(raid: Raid, key: number) {
const isSelected = currentRaid?.id === raid.id
const isRef = isSelected ? scrollToItem : undefined
const imageUrl = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/raids/${raid.slug}.png`
return (
<RaidItem
className={classNames({ [styles.selected]: isSelected })}
icon={{ alt: raid.name[locale], src: imageUrl }}
extra={raid.group.extra}
key={key}
selected={isSelected}
ref={isRef}
role="listitem"
tabIndex={0}
value={raid.slug}
onEscapeKeyPressed={handleEscapeKeyPressed}
onArrowKeyPressed={handleArrowKeyPressed}
onSelect={() => handleValueChange(raid)}
>
{raid.name[locale]}
</RaidItem>
)
}
// Renders a SegmentedControl component for selecting raid sections.
function renderSegmentedControl() {
return (
<SegmentedControl blended={true} className="raid" wrapperClassName="raid">
<Segment
groupName="raid_section"
name="events"
selected={currentSection === 2}
tabIndex={2}
onClick={() => setCurrentSection(2)}
>
{t('raids.sections.events')}
</Segment>
<Segment
groupName="raid_section"
name="raids"
selected={currentSection === 1}
tabIndex={3}
onClick={() => setCurrentSection(1)}
>
{t('raids.sections.raids')}
</Segment>
<Segment
groupName="raid_section"
name="solo"
selected={currentSection === 3}
tabIndex={4}
onClick={() => setCurrentSection(3)}
>
{t('raids.sections.solo')}
</Segment>
</SegmentedControl>
)
}
// Renders a Button for sorting raids and a Tooltip for explaining what it does.
function renderSortButton() {
return (
<Tooltip
content={
sort === Sort.ASCENDING
? 'Lower difficulty battles first'
: 'Higher difficulty battles first'
}
>
<Button
blended={true}
bound={true}
size="small"
leftAccessoryIcon={<ArrowIcon />}
leftAccessoryClassName={sort === Sort.DESCENDING ? 'flipped' : ''}
onClick={reverseSort}
onKeyDown={handleSortButtonKeyDown}
ref={sortButtonRef}
tabIndex={5}
/>
</Tooltip>
)
}
// Renders the content for the Popover trigger.
function renderTriggerContent() {
if (currentRaid) {
const element = (
<>
{!props.minimal ? (
<div className={styles.info}>
<span className={styles.group}>
{currentRaid.group.name[locale]}
</span>
<span className={styles.separator}>/</span>
<span
className={classNames(
{
[styles.raid]: true,
},
linkClass?.split(' ').map((className) => styles[className])
)}
>
{currentRaid.name[locale]}
</span>
</div>
) : (
<span className={classNames({ Raid: true }, linkClass)}>
{currentRaid.name[locale]}
</span>
)}
{currentRaid.group.extra && !props.minimal && (
<i className={styles.extraIndicator}>EX</i>
)}
</>
)
return {
element,
rawValue: currentRaid.id,
}
}
return undefined
}
// Renders the search input for the raid combobox
function renderSearchInput() {
return (
<div className={styles.wrapper}>
<CommandInput
className={styles.input}
placeholder={t('search.placeholders.raid')}
tabIndex={1}
ref={inputRef}
value={query}
onValueChange={setQuery}
/>
<div
className={classNames({
[styles.button]: true,
[styles.clear]: true,
[styles.visible]: query.length > 0,
})}
onClick={clearSearch}
>
<CrossIcon />
</div>
</div>
)
}
// ----------------------------------------------
// Methods: Utility
// ----------------------------------------------
const linkClass = classNames({
wind: currentRaid && currentRaid.element == 1,
fire: currentRaid && currentRaid.element == 2,
water: currentRaid && currentRaid.element == 3,
earth: currentRaid && currentRaid.element == 4,
dark: currentRaid && currentRaid.element == 5,
light: currentRaid && currentRaid.element == 6,
})
// ----------------------------------------------
// Render
// ----------------------------------------------
return (
<Popover
className="raid flush"
open={open}
onOpenChange={toggleOpen}
trigger={{
bound: true,
placeholder: props.showAllRaidsOption ? t('raids.all') : t('raids.placeholder'),
className: classNames({
raid: true,
highlighted: props.showAllRaidsOption,
}),
size: props.size,
}}
triggerTabIndex={props.tabIndex}
value={renderTriggerContent()}
>
<Command className={comboboxClasses}>
<div className={styles.header}>
{renderSearchInput()}
{!query && (
<div className={styles.controls}>
{renderSegmentedControl()}
{renderSortButton()}
</div>
)}
</div>
<div
className={raidsClasses}
ref={listRef}
role="listbox"
tabIndex={6}
onKeyDown={handleListKeyDown}
>
{renderUngroupedRaids()}
{renderRaidSections()}
</div>
</Command>
</Popover>
)
}
RaidCombobox.defaultProps = {
minimal: false,
}
export default RaidCombobox