Add modals

This commit is contained in:
2025-12-16 01:50:40 +07:00
parent 87ecd9756c
commit 574140e67d
11 changed files with 439 additions and 40 deletions

View File

@@ -1,5 +1,6 @@
import { Routes, Route, Navigate } from 'react-router-dom' import { Routes, Route, Navigate } from 'react-router-dom'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { ToastContainer, ConfirmModal } from '@/components/ui'
// Layout // Layout
import { Layout } from '@/components/layout/Layout' import { Layout } from '@/components/layout/Layout'
@@ -41,6 +42,9 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
function App() { function App() {
return ( return (
<>
<ToastContainer />
<ConfirmModal />
<Routes> <Routes>
<Route path="/" element={<Layout />}> <Route path="/" element={<Layout />}>
<Route index element={<HomePage />} /> <Route index element={<HomePage />} />
@@ -130,6 +134,7 @@ function App() {
/> />
</Route> </Route>
</Routes> </Routes>
</>
) )
} }

View File

@@ -4,6 +4,8 @@ import { Button } from '@/components/ui'
import { eventsApi } from '@/api' import { eventsApi } from '@/api'
import type { ActiveEvent, EventType, Challenge } from '@/types' import type { ActiveEvent, EventType, Challenge } from '@/types'
import { EVENT_INFO } from '@/types' import { EVENT_INFO } from '@/types'
import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm'
interface EventControlProps { interface EventControlProps {
marathonId: number marathonId: number
@@ -36,6 +38,8 @@ export function EventControl({
challenges, challenges,
onEventChange, onEventChange,
}: EventControlProps) { }: EventControlProps) {
const toast = useToast()
const confirm = useConfirm()
const [selectedType, setSelectedType] = useState<EventType>('golden_hour') const [selectedType, setSelectedType] = useState<EventType>('golden_hour')
const [selectedChallengeId, setSelectedChallengeId] = useState<number | null>(null) const [selectedChallengeId, setSelectedChallengeId] = useState<number | null>(null)
const [isStarting, setIsStarting] = useState(false) const [isStarting, setIsStarting] = useState(false)
@@ -43,7 +47,7 @@ export function EventControl({
const handleStart = async () => { const handleStart = async () => {
if (selectedType === 'common_enemy' && !selectedChallengeId) { if (selectedType === 'common_enemy' && !selectedChallengeId) {
alert('Выберите челлендж для события "Общий враг"') toast.warning('Выберите челлендж для события "Общий враг"')
return return
} }
@@ -56,14 +60,21 @@ export function EventControl({
onEventChange() onEventChange()
} catch (error) { } catch (error) {
console.error('Failed to start event:', error) console.error('Failed to start event:', error)
alert('Не удалось запустить событие') toast.error('Не удалось запустить событие')
} finally { } finally {
setIsStarting(false) setIsStarting(false)
} }
} }
const handleStop = async () => { const handleStop = async () => {
if (!confirm('Остановить событие досрочно?')) return const confirmed = await confirm({
title: 'Остановить событие?',
message: 'Событие будет завершено досрочно.',
confirmText: 'Остановить',
cancelText: 'Отмена',
variant: 'warning',
})
if (!confirmed) return
setIsStopping(true) setIsStopping(true)
try { try {

View File

@@ -0,0 +1,111 @@
import { useEffect } from 'react'
import { AlertTriangle, Info, Trash2, X } from 'lucide-react'
import { clsx } from 'clsx'
import { useConfirmStore, type ConfirmVariant } from '@/store/confirm'
import { Button } from './Button'
const icons: Record<ConfirmVariant, React.ReactNode> = {
danger: <Trash2 className="w-6 h-6" />,
warning: <AlertTriangle className="w-6 h-6" />,
info: <Info className="w-6 h-6" />,
}
const iconStyles: Record<ConfirmVariant, string> = {
danger: 'bg-red-500/20 text-red-500',
warning: 'bg-yellow-500/20 text-yellow-500',
info: 'bg-blue-500/20 text-blue-500',
}
const buttonVariants: Record<ConfirmVariant, 'danger' | 'primary' | 'secondary'> = {
danger: 'danger',
warning: 'primary',
info: 'primary',
}
export function ConfirmModal() {
const { isOpen, options, handleConfirm, handleCancel } = useConfirmStore()
// Handle escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
handleCancel()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isOpen, handleCancel])
// Prevent body scroll when modal is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => {
document.body.style.overflow = ''
}
}, [isOpen])
if (!isOpen || !options) return null
const variant = options.variant || 'warning'
const Icon = icons[variant]
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/70 backdrop-blur-sm animate-in fade-in duration-200"
onClick={handleCancel}
/>
{/* Modal */}
<div className="relative bg-gray-800 rounded-xl shadow-2xl max-w-md w-full animate-in zoom-in-95 fade-in duration-200 border border-gray-700">
{/* Close button */}
<button
onClick={handleCancel}
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
<div className="p-6">
{/* Icon */}
<div className={clsx('w-12 h-12 rounded-full flex items-center justify-center mb-4', iconStyles[variant])}>
{Icon}
</div>
{/* Title */}
<h3 className="text-xl font-bold text-white mb-2">
{options.title}
</h3>
{/* Message */}
<p className="text-gray-400 mb-6 whitespace-pre-line">
{options.message}
</p>
{/* Actions */}
<div className="flex gap-3">
<Button
variant="secondary"
className="flex-1"
onClick={handleCancel}
>
{options.cancelText || 'Отмена'}
</Button>
<Button
variant={buttonVariants[variant]}
className="flex-1"
onClick={handleConfirm}
>
{options.confirmText || 'Подтвердить'}
</Button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,83 @@
import { useEffect, useState } from 'react'
import { X, CheckCircle, XCircle, AlertTriangle, Info } from 'lucide-react'
import { clsx } from 'clsx'
import { useToastStore, type Toast as ToastType } from '@/store/toast'
const icons = {
success: CheckCircle,
error: XCircle,
warning: AlertTriangle,
info: Info,
}
const styles = {
success: 'bg-green-500/20 border-green-500/50 text-green-400',
error: 'bg-red-500/20 border-red-500/50 text-red-400',
warning: 'bg-yellow-500/20 border-yellow-500/50 text-yellow-400',
info: 'bg-blue-500/20 border-blue-500/50 text-blue-400',
}
const iconStyles = {
success: 'text-green-500',
error: 'text-red-500',
warning: 'text-yellow-500',
info: 'text-blue-500',
}
interface ToastItemProps {
toast: ToastType
onRemove: (id: string) => void
}
function ToastItem({ toast, onRemove }: ToastItemProps) {
const [isVisible, setIsVisible] = useState(false)
const [isLeaving, setIsLeaving] = useState(false)
const Icon = icons[toast.type]
useEffect(() => {
// Trigger enter animation
requestAnimationFrame(() => setIsVisible(true))
}, [])
const handleRemove = () => {
setIsLeaving(true)
setTimeout(() => onRemove(toast.id), 200)
}
return (
<div
className={clsx(
'flex items-start gap-3 p-4 rounded-lg border backdrop-blur-sm shadow-lg',
'transition-all duration-200 ease-out',
styles[toast.type],
isVisible && !isLeaving ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'
)}
>
<Icon className={clsx('w-5 h-5 flex-shrink-0 mt-0.5', iconStyles[toast.type])} />
<p className="flex-1 text-sm text-white">{toast.message}</p>
<button
onClick={handleRemove}
className="flex-shrink-0 text-gray-400 hover:text-white transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
)
}
export function ToastContainer() {
const toasts = useToastStore((state) => state.toasts)
const removeToast = useToastStore((state) => state.removeToast)
if (toasts.length === 0) return null
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm w-full pointer-events-none">
{toasts.map((toast) => (
<div key={toast.id} className="pointer-events-auto">
<ToastItem toast={toast} onRemove={removeToast} />
</div>
))}
</div>
)
}

View File

@@ -1,3 +1,5 @@
export { Button } from './Button' export { Button } from './Button'
export { Input } from './Input' export { Input } from './Input'
export { Card, CardHeader, CardTitle, CardContent } from './Card' export { Card, CardHeader, CardTitle, CardContent } from './Card'
export { ToastContainer } from './Toast'
export { ConfirmModal } from './ConfirmModal'

View File

@@ -4,6 +4,7 @@ import { assignmentsApi } from '@/api'
import type { AssignmentDetail } from '@/types' import type { AssignmentDetail } from '@/types'
import { Card, CardContent, Button } from '@/components/ui' import { Card, CardContent, Button } from '@/components/ui'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { useToast } from '@/store/toast'
import { import {
ArrowLeft, Loader2, ExternalLink, Image, MessageSquare, ArrowLeft, Loader2, ExternalLink, Image, MessageSquare,
ThumbsUp, ThumbsDown, AlertTriangle, Clock, CheckCircle, XCircle, ThumbsUp, ThumbsDown, AlertTriangle, Clock, CheckCircle, XCircle,
@@ -14,6 +15,7 @@ export function AssignmentDetailPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const user = useAuthStore((state) => state.user) const user = useAuthStore((state) => state.user)
const toast = useToast()
const [assignment, setAssignment] = useState<AssignmentDetail | null>(null) const [assignment, setAssignment] = useState<AssignmentDetail | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
@@ -78,7 +80,7 @@ export function AssignmentDetailPage() {
await loadAssignment() await loadAssignment()
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } } const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось создать оспаривание') toast.error(error.response?.data?.detail || 'Не удалось создать оспаривание')
} finally { } finally {
setIsCreatingDispute(false) setIsCreatingDispute(false)
} }
@@ -93,7 +95,7 @@ export function AssignmentDetailPage() {
await loadAssignment() await loadAssignment()
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } } const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось проголосовать') toast.error(error.response?.data?.detail || 'Не удалось проголосовать')
} finally { } finally {
setIsVoting(false) setIsVoting(false)
} }
@@ -109,7 +111,7 @@ export function AssignmentDetailPage() {
await loadAssignment() await loadAssignment()
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } } const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось добавить комментарий') toast.error(error.response?.data?.detail || 'Не удалось добавить комментарий')
} finally { } finally {
setIsAddingComment(false) setIsAddingComment(false)
} }

