refactor: replace browser alert() calls with toast notifications

- Create ToastContext with useToast hook for app-wide toast management
- Add ToastProvider to ClientProviders for global toast access
- Replace all 13 alert() calls across arcade room pages and components
- Use consistent toast patterns: showError, showSuccess, showInfo
- Improve UX with dismissible, auto-timing toast notifications

Files updated:
- src/components/common/ToastContext.tsx (new)
- src/components/ClientProviders.tsx
- src/app/arcade-rooms/page.tsx
- src/app/arcade-rooms/[roomId]/page.tsx
- src/components/nav/ModerationNotifications.tsx
- src/components/nav/AddPlayerButton.tsx
- src/components/nav/PendingInvitations.tsx

Also removed invalid manually-created migration 0009 (will be regenerated properly)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-10-14 09:08:21 -05:00
parent 2fb6ead4f2
commit 87ef35682e
8 changed files with 275 additions and 26 deletions

View File

@@ -1,2 +0,0 @@
-- Add display_password column to arcade_rooms for showing plain text passwords to room owners
ALTER TABLE `arcade_rooms` ADD `display_password` text(100);

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { io, type Socket } from 'socket.io-client'
import { css } from '../../../../styled-system/css'
import { useToast } from '@/components/common/ToastContext'
import { PageWithNav } from '@/components/PageWithNav'
import { useViewerId } from '@/hooks/useViewerId'
import { getRoomDisplayWithEmoji } from '@/utils/room-display'
@@ -40,6 +41,7 @@ interface Player {
export default function RoomDetailPage() {
const params = useParams()
const router = useRouter()
const { showError } = useToast()
const roomId = params.roomId as string
const { data: guestId } = useViewerId()
@@ -172,7 +174,7 @@ export default function RoomDetailPage() {
// Handle specific room membership conflict
if (errorData.code === 'ROOM_MEMBERSHIP_CONFLICT') {
alert(errorData.userMessage || errorData.message)
showError('Already in Another Room', errorData.userMessage || errorData.message)
// Refresh the page to update room state
await fetchRoom()
return
@@ -193,7 +195,7 @@ export default function RoomDetailPage() {
await fetchRoom()
} catch (err) {
console.error('Failed to join room:', err)
alert('Failed to join room')
showError('Failed to join room', err instanceof Error ? err.message : undefined)
}
}
@@ -213,7 +215,7 @@ export default function RoomDetailPage() {
router.push('/arcade')
} catch (err) {
console.error('Failed to leave room:', err)
alert('Failed to leave room')
showError('Failed to leave room', err instanceof Error ? err.message : undefined)
}
}

View File

@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { css } from '../../../styled-system/css'
import { useToast } from '@/components/common/ToastContext'
import { PageWithNav } from '@/components/PageWithNav'
import { getRoomDisplayWithEmoji } from '@/utils/room-display'
@@ -23,6 +24,7 @@ interface Room {
export default function RoomBrowserPage() {
const router = useRouter()
const { showError, showInfo } = useToast()
const [rooms, setRooms] = useState<Room[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -71,7 +73,7 @@ export default function RoomBrowserPage() {
router.push(`/arcade-rooms/${data.room.id}`)
} catch (err) {
console.error('Failed to create room:', err)
alert('Failed to create room')
showError('Failed to create room', err instanceof Error ? err.message : undefined)
}
}
@@ -90,7 +92,7 @@ export default function RoomBrowserPage() {
if (!response.ok) {
const errorData = await response.json()
alert(errorData.error || 'Failed to join room')
showError('Failed to join room', errorData.error)
return
}
@@ -99,12 +101,15 @@ export default function RoomBrowserPage() {
}
if (room.accessMode === 'approval-only') {
alert('This room requires host approval. Please use the Join Room modal to request access.')
showInfo(
'Approval Required',
'This room requires host approval. Please use the Join Room modal to request access.'
)
return
}
if (room.accessMode === 'restricted') {
alert('This room is invitation-only. Please ask the host for an invitation.')
showInfo('Invitation Only', 'This room is invitation-only. Please ask the host for an invitation.')
return
}
@@ -120,7 +125,7 @@ export default function RoomBrowserPage() {
// Handle specific room membership conflict
if (errorData.code === 'ROOM_MEMBERSHIP_CONFLICT') {
alert(errorData.userMessage || errorData.message)
showError('Already in Another Room', errorData.userMessage || errorData.message)
// Refresh the page to update room list state
await fetchRooms()
return
@@ -140,7 +145,7 @@ export default function RoomBrowserPage() {
router.push(`/arcade-rooms/${room.id}`)
} catch (err) {
console.error('Failed to join room:', err)
alert('Failed to join room')
showError('Failed to join room', err instanceof Error ? err.message : undefined)
}
}

View File

@@ -3,6 +3,7 @@
import { AbacusDisplayProvider } from '@soroban/abacus-react'
import { QueryClientProvider } from '@tanstack/react-query'
import { type ReactNode, useState } from 'react'
import { ToastProvider } from '@/components/common/ToastContext'
import { FullscreenProvider } from '@/contexts/FullscreenContext'
import { GameModeProvider } from '@/contexts/GameModeContext'
import { UserProfileProvider } from '@/contexts/UserProfileContext'
@@ -20,17 +21,19 @@ export function ClientProviders({ children }: ClientProvidersProps) {
return (
<QueryClientProvider client={queryClient}>
<AbacusDisplayProvider>
<AbacusSettingsSync />
<UserProfileProvider>
<GameModeProvider>
<FullscreenProvider>
{children}
<DeploymentInfo />
</FullscreenProvider>
</GameModeProvider>
</UserProfileProvider>
</AbacusDisplayProvider>
<ToastProvider>
<AbacusDisplayProvider>
<AbacusSettingsSync />
<UserProfileProvider>
<GameModeProvider>
<FullscreenProvider>
{children}
<DeploymentInfo />
</FullscreenProvider>
</GameModeProvider>
</UserProfileProvider>
</AbacusDisplayProvider>
</ToastProvider>
</QueryClientProvider>
)
}

View File

@@ -0,0 +1,235 @@
'use client'
import * as Toast from '@radix-ui/react-toast'
import { createContext, useCallback, useContext, useState, type ReactNode } from 'react'
export interface ToastMessage {
id: string
type: 'success' | 'error' | 'info'
title: string
description?: string
duration?: number
}
interface ToastContextValue {
showToast: (toast: Omit<ToastMessage, 'id'>) => void
showSuccess: (title: string, description?: string) => void
showError: (title: string, description?: string) => void
showInfo: (title: string, description?: string) => void
}
const ToastContext = createContext<ToastContextValue | null>(null)
export function useToast() {
const context = useContext(ToastContext)
if (!context) {
throw new Error('useToast must be used within ToastProvider')
}
return context
}
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastMessage[]>([])
const showToast = useCallback((toast: Omit<ToastMessage, 'id'>) => {
const id = Math.random().toString(36).substring(7)
setToasts((prev) => [...prev, { ...toast, id }])
}, [])
const showSuccess = useCallback(
(title: string, description?: string) => {
showToast({ type: 'success', title, description, duration: 5000 })
},
[showToast]
)
const showError = useCallback(
(title: string, description?: string) => {
showToast({ type: 'error', title, description, duration: 7000 })
},
[showToast]
)
const showInfo = useCallback(
(title: string, description?: string) => {
showToast({ type: 'info', title, description, duration: 5000 })
},
[showToast]
)
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, [])
const getToastStyles = (type: ToastMessage['type']) => {
switch (type) {
case 'success':
return {
background: 'linear-gradient(135deg, rgba(34, 197, 94, 0.97), rgba(22, 163, 74, 0.97))',
border: '2px solid rgba(34, 197, 94, 0.6)',
icon: '✓',
}
case 'error':
return {
background: 'linear-gradient(135deg, rgba(239, 68, 68, 0.97), rgba(220, 38, 38, 0.97))',
border: '2px solid rgba(239, 68, 68, 0.6)',
icon: '⚠',
}
case 'info':
return {
background: 'linear-gradient(135deg, rgba(59, 130, 246, 0.97), rgba(37, 99, 235, 0.97))',
border: '2px solid rgba(59, 130, 246, 0.6)',
icon: '',
}
}
}
return (
<ToastContext.Provider value={{ showToast, showSuccess, showError, showInfo }}>
{children}
<Toast.Provider swipeDirection="right">
{toasts.map((toast) => {
const styles = getToastStyles(toast.type)
return (
<Toast.Root
key={toast.id}
open={true}
onOpenChange={(open) => {
if (!open) {
removeToast(toast.id)
}
}}
duration={toast.duration}
style={{
background: styles.background,
border: styles.border,
borderRadius: '12px',
padding: '16px',
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.4)',
display: 'flex',
gap: '12px',
alignItems: 'flex-start',
minWidth: '300px',
maxWidth: '450px',
transition: 'all 0.2s ease',
}}
>
<div style={{ fontSize: '20px', flexShrink: 0 }}>{styles.icon}</div>
<div style={{ flex: 1 }}>
<Toast.Title
style={{
fontSize: '14px',
fontWeight: 'bold',
color: 'white',
marginBottom: toast.description ? '4px' : 0,
}}
>
{toast.title}
</Toast.Title>
{toast.description && (
<Toast.Description
style={{
fontSize: '13px',
color: 'rgba(255, 255, 255, 0.9)',
}}
>
{toast.description}
</Toast.Description>
)}
</div>
<Toast.Close
style={{
background: 'rgba(255, 255, 255, 0.2)',
border: 'none',
borderRadius: '50%',
width: '24px',
height: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
color: 'white',
fontSize: '16px',
lineHeight: 1,
flexShrink: 0,
}}
>
×
</Toast.Close>
</Toast.Root>
)
})}
<Toast.Viewport
style={{
position: 'fixed',
top: '80px',
right: '20px',
display: 'flex',
flexDirection: 'column',
gap: '10px',
zIndex: 10001,
maxWidth: '100vw',
margin: 0,
listStyle: 'none',
outline: 'none',
}}
/>
<style
dangerouslySetInnerHTML={{
__html: `
@keyframes slideIn {
from {
transform: translateX(calc(100% + 25px));
}
to {
transform: translateX(0);
}
}
@keyframes slideOut {
from {
transform: translateX(0);
}
to {
transform: translateX(calc(100% + 25px));
}
}
@keyframes hide {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
[data-state='open'] {
animation: slideIn 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
[data-state='closed'] {
animation: hide 100ms ease-in, slideOut 200ms cubic-bezier(0.32, 0, 0.67, 0);
}
[data-swipe='move'] {
transform: translateX(var(--radix-toast-swipe-move-x));
}
[data-swipe='cancel'] {
transform: translateX(0);
transition: transform 200ms ease-out;
}
[data-swipe='end'] {
animation: slideOut 100ms ease-out;
}
`,
}}
/>
</Toast.Provider>
</ToastContext.Provider>
)
}

View File

@@ -1,5 +1,6 @@
import React from 'react'
import { useRouter } from 'next/navigation'
import { useToast } from '@/components/common/ToastContext'
import { InvitePlayersTab } from './InvitePlayersTab'
import { PlayOnlineTab } from './PlayOnlineTab'
import { addToRecentRooms } from './RecentRoomsList'
@@ -41,6 +42,7 @@ export function AddPlayerButton({
}: AddPlayerButtonProps) {
const popoverRef = React.useRef<HTMLDivElement>(null)
const router = useRouter()
const { showError } = useToast()
// Use lifted state if provided, otherwise fallback to internal state
const [internalShowPopover, setInternalShowPopover] = React.useState(false)
@@ -75,7 +77,7 @@ export function AddPlayerButton({
},
onError: (error) => {
console.error('Failed to create room:', error)
alert(`Failed to create room: ${error.message}`)
showError('Failed to create room', error.message)
},
}
)

View File

@@ -3,6 +3,7 @@ import { useQueryClient } from '@tanstack/react-query'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { Modal } from '@/components/common/Modal'
import { useToast } from '@/components/common/ToastContext'
import type { ModerationEvent } from '@/hooks/useRoomData'
import { useJoinRoom } from '@/hooks/useRoomData'
@@ -27,6 +28,7 @@ export function ModerationNotifications({
}: ModerationNotificationsProps) {
const router = useRouter()
const queryClient = useQueryClient()
const { showError } = useToast()
const [showToast, setShowToast] = useState(false)
const [showJoinRequestToast, setShowJoinRequestToast] = useState(false)
const [isAcceptingInvitation, setIsAcceptingInvitation] = useState(false)
@@ -834,7 +836,7 @@ export function ModerationNotifications({
router.push('/arcade/room')
} catch (error) {
console.error('Failed to join room:', error)
alert(error instanceof Error ? error.message : 'Failed to join room')
showError('Failed to join room', error instanceof Error ? error.message : undefined)
setIsAcceptingInvitation(false)
}
}}

View File

@@ -1,5 +1,6 @@
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useToast } from '@/components/common/ToastContext'
import { useJoinRoom } from '@/hooks/useRoomData'
interface PendingInvitation {
@@ -32,6 +33,7 @@ export interface PendingInvitationsProps {
*/
export function PendingInvitations({ onInvitationChange, currentRoomId }: PendingInvitationsProps) {
const router = useRouter()
const { showError } = useToast()
const [invitations, setInvitations] = useState<PendingInvitation[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState('')
@@ -72,7 +74,7 @@ export function PendingInvitations({ onInvitationChange, currentRoomId }: Pendin
onInvitationChange?.()
} catch (error) {
console.error('Failed to join room:', error)
alert(error instanceof Error ? error.message : 'Failed to join room')
showError('Failed to join room', error instanceof Error ? error.message : undefined)
} finally {
setActionLoading(null)
}
@@ -97,7 +99,7 @@ export function PendingInvitations({ onInvitationChange, currentRoomId }: Pendin
onInvitationChange?.()
} catch (error) {
console.error('Failed to decline invitation:', error)
alert(error instanceof Error ? error.message : 'Failed to decline invitation')
showError('Failed to decline invitation', error instanceof Error ? error.message : undefined)
} finally {
setActionLoading(null)
}