Live Drops
O sistema de Live Drops exibe em tempo real todos os itens dropados no site, criando engajamento e FOMO (Fear of Missing Out).
Visão Geral
Por que Delay de 12 segundos?
O drop só é emitido após a animação da roleta por dois motivos:
- Experiência do Usuário: Evita spoiler do resultado
- Sincronização: Todos veem o drop quando o jogador vê
typescript
// Frontend - Após animação
const handleCaseOpen = async (caseId: string) => {
// 1. Chamar API
const result = await api.post(`/cases/${caseId}/open`);
// 2. Iniciar animação
startRouletteAnimation(result.data);
// 3. Após animação (12s), emitir live drop
setTimeout(() => {
emitLiveDrop(result.data.openingId);
}, 12000);
};Implementação
Endpoint de Emissão
typescript
// live-drops.controller.ts
@Controller('live-drops')
export class LiveDropsController {
constructor(
private webSocketGateway: WebSocketGatewayService,
private caseOpeningRepository: CaseOpeningRepository,
private redisService: RedisService,
) {}
@Post('emit')
@UseGuards(AuthGuard)
async emitDrop(
@CurrentUser() user: User,
@Body() dto: EmitLiveDropDto,
): Promise<void> {
// 1. Verificar se opening pertence ao usuário
const opening = await this.caseOpeningRepository.findById(dto.openingId);
if (!opening || opening.userId !== user.id) {
throw new ForbiddenException();
}
// 2. Verificar se já foi emitido
const emittedKey = `live-drop:emitted:${dto.openingId}`;
const alreadyEmitted = await this.redisService.get(emittedKey);
if (alreadyEmitted) {
return; // Já foi emitido, ignorar
}
// 3. Marcar como emitido (TTL de 1 hora)
await this.redisService.set(emittedKey, '1', 3600);
// 4. Buscar dados completos
const dropData = await this.buildDropData(opening);
// 5. Emitir via WebSocket
this.webSocketGateway.emitLiveDrop(dropData);
// 6. Salvar no histórico recente
await this.saveToRecentDrops(dropData);
}
private async buildDropData(opening: CaseOpening): Promise<LiveDropData> {
return {
id: opening.id.toString(),
itemName: opening.item.name,
itemImageUrl: opening.item.imageUrl,
rarity: opening.item.rarity,
valueCents: Number(opening.item.valueCents),
userName: opening.user.username,
userAvatar: opening.user.avatarUrl,
caseId: opening.caseId.toString(),
caseName: opening.case.name,
isFlip: opening.isFlip,
};
}
private async saveToRecentDrops(drop: LiveDropData): Promise<void> {
const key = 'live-drops:recent';
// Adicionar ao início da lista
await this.redisService.lpush(key, JSON.stringify(drop));
// Manter apenas os últimos 50
await this.redisService.ltrim(key, 0, 49);
}
}Endpoint de Histórico Recente
typescript
// live-drops.controller.ts (continuação)
@Get('recent')
@Public() // Endpoint público
async getRecentDrops(
@Query('limit') limit: number = 20,
): Promise<LiveDropData[]> {
const key = 'live-drops:recent';
// Buscar do Redis
const drops = await this.redisService.lrange(key, 0, limit - 1);
return drops.map((d) => JSON.parse(d));
}Frontend
Context de Live Drops
tsx
// contexts/LiveDropsContext.tsx
import { createContext, useContext, useState, useEffect } from 'react';
import { useWebSocket, useSocketEvent } from './WebSocketContext';
interface LiveDrop {
id: string;
itemName: string;
itemImageUrl: string;
rarity: string;
valueCents: number;
userName: string;
userAvatar: string;
caseName: string;
isFlip: boolean;
timestamp: string;
}
interface LiveDropsContextValue {
drops: LiveDrop[];
isLoading: boolean;
}
const LiveDropsContext = createContext<LiveDropsContextValue | null>(null);
export function LiveDropsProvider({ children }: { children: React.ReactNode }) {
const [drops, setDrops] = useState<LiveDrop[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Carregar drops recentes na inicialização
useEffect(() => {
async function loadRecent() {
try {
const response = await fetch('/api/live-drops/recent');
const data = await response.json();
setDrops(data);
} catch (error) {
console.error('Failed to load recent drops:', error);
} finally {
setIsLoading(false);
}
}
loadRecent();
}, []);
// Escutar novos drops via WebSocket
useSocketEvent<LiveDrop>('live:drop', (drop) => {
setDrops((prev) => {
// Adicionar no início, manter máximo de 50
const updated = [drop, ...prev];
return updated.slice(0, 50);
});
});
return (
<LiveDropsContext.Provider value={{ drops, isLoading }}>
{children}
</LiveDropsContext.Provider>
);
}
export function useLiveDrops() {
const context = useContext(LiveDropsContext);
if (!context) {
throw new Error('useLiveDrops must be used within LiveDropsProvider');
}
return context;
}Componente de Feed
tsx
// components/LiveDropsFeed.tsx
import { useLiveDrops } from '../contexts/LiveDropsContext';
import { motion, AnimatePresence } from 'framer-motion';
export function LiveDropsFeed() {
const { drops, isLoading } = useLiveDrops();
if (isLoading) {
return <LiveDropsSkeleton />;
}
return (
<div className="live-drops-feed">
<h3 className="flex items-center gap-2">
<span className="live-indicator animate-pulse" />
Live Drops
</h3>
<div className="drops-container overflow-hidden">
<AnimatePresence mode="popLayout">
{drops.map((drop) => (
<motion.div
key={drop.id}
layout
initial={{ opacity: 0, x: -20, scale: 0.8 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.3 }}
>
<DropCard drop={drop} />
</motion.div>
))}
</AnimatePresence>
</div>
</div>
);
}
function DropCard({ drop }: { drop: LiveDrop }) {
const rarityColor = getRarityColor(drop.rarity);
return (
<div
className="drop-card"
style={{ borderColor: rarityColor }}
>
{/* Avatar do usuário */}
<img
src={drop.userAvatar}
alt={drop.userName}
className="user-avatar"
/>
{/* Imagem do item */}
<div className="item-image-container">
<img src={drop.itemImageUrl} alt={drop.itemName} />
{drop.isFlip && (
<span className="flip-badge">FLIP!</span>
)}
</div>
{/* Informações */}
<div className="drop-info">
<span className="user-name">{drop.userName}</span>
<span className="item-name">{drop.itemName}</span>
<span className="item-value">
R$ {(drop.valueCents / 100).toFixed(2)}
</span>
<span className="case-name">{drop.caseName}</span>
</div>
</div>
);
}Ticker Horizontal
tsx
// components/LiveDropsTicker.tsx
import { useLiveDrops } from '../contexts/LiveDropsContext';
import { motion } from 'framer-motion';
export function LiveDropsTicker() {
const { drops } = useLiveDrops();
return (
<div className="live-drops-ticker overflow-hidden whitespace-nowrap">
<motion.div
className="ticker-track inline-flex gap-4"
animate={{ x: [0, -1000] }}
transition={{
duration: 30,
repeat: Infinity,
ease: 'linear',
}}
>
{/* Duplicar para loop contínuo */}
{[...drops, ...drops].map((drop, index) => (
<div
key={`${drop.id}-${index}`}
className="ticker-item flex items-center gap-2 px-4"
>
<img
src={drop.itemImageUrl}
alt={drop.itemName}
className="w-8 h-8 object-contain"
/>
<span className="font-medium">{drop.userName}</span>
<span className="text-muted">ganhou</span>
<span style={{ color: getRarityColor(drop.rarity) }}>
{drop.itemName}
</span>
</div>
))}
</motion.div>
</div>
);
}Filtragem e Destaque
Filtrar por Valor
typescript
// Apenas drops acima de R$ 50
const significantDrops = drops.filter((d) => d.valueCents >= 5000);Destacar Drops Raros
tsx
function DropCard({ drop }: { drop: LiveDrop }) {
const isRare = ['COVERT', 'EXTRAORDINARY'].includes(drop.rarity);
return (
<motion.div
className={cn(
'drop-card',
isRare && 'drop-card-rare',
)}
animate={isRare ? {
boxShadow: [
'0 0 0 rgba(255, 215, 0, 0)',
'0 0 20px rgba(255, 215, 0, 0.5)',
'0 0 0 rgba(255, 215, 0, 0)',
],
} : undefined}
transition={{ duration: 1, repeat: Infinity }}
>
{/* ... */}
</motion.div>
);
}Estatísticas
Drops por Hora
typescript
// live-drops.service.ts
async getDropStats(): Promise<DropStats> {
const oneHourAgo = Date.now() - 3600000;
// Contar drops da última hora
const recentDrops = await this.redisService.lrange('live-drops:recent', 0, -1);
const dropsLastHour = recentDrops.filter((d) => {
const drop = JSON.parse(d);
return new Date(drop.timestamp).getTime() > oneHourAgo;
});
// Calcular valor total
const totalValue = dropsLastHour.reduce((sum, d) => {
return sum + JSON.parse(d).valueCents;
}, 0);
// Encontrar maior drop
const biggestDrop = dropsLastHour.reduce((max, d) => {
const drop = JSON.parse(d);
return drop.valueCents > max.valueCents ? drop : max;
}, { valueCents: 0 });
return {
dropsLastHour: dropsLastHour.length,
totalValueCents: totalValue,
biggestDrop,
dropsPerMinute: dropsLastHour.length / 60,
};
}Configurações
typescript
// live-drops.config.ts
export const LIVE_DROPS_CONFIG = {
// Número máximo de drops no histórico
maxRecentDrops: 50,
// TTL do cache de drops emitidos (previne duplicatas)
emittedTtlSeconds: 3600,
// Delay mínimo após animação
emitDelayMs: 12000,
// Valor mínimo para aparecer no feed (0 = todos)
minValueCents: 0,
// Filtrar itens de batalhas? (evita spam)
includeBattleDrops: true,
};Troubleshooting
Drop Não Aparece
typescript
// 1. Verificar se foi emitido
const emitted = await redis.get(`live-drop:emitted:${openingId}`);
console.log('Emitted:', !!emitted);
// 2. Verificar WebSocket connections
const connections = await wsGateway.getConnectionsCount();
console.log('WS Connections:', connections);
// 3. Verificar histórico recente
const recent = await redis.lrange('live-drops:recent', 0, 9);
console.log('Recent drops:', recent.length);Drops Duplicados
typescript
// Verificar se a chave de emissão existe
const key = `live-drop:emitted:${openingId}`;
const exists = await redis.exists(key);
if (exists) {
console.log('Drop já foi emitido, ignorando duplicata');
return;
}Delay Incorreto
typescript
// Frontend - Garantir delay correto
const ANIMATION_DURATION = 10000; // 10s roleta
const BUFFER = 2000; // 2s buffer
const EMIT_DELAY = ANIMATION_DURATION + BUFFER;
setTimeout(() => {
emitLiveDrop(openingId);
}, EMIT_DELAY);