View File

@@ -4,6 +4,8 @@ import { marathonsApi, gamesApi } from '@/api'
import type { Marathon, Game, Challenge, ChallengePreview } from '@/types' import type { Marathon, Game, Challenge, ChallengePreview } from '@/types'
import { Button, Input, Card, CardContent, CardHeader, CardTitle } from '@/components/ui' import { Button, Input, Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm'
import { import {
Plus, Trash2, Sparkles, Play, Loader2, Gamepad2, X, Save, Eye, Plus, Trash2, Sparkles, Play, Loader2, Gamepad2, X, Save, Eye,
ChevronDown, ChevronUp, Edit2, Check, CheckCircle, XCircle, Clock, User, ArrowLeft ChevronDown, ChevronUp, Edit2, Check, CheckCircle, XCircle, Clock, User, ArrowLeft
@@ -13,6 +15,8 @@ export function LobbyPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const user = useAuthStore((state) => state.user) const user = useAuthStore((state) => state.user)
const toast = useToast()
const confirm = useConfirm()
const [marathon, setMarathon] = useState<Marathon | null>(null) const [marathon, setMarathon] = useState<Marathon | null>(null)
const [games, setGames] = useState<Game[]>([]) const [games, setGames] = useState<Game[]>([])
@@ -99,7 +103,14 @@ export function LobbyPage() {
} }
const handleDeleteGame = async (gameId: number) => { const handleDeleteGame = async (gameId: number) => {
if (!confirm('Удалить эту игру?')) return const confirmed = await confirm({
title: 'Удалить игру?',
message: 'Игра и все её челленджи будут удалены.',
confirmText: 'Удалить',
cancelText: 'Отмена',
variant: 'danger',
})
if (!confirmed) return
try { try {
await gamesApi.delete(gameId) await gamesApi.delete(gameId)
@@ -122,7 +133,14 @@ export function LobbyPage() {
} }
const handleRejectGame = async (gameId: number) => { const handleRejectGame = async (gameId: number) => {
if (!confirm('Отклонить эту игру?')) return const confirmed = await confirm({
title: 'Отклонить игру?',
message: 'Игра будет удалена из списка ожидающих.',
confirmText: 'Отклонить',
cancelText: 'Отмена',
variant: 'danger',
})
if (!confirmed) return
setModeratingGameId(gameId) setModeratingGameId(gameId)
try { try {
@@ -157,7 +175,14 @@ export function LobbyPage() {
} }
const handleDeleteChallenge = async (challengeId: number, gameId: number) => { const handleDeleteChallenge = async (challengeId: number, gameId: number) => {
if (!confirm('Удалить это задание?')) return const confirmed = await confirm({
title: 'Удалить задание?',
message: 'Это действие нельзя отменить.',
confirmText: 'Удалить',
cancelText: 'Отмена',
variant: 'danger',
})
if (!confirmed) return
try { try {
await gamesApi.deleteChallenge(challengeId) await gamesApi.deleteChallenge(challengeId)
@@ -227,7 +252,16 @@ export function LobbyPage() {
} }
const handleStartMarathon = async () => { const handleStartMarathon = async () => {
if (!id || !confirm('Начать марафон? После этого нельзя будет добавить новые игры.')) return if (!id) return
const confirmed = await confirm({
title: 'Начать марафон?',
message: 'После старта нельзя будет добавить новые игры.',
confirmText: 'Начать',
cancelText: 'Отмена',
variant: 'warning',
})
if (!confirmed) return
setIsStarting(true) setIsStarting(true)
try { try {
@@ -235,7 +269,7 @@ export function LobbyPage() {
navigate(`/marathons/${id}/play`) navigate(`/marathons/${id}/play`)
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } } const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось запустить марафон') toast.error(error.response?.data?.detail || 'Не удалось запустить марафон')
} finally { } finally {
setIsStarting(false) setIsStarting(false)
} }

View File

@@ -4,6 +4,8 @@ import { marathonsApi, eventsApi, challengesApi } from '@/api'
import type { Marathon, ActiveEvent, Challenge } from '@/types' import type { Marathon, ActiveEvent, Challenge } from '@/types'
import { Button, Card, CardContent } from '@/components/ui' import { Button, Card, CardContent } from '@/components/ui'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm'
import { EventBanner } from '@/components/EventBanner' import { EventBanner } from '@/components/EventBanner'
import { EventControl } from '@/components/EventControl' import { EventControl } from '@/components/EventControl'
import { ActivityFeed, type ActivityFeedRef } from '@/components/ActivityFeed' import { ActivityFeed, type ActivityFeedRef } from '@/components/ActivityFeed'
@@ -14,6 +16,8 @@ export function MarathonPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const user = useAuthStore((state) => state.user) const user = useAuthStore((state) => state.user)
const toast = useToast()
const confirm = useConfirm()
const [marathon, setMarathon] = useState<Marathon | null>(null) const [marathon, setMarathon] = useState<Marathon | null>(null)
const [activeEvent, setActiveEvent] = useState<ActiveEvent | null>(null) const [activeEvent, setActiveEvent] = useState<ActiveEvent | null>(null)
const [challenges, setChallenges] = useState<Challenge[]>([]) const [challenges, setChallenges] = useState<Challenge[]>([])
@@ -83,7 +87,16 @@ export function MarathonPage() {
} }
const handleDelete = async () => { const handleDelete = async () => {
if (!marathon || !confirm('Вы уверены, что хотите удалить этот марафон? Это действие нельзя отменить.')) return if (!marathon) return
const confirmed = await confirm({
title: 'Удалить марафон?',
message: 'Все данные марафона будут удалены безвозвратно.',
confirmText: 'Удалить',
cancelText: 'Отмена',
variant: 'danger',
})
if (!confirmed) return
setIsDeleting(true) setIsDeleting(true)
try { try {
@@ -91,7 +104,7 @@ export function MarathonPage() {
navigate('/marathons') navigate('/marathons')
} catch (error) { } catch (error) {
console.error('Failed to delete marathon:', error) console.error('Failed to delete marathon:', error)
alert('Не удалось удалить марафон') toast.error('Не удалось удалить марафон')
} finally { } finally {
setIsDeleting(false) setIsDeleting(false)
} }
@@ -106,7 +119,7 @@ export function MarathonPage() {
setMarathon(updated) setMarathon(updated)
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } } const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось присоединиться') toast.error(error.response?.data?.detail || 'Не удалось присоединиться')
} finally { } finally {
setIsJoining(false) setIsJoining(false)
} }

View File

@@ -6,9 +6,13 @@ import { Button, Card, CardContent } from '@/components/ui'
import { SpinWheel } from '@/components/SpinWheel' import { SpinWheel } from '@/components/SpinWheel'
import { EventBanner } from '@/components/EventBanner' import { EventBanner } from '@/components/EventBanner'
import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft, AlertTriangle } from 'lucide-react' import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft, AlertTriangle } from 'lucide-react'
import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm'
export function PlayPage() { export function PlayPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const toast = useToast()
const confirm = useConfirm()
const [marathon, setMarathon] = useState<Marathon | null>(null) const [marathon, setMarathon] = useState<Marathon | null>(null)
const [currentAssignment, setCurrentAssignment] = useState<Assignment | null>(null) const [currentAssignment, setCurrentAssignment] = useState<Assignment | null>(null)
@@ -99,7 +103,7 @@ export function PlayPage() {
setGameChoiceChallenges(challenges) setGameChoiceChallenges(challenges)
} catch (error) { } catch (error) {
console.error('Failed to load game choice challenges:', error) console.error('Failed to load game choice challenges:', error)
alert('Не удалось загрузить челленджи для этой игры') toast.error('Не удалось загрузить челленджи для этой игры')
} finally { } finally {
setIsLoadingChallenges(false) setIsLoadingChallenges(false)
} }
@@ -181,7 +185,7 @@ export function PlayPage() {
return result.game return result.game
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } } const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось крутить') toast.error(error.response?.data?.detail || 'Не удалось крутить')
return null return null
} }
} }
@@ -196,7 +200,7 @@ export function PlayPage() {
const handleComplete = async () => { const handleComplete = async () => {
if (!currentAssignment) return if (!currentAssignment) return
if (!proofFile && !proofUrl) { if (!proofFile && !proofUrl) {
alert('Пожалуйста, предоставьте доказательство (файл или ссылку)') toast.warning('Пожалуйста, предоставьте доказательство (файл или ссылку)')
return return
} }
@@ -208,7 +212,7 @@ export function PlayPage() {
comment: comment || undefined, comment: comment || undefined,
}) })
alert(`Выполнено! +${result.points_earned} очков (бонус серии: +${result.streak_bonus})`) toast.success(`Выполнено! +${result.points_earned} очков (бонус серии: +${result.streak_bonus})`)
// Reset form // Reset form
setProofFile(null) setProofFile(null)
@@ -219,7 +223,7 @@ export function PlayPage() {
await loadData() await loadData()
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } } const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось выполнить') toast.error(error.response?.data?.detail || 'Не удалось выполнить')
} finally { } finally {
setIsCompleting(false) setIsCompleting(false)
} }
@@ -229,18 +233,25 @@ export function PlayPage() {
if (!currentAssignment) return if (!currentAssignment) return
const penalty = spinResult?.drop_penalty || 0 const penalty = spinResult?.drop_penalty || 0
if (!confirm(`Пропустить это задание? Вы потеряете ${penalty} очков.`)) return const confirmed = await confirm({
title: 'Пропустить задание?',
message: `Вы потеряете ${penalty} очков.`,
confirmText: 'Пропустить',
cancelText: 'Отмена',
variant: 'warning',
})
if (!confirmed) return
setIsDropping(true) setIsDropping(true)
try { try {
const result = await wheelApi.drop(currentAssignment.id) const result = await wheelApi.drop(currentAssignment.id)
alert(`Пропущено. Штраф: -${result.penalty} очков`) toast.info(`Пропущено. Штраф: -${result.penalty} очков`)
setSpinResult(null) setSpinResult(null)
await loadData() await loadData()
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } } const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось пропустить') toast.error(error.response?.data?.detail || 'Не удалось пропустить')
} finally { } finally {
setIsDropping(false) setIsDropping(false)
} }
@@ -249,7 +260,7 @@ export function PlayPage() {
const handleEventComplete = async () => { const handleEventComplete = async () => {
if (!eventAssignment?.assignment) return if (!eventAssignment?.assignment) return
if (!eventProofFile && !eventProofUrl) { if (!eventProofFile && !eventProofUrl) {
alert('Пожалуйста, предоставьте доказательство (файл или ссылку)') toast.warning('Пожалуйста, предоставьте доказательство (файл или ссылку)')
return return
} }
@@ -261,7 +272,7 @@ export function PlayPage() {
comment: eventComment || undefined, comment: eventComment || undefined,
}) })
alert(`Выполнено! +${result.points_earned} очков`) toast.success(`Выполнено! +${result.points_earned} очков`)
// Reset form // Reset form
setEventProofFile(null) setEventProofFile(null)
@@ -271,7 +282,7 @@ export function PlayPage() {
await loadData() await loadData()
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } } const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось выполнить') toast.error(error.response?.data?.detail || 'Не удалось выполнить')
} finally { } finally {
setIsEventCompleting(false) setIsEventCompleting(false)
} }
@@ -286,22 +297,27 @@ export function PlayPage() {
if (!id) return if (!id) return
const hasActiveAssignment = !!currentAssignment const hasActiveAssignment = !!currentAssignment
const confirmMessage = hasActiveAssignment const confirmed = await confirm({
? 'Выбрать этот челлендж? Текущее задание будет заменено без штрафа.' title: 'Выбрать челлендж?',
: 'Выбрать этот челлендж?' message: hasActiveAssignment
? 'Текущее задание будет заменено без штрафа.'
if (!confirm(confirmMessage)) return : 'Вы уверены, что хотите выбрать этот челлендж?',
confirmText: 'Выбрать',
cancelText: 'Отмена',
variant: 'info',
})
if (!confirmed) return
setIsSelectingChallenge(true) setIsSelectingChallenge(true)
try { try {
const result = await eventsApi.selectGameChoiceChallenge(parseInt(id), challengeId) const result = await eventsApi.selectGameChoiceChallenge(parseInt(id), challengeId)
alert(result.message) toast.success(result.message)
setSelectedGameId(null) setSelectedGameId(null)
setGameChoiceChallenges(null) setGameChoiceChallenges(null)
await loadData() await loadData()
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } } const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось выбрать челлендж') toast.error(error.response?.data?.detail || 'Не удалось выбрать челлендж')
} finally { } finally {
setIsSelectingChallenge(false) setIsSelectingChallenge(false)
} }
@@ -310,17 +326,24 @@ export function PlayPage() {
const handleSendSwapRequest = async (participantId: number, participantName: string, theirChallenge: string) => { const handleSendSwapRequest = async (participantId: number, participantName: string, theirChallenge: string) => {
if (!id) return if (!id) return
if (!confirm(`Отправить запрос на обмен с ${participantName}?\n\nВы предлагаете обменяться на: "${theirChallenge}"\n\n${participantName} должен будет подтвердить обмен.`)) return const confirmed = await confirm({
title: 'Отправить запрос на обмен?',
message: `Вы предлагаете обменяться с ${participantName} на:\n"${theirChallenge}"\n\n${participantName} должен будет подтвердить обмен.`,
confirmText: 'Отправить',
cancelText: 'Отмена',
variant: 'info',
})
if (!confirmed) return
setSendingRequestTo(participantId) setSendingRequestTo(participantId)
try { try {
await eventsApi.createSwapRequest(parseInt(id), participantId) await eventsApi.createSwapRequest(parseInt(id), participantId)
alert('Запрос на обмен отправлен! Ожидайте подтверждения.') toast.success('Запрос на обмен отправлен! Ожидайте подтверждения.')
await loadSwapRequests() await loadSwapRequests()
await loadSwapCandidates() await loadSwapCandidates()
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } } const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось отправить запрос') toast.error(error.response?.data?.detail || 'Не удалось отправить запрос')
} finally { } finally {
setSendingRequestTo(null) setSendingRequestTo(null)
} }
@@ -329,16 +352,23 @@ export function PlayPage() {
const handleAcceptSwapRequest = async (requestId: number) => { const handleAcceptSwapRequest = async (requestId: number) => {
if (!id) return if (!id) return
if (!confirm('Принять обмен? Задания будут обменяны сразу после подтверждения.')) return const confirmed = await confirm({
title: 'Принять обмен?',
message: 'Задания будут обменяны сразу после подтверждения.',
confirmText: 'Принять',
cancelText: 'Отмена',
variant: 'info',
})
if (!confirmed) return
setProcessingRequestId(requestId) setProcessingRequestId(requestId)
try { try {
await eventsApi.acceptSwapRequest(parseInt(id), requestId) await eventsApi.acceptSwapRequest(parseInt(id), requestId)
alert('Обмен выполнен!') toast.success('Обмен выполнен!')
await loadData() await loadData()
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } } const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось выполнить обмен') toast.error(error.response?.data?.detail || 'Не удалось выполнить обмен')
} finally { } finally {
setProcessingRequestId(null) setProcessingRequestId(null)
} }
@@ -353,7 +383,7 @@ export function PlayPage() {
await loadSwapRequests() await loadSwapRequests()
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } } const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось отклонить запрос') toast.error(error.response?.data?.detail || 'Не удалось отклонить запрос')
} finally { } finally {
setProcessingRequestId(null) setProcessingRequestId(null)
} }
@@ -369,7 +399,7 @@ export function PlayPage() {
await loadSwapCandidates() await loadSwapCandidates()
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } } const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось отменить запрос') toast.error(error.response?.data?.detail || 'Не удалось отменить запрос')
} finally { } finally {
setProcessingRequestId(null) setProcessingRequestId(null)
} }

