Skip to content

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:

  1. Experiência do Usuário: Evita spoiler do resultado
  2. 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);

Documentação Técnica CSGOFlip