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 { useToast } from '@/store/toast'
import { GlassCard, NeonButton, FramePreview } from '@/components/ui'
import {
Loader2, Package, ShoppingBag, Coins, Check,
Frame, Type, Palette, Image, Zap, Shield, RefreshCw, SkipForward
Frame, Type, Palette, Image, Zap, SkipForward, Shuffle, Dice5, Copy, Undo2
} from 'lucide-react'
import type { InventoryItem, ShopItemType } from '@/types'
import { RARITY_COLORS, RARITY_NAMES, ITEM_TYPE_NAMES } from '@/types'
@@ -13,9 +13,11 @@ import clsx from 'clsx'
const CONSUMABLE_ICONS: Record<string, React.ReactNode> = {
skip: <SkipForward className="w-8 h-8" />,
shield: <Shield className="w-8 h-8" />,
boost: <Zap className="w-8 h-8" />,
reroll: <RefreshCw className="w-8 h-8" />,
wild_card: <Shuffle className="w-8 h-8" />,
lucky_dice: <Dice5 className="w-8 h-8" />,
copycat: <Copy className="w-8 h-8" />,
undo: <Undo2 className="w-8 h-8" />,
}
interface InventoryItemCardProps {

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">

View File

@@ -6,8 +6,8 @@ import { useConfirm } from '@/store/confirm'
import { GlassCard, NeonButton, FramePreview } from '@/components/ui'
import {
Loader2, Coins, ShoppingBag, Package, Sparkles,
Frame, Type, Palette, Image, Zap, Shield, RefreshCw, SkipForward,
Minus, Plus
Frame, Type, Palette, Image, Zap, SkipForward,
Minus, Plus, Shuffle, Dice5, Copy, Undo2
} from 'lucide-react'
import type { ShopItem, ShopItemType, ShopItemPublic } from '@/types'
import { RARITY_COLORS, RARITY_NAMES } from '@/types'
@@ -23,9 +23,11 @@ const ITEM_TYPE_ICONS: Record<ShopItemType, React.ReactNode> = {
const CONSUMABLE_ICONS: Record<string, React.ReactNode> = {
skip: <SkipForward className="w-8 h-8" />,
shield: <Shield className="w-8 h-8" />,
boost: <Zap className="w-8 h-8" />,
reroll: <RefreshCw className="w-8 h-8" />,
wild_card: <Shuffle className="w-8 h-8" />,
lucky_dice: <Dice5 className="w-8 h-8" />,
copycat: <Copy className="w-8 h-8" />,
undo: <Undo2 className="w-8 h-8" />,
}
interface ShopItemCardProps {
@@ -176,7 +178,7 @@ function ShopItemCard({ item, onPurchase, isPurchasing }: ShopItemCardProps) {
className={clsx(
'p-4 border transition-all duration-300',
rarityColors.border,
item.is_owned && 'opacity-60'
item.is_owned && !isConsumable && 'opacity-60'
)}
>
{/* Rarity badge */}
@@ -196,7 +198,7 @@ function ShopItemCard({ item, onPurchase, isPurchasing }: ShopItemCardProps) {
</p>
{/* Quantity selector for consumables */}
{isConsumable && !item.is_owned && item.is_available && (
{isConsumable && item.is_available && (
<div className="flex items-center justify-center gap-2 mb-3">
<button
onClick={decrementQuantity}
@@ -236,7 +238,7 @@ function ShopItemCard({ item, onPurchase, isPurchasing }: ShopItemCardProps) {
) : (
<NeonButton
size="sm"
onClick={() => onPurchase(item, quantity)}
onClick={() => onPurchase(item, isConsumable ? quantity : 1)}
disabled={isPurchasing || !item.is_available}
>
{isPurchasing ? (
@@ -460,9 +462,9 @@ export function ShopPage() {
</h3>
<ul className="text-gray-400 text-sm space-y-1">
<li> Выполняй задания в <span className="text-neon-400">сертифицированных</span> марафонах</li>
<li> Easy задание 5 монет, Medium 12 монет, Hard 25 монет</li>
<li> Playthrough ~5% от заработанных очков</li>
<li> Топ-3 места в марафоне: 1-е 100, 2-е 50, 3-е 30 монет</li>
<li> Easy задание 10 монет, Medium 20 монет, Hard 35 монет</li>
<li> Playthrough ~10% от заработанных очков</li>
<li> Топ-3 места в марафоне: 1-е 500, 2-е 250, 3-е 150 монет</li>
</ul>
</GlassCard>
</div>

View File

@@ -718,7 +718,7 @@ export interface PasswordChangeData {
export type ShopItemType = 'frame' | 'title' | 'name_color' | 'background' | 'consumable'
export type ItemRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
export type ConsumableType = 'skip' | 'shield' | 'boost' | 'reroll'
export type ConsumableType = 'skip' | 'boost' | 'wild_card' | 'lucky_dice' | 'copycat' | 'undo'
export interface ShopItemPublic {
id: number
@@ -776,6 +776,8 @@ export interface UseConsumableRequest {
item_code: ConsumableType
marathon_id: number
assignment_id?: number
game_id?: number // Required for wild_card
target_participant_id?: number // Required for copycat
}
export interface UseConsumableResponse {
@@ -805,12 +807,16 @@ export interface ConsumablesStatus {
skips_available: number
skips_used: number
skips_remaining: number | null
shields_available: number
has_shield: boolean
boosts_available: number
has_active_boost: boolean
boost_multiplier: number | null
rerolls_available: number
wild_cards_available: number
lucky_dice_available: number
has_lucky_dice: boolean
lucky_dice_multiplier: number | null
copycats_available: number
undos_available: number
can_undo: boolean
}
export interface UserCosmetics {