View File

@@ -0,0 +1,55 @@
import { create } from 'zustand'
export type ConfirmVariant = 'danger' | 'warning' | 'info'
interface ConfirmOptions {
title: string
message: string
confirmText?: string
cancelText?: string
variant?: ConfirmVariant
}
interface ConfirmState {
isOpen: boolean
options: ConfirmOptions | null
resolve: ((value: boolean) => void) | null
confirm: (options: ConfirmOptions) => Promise<boolean>
handleConfirm: () => void
handleCancel: () => void
}
export const useConfirmStore = create<ConfirmState>((set, get) => ({
isOpen: false,
options: null,
resolve: null,
confirm: (options) => {
return new Promise<boolean>((resolve) => {
set({
isOpen: true,
options,
resolve,
})
})
},
handleConfirm: () => {
const { resolve } = get()
if (resolve) resolve(true)
set({ isOpen: false, options: null, resolve: null })
},
handleCancel: () => {
const { resolve } = get()
if (resolve) resolve(false)
set({ isOpen: false, options: null, resolve: null })
},
}))
// Convenient hook
export const useConfirm = () => {
const confirm = useConfirmStore((state) => state.confirm)
return confirm
}

View File

@@ -0,0 +1,53 @@
import { create } from 'zustand'
export type ToastType = 'success' | 'error' | 'info' | 'warning'
export interface Toast {
id: string
type: ToastType
message: string
duration?: number
}
interface ToastState {
toasts: Toast[]
addToast: (type: ToastType, message: string, duration?: number) => void
removeToast: (id: string) => void
}
export const useToastStore = create<ToastState>((set) => ({
toasts: [],
addToast: (type, message, duration = 4000) => {
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
set((state) => ({
toasts: [...state.toasts, { id, type, message, duration }],
}))
if (duration > 0) {
setTimeout(() => {
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
}))
}, duration)
}
},
removeToast: (id) => {
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
}))
},
}))
// Helper hooks for convenience
export const useToast = () => {
const addToast = useToastStore((state) => state.addToast)
return {
success: (message: string, duration?: number) => addToast('success', message, duration),
error: (message: string, duration?: number) => addToast('error', message, duration),
info: (message: string, duration?: number) => addToast('info', message, duration),
warning: (message: string, duration?: number) => addToast('warning', message, duration),
}
}