import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react'; import { BuffType, type ActiveBuff, type BuffChargeState } from '../game/types'; import { BUFF_COOLDOWN_MS, buffMaxChargesForHero } from '../network/buffMap'; import { BUFF_VISUAL, buffUiStrings } from './buffMeta'; import { purchaseBuffRefill } from '../network/api'; import type { HeroResponse } from '../network/api'; import { getTelegramUserId } from '../shared/telegram'; import { useT, t } from '../i18n'; // ---- Types ---- interface BuffBarProps { buffs: ActiveBuff[]; /** Client-side cooldown end timestamps (ms); backend does not persist these yet. */ cooldownEndsAt: Partial>; /** Per-buff charge quotas from the server. */ buffCharges: Partial>; /** When true, UI max charge labels use subscriber caps (×2). */ subscriptionActive?: boolean; /** When true (e.g. hero dead), buff taps do nothing and refill flow is blocked. */ buffsLocked?: boolean; nowMs: number; onActivate: (type: BuffType) => void; /** Called when a buff refill purchase returns an updated hero */ onHeroUpdated?: (hero: HeroResponse) => void; } interface BuffButtonProps { buff: ActiveBuff; charge: BuffChargeState | undefined; maxCharges: number; onActivate: () => void; onRefill?: (type: BuffType) => void; nowMs: number; buffsLocked?: boolean; } // ---- Tooltip ---- const LONG_PRESS_MS = 400; const TOOLTIP_AUTO_HIDE_MS = 2500; /** When false, the green refill CTA in the hover tooltip is hidden (purchase still reachable from tap flow if any). */ const SHOW_BUFF_REFILL_BUTTON_IN_TOOLTIP = false; const tooltipStyle: CSSProperties = { position: 'absolute', bottom: '110%', left: '50%', transform: 'translateX(-50%)', padding: '6px 10px', borderRadius: 8, backgroundColor: 'rgba(10, 10, 20, 0.92)', border: '1px solid rgba(255,255,255,0.15)', color: '#eee', fontSize: 11, fontWeight: 600, whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 100, textAlign: 'center', lineHeight: 1.4, boxShadow: '0 4px 12px rgba(0,0,0,0.5)', }; const tooltipArrow: CSSProperties = { position: 'absolute', bottom: -5, left: '50%', transform: 'translateX(-50%)', width: 0, height: 0, borderLeft: '5px solid transparent', borderRight: '5px solid transparent', borderTop: '5px solid rgba(10, 10, 20, 0.92)', }; // ---- Charge Badge ---- const chargeBadgeBase: CSSProperties = { position: 'absolute', top: -4, right: -4, minWidth: 16, height: 16, borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 9, fontWeight: 700, lineHeight: 1, padding: '0 3px', zIndex: 10, pointerEvents: 'none', border: '1px solid rgba(0,0,0,0.4)', }; // ---- Radial Cooldown SVG ---- function RadialCooldown({ progress }: { progress: number }) { if (progress <= 0) return null; const radius = 22; const cx = 25; const cy = 25; const circumference = 2 * Math.PI * radius; const offset = circumference * (1 - progress); return ( ); } // ---- Helpers ---- /** Format an ISO timestamp as HH:MM in local time. */ function formatTimeHHMM(iso: string): string { const d = new Date(iso); if (isNaN(d.getTime())) return '??:??'; return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } // ---- Buff Button ---- const buttonBase: CSSProperties = { position: 'relative', border: '2px solid rgba(255,255,255,0.2)', backgroundColor: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', cursor: 'pointer', userSelect: 'none', WebkitTapHighlightColor: 'transparent', }; function BuffButton({ buff, charge, maxCharges, onActivate, onRefill, nowMs, buffsLocked }: BuffButtonProps) { const tr = useT(); const visual = BUFF_VISUAL[buff.type]; const { label, desc } = buffUiStrings(tr, buff.type); const meta = { ...visual, label, desc }; const [pressed, setPressed] = useState(false); const [showTooltip, setShowTooltip] = useState(false); const [showRefillConfirm, setShowRefillConfirm] = useState(false); const longPressTimer = useRef | null>(null); const autoHideTimer = useRef | null>(null); const didLongPress = useRef(false); const remainingEffectMs = buff.expiresAtMs != null ? Math.max(0, buff.expiresAtMs - nowMs) : buff.remainingMs; const isActive = remainingEffectMs > 0; const isOnCooldown = buff.cooldownRemainingMs > 0; const cooldownProgress = buff.cooldownMs > 0 ? buff.cooldownRemainingMs / buff.cooldownMs : 0; const cdSec = Math.ceil((buff.cooldownMs || 0) / 1000); // Charge state const remaining = charge?.remaining; const hasChargeData = remaining != null; const isOutOfCharges = hasChargeData && remaining === 0; const isDisabled = isOnCooldown || (isOutOfCharges && !isActive) || !!buffsLocked; useEffect(() => { return () => { if (longPressTimer.current) clearTimeout(longPressTimer.current); if (autoHideTimer.current) clearTimeout(autoHideTimer.current); }; }, []); useEffect(() => { if (buffsLocked) { setShowTooltip(false); setShowRefillConfirm(false); } }, [buffsLocked]); const openTooltip = (): void => { setShowTooltip(true); if (autoHideTimer.current) clearTimeout(autoHideTimer.current); autoHideTimer.current = setTimeout(() => setShowTooltip(false), TOOLTIP_AUTO_HIDE_MS); }; const closeTooltip = (): void => { setShowTooltip(false); if (autoHideTimer.current) clearTimeout(autoHideTimer.current); }; const handleTouchStart = (): void => { if (buffsLocked) return; didLongPress.current = false; longPressTimer.current = setTimeout(() => { didLongPress.current = true; openTooltip(); }, LONG_PRESS_MS); }; const handleTouchEnd = (): void => { if (longPressTimer.current) { clearTimeout(longPressTimer.current); longPressTimer.current = null; } }; const handleClick = (): void => { if (buffsLocked) return; if (didLongPress.current) { didLongPress.current = false; return; } setPressed(true); window.setTimeout(() => setPressed(false), 140); // When out of charges, show refill confirmation instead of doing nothing if (isOutOfCharges && !isOnCooldown && !isActive) { setShowRefillConfirm(true); return; } if (isDisabled) return; onActivate(); }; const dimmedFaceOpacity = buffsLocked ? 0.4 : isDisabled ? (isOutOfCharges && !isOnCooldown ? 0.3 : 0.55) : 1; const hitStyle: CSSProperties = { position: 'relative', width: 44, height: 50, padding: 0, border: 'none', background: 'transparent', pointerEvents: buffsLocked ? 'none' : 'auto', cursor: buffsLocked || isDisabled ? 'not-allowed' : 'pointer', transform: pressed ? 'scale(0.94)' : 'scale(1)', transition: 'transform 80ms ease', }; const activatorFaceStyle: CSSProperties = { ...buttonBase, position: 'absolute', inset: 0, width: '100%', height: '100%', borderRadius: 10, borderColor: isActive ? meta.color : pressed ? '#fff' : 'rgba(255,255,255,0.2)', boxShadow: isActive ? `0 0 12px ${meta.color}` : pressed ? `0 0 10px rgba(255,255,255,0.5), inset 0 0 12px ${meta.color}66` : 'none', opacity: dimmedFaceOpacity, transition: 'opacity 150ms ease, box-shadow 80ms ease, border-color 80ms ease', pointerEvents: 'none', }; return ( )}
)} {/* Refill confirmation popup */} {showRefillConfirm && (
{t(tr.refillQuestion, { label: meta.label })}
{buff.type === BuffType.Resurrection ? '150\u20BD' : '50\u20BD'}
)} ); } // ---- Buff Bar ---- const barStyle: CSSProperties = { display: 'flex', gap: 4, justifyContent: 'center', flexWrap: 'wrap', padding: '6px 0', }; /** All buff types that should always be visible as buttons */ const ALL_BUFF_TYPES: BuffType[] = [ BuffType.Heal, BuffType.Rage, BuffType.Shield, BuffType.Rush, BuffType.PowerPotion, BuffType.WarCry, BuffType.Luck, ]; function getBuffEntry( type: BuffType, activeBuffs: ActiveBuff[], cooldownEndsAt: Partial>, nowMs: number, ): ActiveBuff { const active = activeBuffs.find((b) => b.type === type); const cdEnd = cooldownEndsAt[type]; const cdRem = cdEnd != null ? Math.max(0, cdEnd - nowMs) : 0; const cdFull = active?.cooldownMs || BUFF_COOLDOWN_MS[type]; if (active) { const exp = active.expiresAtMs; const remainingMs = exp != null ? Math.max(0, exp - nowMs) : active.remainingMs; return { ...active, remainingMs, cooldownMs: active.cooldownMs || cdFull, cooldownRemainingMs: cdRem, }; } return { type, remainingMs: 0, durationMs: 0, cooldownMs: cdFull, cooldownRemainingMs: cdRem, }; } export function BuffBar({ buffs, cooldownEndsAt, buffCharges, subscriptionActive, buffsLocked, nowMs, onActivate, onHeroUpdated, }: BuffBarProps) { const handleActivate = useCallback( (type: BuffType) => () => onActivate(type), [onActivate], ); const handleRefill = useCallback( (type: BuffType) => { const telegramId = getTelegramUserId() ?? undefined; purchaseBuffRefill(type, telegramId) .then((hero) => { onHeroUpdated?.(hero); }) .catch((err) => { console.warn('[BuffBar] purchaseBuffRefill failed:', err); }); }, [onHeroUpdated], ); return (
{ALL_BUFF_TYPES.map((type) => { const buff = getBuffEntry(type, buffs, cooldownEndsAt, nowMs); const charge = buffCharges[type]; const maxCharges = buffMaxChargesForHero(type, subscriptionActive); return ( ); })}
); }