rework shop

This commit is contained in:
2026-01-08 08:49:51 +07:00
parent 2874b64481
commit 1751c4dd4c
16 changed files with 913 additions and 260 deletions

View File

@@ -5,7 +5,7 @@ import type { Marathon, Assignment, Game, ActiveEvent, SwapCandidate, MySwapRequ
import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
import { SpinWheel } from '@/components/SpinWheel'
import { EventBanner } from '@/components/EventBanner'
import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft, AlertTriangle, Zap, Flame, Target, Download, Shield, RefreshCw, SkipForward, Package } from 'lucide-react'
import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft, AlertTriangle, Zap, Flame, Target, Download, SkipForward, Package, Dice5, Copy, Undo2, Shuffle } from 'lucide-react'
import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm'
import { useShopStore } from '@/store/shop'
@@ -494,45 +494,6 @@ export function PlayPage() {
}
}
const handleUseReroll = async () => {
if (!currentAssignment || !id) return
setIsUsingConsumable('reroll')
try {
await shopApi.useConsumable({
item_code: 'reroll',
marathon_id: parseInt(id),
assignment_id: currentAssignment.id,
})
toast.success('Задание отменено! Можно крутить заново.')
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось использовать Reroll')
} finally {
setIsUsingConsumable(null)
}
}
const handleUseShield = async () => {
if (!id) return
setIsUsingConsumable('shield')
try {
await shopApi.useConsumable({
item_code: 'shield',
marathon_id: parseInt(id),
})
toast.success('Shield активирован! Следующий пропуск будет бесплатным.')
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось активировать Shield')
} finally {
setIsUsingConsumable(null)
}
}
const handleUseBoost = async () => {
if (!id) return
setIsUsingConsumable('boost')
@@ -541,7 +502,7 @@ export function PlayPage() {
item_code: 'boost',
marathon_id: parseInt(id),
})
toast.success('Boost активирован! x1.5 очков за следующее выполнение.')
toast.success('Boost активирован! x1.5 очков за текущее задание.')
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
@@ -552,6 +513,119 @@ export function PlayPage() {
}
}
// Wild Card modal state
const [showWildCardModal, setShowWildCardModal] = useState(false)
const handleUseWildCard = async (gameId: number) => {
if (!currentAssignment || !id) return
setIsUsingConsumable('wild_card')
try {
const result = await shopApi.useConsumable({
item_code: 'wild_card',
marathon_id: parseInt(id),
assignment_id: currentAssignment.id,
game_id: gameId,
})
toast.success(result.effect_description)
setShowWildCardModal(false)
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось использовать Wild Card')
} finally {
setIsUsingConsumable(null)
}
}
const handleUseLuckyDice = async () => {
if (!id) return
setIsUsingConsumable('lucky_dice')
try {
const result = await shopApi.useConsumable({
item_code: 'lucky_dice',
marathon_id: parseInt(id),
})
const multiplier = result.effect_data?.multiplier as number
toast.success(`Lucky Dice: x${multiplier} множитель!`)
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось использовать Lucky Dice')
} finally {
setIsUsingConsumable(null)
}
}
// Copycat modal state
const [showCopycatModal, setShowCopycatModal] = useState(false)
const [copycatCandidates, setCopycatCandidates] = useState<SwapCandidate[]>([])
const [isLoadingCopycatCandidates, setIsLoadingCopycatCandidates] = useState(false)
const loadCopycatCandidates = async () => {
if (!id) return
setIsLoadingCopycatCandidates(true)
try {
const candidates = await eventsApi.getSwapCandidates(parseInt(id))
setCopycatCandidates(candidates)
} catch (error) {
console.error('Failed to load copycat candidates:', error)
} finally {
setIsLoadingCopycatCandidates(false)
}
}
const handleUseCopycat = async (targetParticipantId: number) => {
if (!currentAssignment || !id) return
setIsUsingConsumable('copycat')
try {
const result = await shopApi.useConsumable({
item_code: 'copycat',
marathon_id: parseInt(id),
assignment_id: currentAssignment.id,
target_participant_id: targetParticipantId,
})
toast.success(result.effect_description)
setShowCopycatModal(false)
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось использовать Copycat')
} finally {
setIsUsingConsumable(null)
}
}
const handleUseUndo = async () => {
if (!id) return
const confirmed = await confirm({
title: 'Использовать Undo?',
message: 'Это вернёт очки и серию от последнего пропуска.',
confirmText: 'Использовать',
cancelText: 'Отмена',
variant: 'info',
})
if (!confirmed) return
setIsUsingConsumable('undo')
try {
const result = await shopApi.useConsumable({
item_code: 'undo',
marathon_id: parseInt(id),
})
toast.success(result.effect_description)
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось использовать Undo')
} finally {
setIsUsingConsumable(null)
}
}
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center py-24">
@@ -710,18 +784,18 @@ export function PlayPage() {
</div>
{/* Active effects */}
{(consumablesStatus.has_shield || consumablesStatus.has_active_boost) && (
{(consumablesStatus.has_active_boost || consumablesStatus.has_lucky_dice) && (
<div className="mb-4 p-3 bg-green-500/10 border border-green-500/30 rounded-xl">
<p className="text-green-400 text-sm font-medium mb-2">Активные эффекты:</p>
<div className="flex flex-wrap gap-2">
{consumablesStatus.has_shield && (
<span className="px-2 py-1 bg-blue-500/20 text-blue-400 text-xs rounded-lg border border-blue-500/30 flex items-center gap-1">
<Shield className="w-3 h-3" /> Shield (следующий drop бесплатный)
</span>
)}
{consumablesStatus.has_active_boost && (
<span className="px-2 py-1 bg-yellow-500/20 text-yellow-400 text-xs rounded-lg border border-yellow-500/30 flex items-center gap-1">
<Zap className="w-3 h-3" /> Boost x1.5 (следующий complete)
<Zap className="w-3 h-3" /> Boost x1.5
</span>
)}
{consumablesStatus.has_lucky_dice && (
<span className="px-2 py-1 bg-purple-500/20 text-purple-400 text-xs rounded-lg border border-purple-500/30 flex items-center gap-1">
<Dice5 className="w-3 h-3" /> Lucky Dice x{consumablesStatus.lucky_dice_multiplier}
</span>
)}
</div>
@@ -752,52 +826,6 @@ export function PlayPage() {
</NeonButton>
</div>
{/* Reroll */}
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<RefreshCw className="w-4 h-4 text-cyan-400" />
<span className="text-white font-medium">Reroll</span>
</div>
<span className="text-gray-400 text-sm">{consumablesStatus.rerolls_available} шт.</span>
</div>
<p className="text-gray-500 text-xs mb-2">Переспинить задание</p>
<NeonButton
size="sm"
variant="outline"
onClick={handleUseReroll}
disabled={consumablesStatus.rerolls_available === 0 || !currentAssignment || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'reroll'}
className="w-full"
>
Использовать
</NeonButton>
</div>
{/* Shield */}
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-blue-400" />
<span className="text-white font-medium">Shield</span>
</div>
<span className="text-gray-400 text-sm">
{consumablesStatus.has_shield ? 'Активен' : `${consumablesStatus.shields_available} шт.`}
</span>
</div>
<p className="text-gray-500 text-xs mb-2">Защита от штрафа</p>
<NeonButton
size="sm"
variant="outline"
onClick={handleUseShield}
disabled={consumablesStatus.has_shield || consumablesStatus.shields_available === 0 || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'shield'}
className="w-full"
>
{consumablesStatus.has_shield ? 'Активен' : 'Активировать'}
</NeonButton>
</div>
{/* Boost */}
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">
@@ -821,10 +849,180 @@ export function PlayPage() {
{consumablesStatus.has_active_boost ? 'Активен' : 'Активировать'}
</NeonButton>
</div>
{/* Wild Card */}
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Shuffle className="w-4 h-4 text-green-400" />
<span className="text-white font-medium">Wild Card</span>
</div>
<span className="text-gray-400 text-sm">{consumablesStatus.wild_cards_available} шт.</span>
</div>
<p className="text-gray-500 text-xs mb-2">Выбрать игру</p>
<NeonButton
size="sm"
variant="outline"
onClick={() => setShowWildCardModal(true)}
disabled={consumablesStatus.wild_cards_available === 0 || !currentAssignment || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'wild_card'}
className="w-full"
>
Выбрать
</NeonButton>
</div>
{/* Lucky Dice */}
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Dice5 className="w-4 h-4 text-purple-400" />
<span className="text-white font-medium">Lucky Dice</span>
</div>
<span className="text-gray-400 text-sm">
{consumablesStatus.has_lucky_dice ? `x${consumablesStatus.lucky_dice_multiplier}` : `${consumablesStatus.lucky_dice_available} шт.`}
</span>
</div>
<p className="text-gray-500 text-xs mb-2">Случайный множитель</p>
<NeonButton
size="sm"
variant="outline"
onClick={handleUseLuckyDice}
disabled={consumablesStatus.has_lucky_dice || consumablesStatus.lucky_dice_available === 0 || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'lucky_dice'}
className="w-full"
>
{consumablesStatus.has_lucky_dice ? `x${consumablesStatus.lucky_dice_multiplier}` : 'Бросить'}
</NeonButton>
</div>
{/* Copycat */}
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Copy className="w-4 h-4 text-cyan-400" />
<span className="text-white font-medium">Copycat</span>
</div>
<span className="text-gray-400 text-sm">{consumablesStatus.copycats_available} шт.</span>
</div>
<p className="text-gray-500 text-xs mb-2">Скопировать задание</p>
<NeonButton
size="sm"
variant="outline"
onClick={() => {
setShowCopycatModal(true)
loadCopycatCandidates()
}}
disabled={consumablesStatus.copycats_available === 0 || !currentAssignment || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'copycat'}
className="w-full"
>
Выбрать
</NeonButton>
</div>
{/* Undo */}
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Undo2 className="w-4 h-4 text-red-400" />
<span className="text-white font-medium">Undo</span>
</div>
<span className="text-gray-400 text-sm">{consumablesStatus.undos_available} шт.</span>
</div>
<p className="text-gray-500 text-xs mb-2">Отменить дроп</p>
<NeonButton
size="sm"
variant="outline"
onClick={handleUseUndo}
disabled={!consumablesStatus.can_undo || consumablesStatus.undos_available === 0 || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'undo'}
className="w-full"
>
{consumablesStatus.can_undo ? 'Отменить' : 'Нет дропа'}
</NeonButton>
</div>
</div>
</GlassCard>
)}
{/* Wild Card Modal */}
{showWildCardModal && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<GlassCard className="w-full max-w-md max-h-[80vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-white">Выберите игру</h3>
<button
onClick={() => setShowWildCardModal(false)}
className="p-2 text-gray-400 hover:text-white"
>
<X className="w-5 h-5" />
</button>
</div>
<p className="text-gray-400 text-sm mb-4">
Вы получите случайное задание из выбранной игры
</p>
<div className="space-y-2">
{games.map((game) => (
<button
key={game.id}
onClick={() => handleUseWildCard(game.id)}
disabled={isUsingConsumable === 'wild_card'}
className="w-full p-3 bg-dark-700/50 hover:bg-dark-700 rounded-xl text-left transition-colors border border-dark-600 hover:border-green-500/30 disabled:opacity-50"
>
<p className="text-white font-medium">{game.title}</p>
<p className="text-gray-400 text-xs">{game.challenges_count} челленджей</p>
</button>
))}
</div>
</GlassCard>
</div>
)}
{/* Copycat Modal */}
{showCopycatModal && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<GlassCard className="w-full max-w-md max-h-[80vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-white">Скопировать задание</h3>
<button
onClick={() => setShowCopycatModal(false)}
className="p-2 text-gray-400 hover:text-white"
>
<X className="w-5 h-5" />
</button>
</div>
<p className="text-gray-400 text-sm mb-4">
Выберите участника, чьё задание хотите скопировать
</p>
{isLoadingCopycatCandidates ? (
<div className="flex justify-center py-8">
<Loader2 className="w-8 h-8 animate-spin text-cyan-400" />
</div>
) : copycatCandidates.length === 0 ? (
<p className="text-center text-gray-500 py-8">Нет доступных заданий для копирования</p>
) : (
<div className="space-y-2">
{copycatCandidates.map((candidate) => (
<button
key={candidate.participant_id}
onClick={() => handleUseCopycat(candidate.participant_id)}
disabled={isUsingConsumable === 'copycat'}
className="w-full p-3 bg-dark-700/50 hover:bg-dark-700 rounded-xl text-left transition-colors border border-dark-600 hover:border-cyan-500/30 disabled:opacity-50"
>
<p className="text-white font-medium">{candidate.user.nickname}</p>
<p className="text-cyan-400 text-sm">{candidate.challenge_title}</p>
<p className="text-gray-500 text-xs">
{candidate.game_title} {candidate.challenge_points} очков {candidate.challenge_difficulty}
</p>
</button>
))}
</div>
)}
</GlassCard>
</div>
)}
{/* Tabs for Common Enemy event */}
{activeEvent?.event?.type === 'common_enemy' && (
<div className="flex gap-2 mb-6">