Files
trumpsignal-frontend/components/telegram/TelegramCard.tsx
T
k d72323b1c6 Production polish: i18n shelved, PWA icons, SEO/GEO cleanup, UX fixes
Big-picture changes since 01be8e7:

New routes — /trump /btc /kol /trades /analytics /archive plus four
SEO landing pages (/methodology /glossary /case-studies /contact),
all served under /[locale]. Each has dedicated metadata + JSON-LD.

KOL page (new) — DigestWidget + OnchainWidget + TalkVsTradesWidget,
filter by handle/ticker, click-through to per-post detail with the
original AI-extracted ticker/conviction/quote.

BTC page (new) — tabbed: macro-bottom (AHR999 + 200WMA + Pi Cycle)
and funding-rate reversal, with live sparkline + threshold bands.

Telegram card on Settings — wallet-link code generation, status,
disconnect. Preferences moved into the bot itself (/trump /btc etc.)
so the card stays minimal.

SignalMonitor (new) — ETH/LINK Bollinger breakout monitor in its own
component, shares the singleton WsProvider so no second WS opens.

WS singleton refactor (lib/wsContext) — shared WsProvider + useWsSubscribe
hook. Cleanup now actively closes the socket on unmount; previously the
local `ws` couldn't be reached from cleanup and leaked one connection
per StrictMode remount.

OpenPositions polling no longer pops MetaMask in the background —
splits into load('first') for user-initiated and load('poll') that
uses getCachedViewEnvelope without signing.

i18n shelved — proxy.ts (Next 16 middleware rename) wires next-intl
but only Navbar + layout footer have translations. Rest of UI used
isZh ternary scattered across 28 files. All `const isZh = locale === 'zh'`
flipped to `const isZh = false` so every site renders English; Chinese
branches kept as dead code so revival is one regex away.
LanguageSwitch hidden but file kept. zh-CN hreflang removed from
metadata + sitemap to avoid duplicate-content penalties.

Wallet error handling — lib/walletError.ts: isUserRejection walks EIP-1193
code 4001 + .cause chain; previous string-match for "reject"/"denied"
broke for users running MetaMask in non-English UIs. 12 call sites
migrated across 5 components.

PWA icons — app/icon.tsx + app/apple-icon.tsx render the brand "α" via
Next's ImageResponse so no static PNG asset is required. manifest.ts
references /icon and /apple-icon dynamic routes.

OG image + sitemap + robots — dynamic 1200×630 OG card; robots blocks
both /en/settings and /zh/settings; sitemap only emits /en routes;
JSON-LD covers SoftwareApplication + Organization + 10-Q FAQPage.

Landing page polish — Launch Dashboard button stripped of magnetic
hover + shimmer + lift + glow expansion (multiple users found it busy);
hero scramble alphabet swapped to alphanumeric (was block characters);
"15 KOL feeds" copy updated to 19 in 5 places.

PostCards source icons — new entries for btc_bottom_reversal,
funding_reversal, kol_divergence so they no longer fall through to
the generic "first letter" fallback.

Archive page filter — excludes funding_reversal + kol_divergence
(previously only excluded truth + btc_bottom_reversal so new live
signals leaked into the legacy archive).

Cache + skeleton loading — lib/cache.ts SWR module with per-key TTL,
applied across the major pages so navigation feels instant.

SignConfirmSheet — 4 hardcoded Chinese strings translated to English
(English users were seeing zh-only "需要钱包签名" etc).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 00:53:27 +08:00

278 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useLocale } from 'next-intl'
import { useAccount, useSignMessage } from 'wagmi'
import {
getTelegramStatus, tgInit, tgUnbind,
type TelegramStatus,
} from '@/lib/api'
import { signRequest } from '@/lib/signedRequest'
import { walletErrorLabel } from '@/lib/walletError'
import { confirmSign } from '@/components/wallet/SignConfirmSheet'
/**
* Settings → Telegram alerts card.
*
* Three states:
* 1. Server not configured → show "ask the admin to set up the bot"
* 2. Not bound → deep-link to bot + optional wallet-link code
* 3. Bound → status line + link to bot for preferences
*
* Preferences are managed entirely inside the bot (/trump /btc /funding /kol
* /conf /quiet). This card is just discovery + wallet-linking for Pro users.
*/
export default function TelegramCard() {
const locale = useLocale()
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const { address, isConnected } = useAccount()
const { signMessageAsync } = useSignMessage()
const [mounted, setMounted] = useState(false)
const [status, setStatus] = useState<TelegramStatus | null>(null)
const [loading, setLoading] = useState(true)
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const [code, setCode] = useState<{ code: string; deep_link: string } | null>(null)
useEffect(() => { setMounted(true) }, [])
const refresh = useCallback(async () => {
if (!address) return
setLoading(true)
try {
const s = await getTelegramStatus(address.toLowerCase())
setStatus(s); setErr('')
} catch (e) {
setErr(e instanceof Error ? e.message : 'load failed')
} finally { setLoading(false) }
}, [address])
useEffect(() => { refresh() }, [refresh])
// Poll while waiting for wallet-link to complete.
useEffect(() => {
if (!code || status?.bound) return
const id = setInterval(refresh, 3000)
return () => clearInterval(id)
}, [code, status?.bound, refresh])
const botUsername = status?.bot_username ?? 'TrumpAlpha_bot'
const botLink = `https://t.me/${botUsername}`
async function handleLinkWallet() {
if (!address) return
setBusy(true); setErr('')
try {
const confirmed = await confirmSign({
label: 'Generate Telegram link code',
description: 'A one-time 6-char code valid for 10 min. Paste it into the bot to link your wallet for Pro features.',
})
if (!confirmed) { setBusy(false); return }
const env = await signRequest({
action: 'telegram_init', wallet: address, body: null, signMessageAsync,
})
const r = await tgInit(address.toLowerCase(), env)
setCode({ code: r.code, deep_link: r.deep_link })
} catch (e) {
setErr(walletErrorLabel(e, 'Cancelled'))
} finally { setBusy(false) }
}
async function handleUnbind() {
if (!address) return
setBusy(true); setErr('')
try {
const confirmed = await confirmSign({
label: 'Disconnect Telegram',
description: 'Unlinks this wallet from Telegram. Your free subscription stays active in the bot.',
})
if (!confirmed) { setBusy(false); return }
const env = await signRequest({
action: 'telegram_unbind', wallet: address, body: null, signMessageAsync,
})
await tgUnbind(address.toLowerCase(), env)
setCode(null)
await refresh()
} catch (e) {
setErr(walletErrorLabel(e, 'Cancelled'))
} finally { setBusy(false) }
}
// ── Render ───────────────────────────────────────────────────────────────
if (!mounted) return null
if (!isConnected) return null
return (
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between',
alignItems: 'center', marginBottom: 12, gap: 12, flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.06em',
textTransform: 'uppercase', color: 'var(--ink-3)' }}>
{isZh ? 'Telegram 提醒' : 'Telegram alerts'}
</div>
<div style={{ fontSize: 13, color: 'var(--ink-2)', marginTop: 2 }}>
{isZh ? '高置信度信号触发时发送推送提醒' : 'Push notifications when high-conviction signals fire'}
</div>
</div>
{status?.bound && status.alerts_enabled && (
<span style={{ fontSize: 11, color: 'var(--up)', fontWeight: 600,
padding: '4px 10px', borderRadius: 6,
background: 'var(--up-soft)' }}>
{isZh ? '已开启' : 'Active'}
</span>
)}
{status?.bound && !status.alerts_enabled && (
<span style={{ fontSize: 11, color: 'var(--ink-4)', fontWeight: 600,
padding: '4px 10px', borderRadius: 6,
background: 'var(--bg-sunk)' }}>
{isZh ? '已暂停' : 'Paused'}
</span>
)}
</div>
{loading && (
<div className="skeleton sk-line sk-w-full" style={{ marginBottom: 8 }} />
)}
{/* Server not configured */}
{!loading && status && !status.configured && (
<div style={{ padding: 12, fontSize: 12, color: 'var(--ink-3)',
background: 'var(--bg-sunk)', borderRadius: 6 }}>
{isZh ? '当前服务器还没有配置 Telegram 提醒。请让管理员设置' : 'Telegram alerts are not configured on this server. Ask the operator to set'}
<code style={{ margin: '0 4px' }}>TELEGRAM_BOT_TOKEN</code> and
<code style={{ margin: '0 4px' }}>TELEGRAM_BOT_USERNAME</code>.
</div>
)}
{/* Configured — main body */}
{!loading && status?.configured && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* Bot link row — always visible */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10,
padding: '10px 14px', borderRadius: 8,
background: 'var(--bg-sunk)', border: '1px solid var(--line)' }}>
<span style={{ fontSize: 18 }}></span>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>
<a href={botLink} target="_blank" rel="noopener noreferrer"
style={{ color: 'var(--amber, #f59e0b)', textDecoration: 'none' }}>
@{botUsername}
</a>
</div>
<div style={{ fontSize: 11, color: 'var(--ink-4)', marginTop: 2 }}>
{status.bound
? (isZh ? '打开机器人调整偏好(/trump /btc /funding /kol /conf /quiet' : 'Open the bot to adjust preferences (/trump /btc /funding /kol /conf /quiet)')
: (isZh ? '打开机器人并发送 /start,无需额外账号' : 'Open the bot and send /start — no account needed')}
</div>
</div>
</div>
{/* Bound: wallet status + disconnect */}
{status.bound && (
<div style={{ fontSize: 12, color: 'var(--ink-3)',
display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>
{status.tg_username
? <>{isZh ? '已绑定账号' : 'Linked as'} <strong style={{ color: 'var(--ink)' }}>@{status.tg_username}</strong></>
: <>{isZh ? '聊天 ID' : 'Chat'} <strong style={{ color: 'var(--ink)' }}>#{status.chat_id}</strong></>}
</span>
{status.wallet_address ? (
<>
<span style={{ color: 'var(--ink-5)' }}>·</span>
<span style={{ color: 'var(--up)', fontSize: 11 }}>Pro</span>
<span style={{ color: 'var(--ink-5)' }}>·</span>
<span>{isZh ? `已发送 ${status.total_alerts_sent ?? 0} 条提醒` : `${status.total_alerts_sent ?? 0} alerts sent`}</span>
<span style={{ color: 'var(--ink-5)' }}>·</span>
<button className="btn ghost" disabled={busy} onClick={handleUnbind}
style={{ fontSize: 11, color: 'var(--down)', padding: '2px 8px' }}>
{isZh ? '断开钱包绑定' : 'Disconnect wallet'}
</button>
</>
) : (
<>
<span style={{ color: 'var(--ink-5)' }}>·</span>
<span>{isZh ? `已发送 ${status.total_alerts_sent ?? 0} 条提醒` : `${status.total_alerts_sent ?? 0} alerts sent`}</span>
</>
)}
</div>
)}
{/* Not bound: optional wallet-link for Pro */}
{!status.bound && !code && (
<div style={{ fontSize: 12, color: 'var(--ink-4)' }}>
{isZh ? '已经在机器人里了?' : 'Already in the bot?'}{' '}
<button onClick={handleLinkWallet} disabled={busy}
style={{ background: 'none', border: 'none', cursor: 'pointer',
color: 'var(--amber, #f59e0b)', fontSize: 12, padding: 0,
textDecoration: 'underline' }}>
{isZh ? '绑定这个钱包以启用 Pro 功能' : 'Link this wallet for Pro features'}
</button>
</div>
)}
{/* Wallet-link code flow */}
{!status.bound && code && (
<WalletLinkPanel code={code.code} link={code.deep_link}
onCancel={() => setCode(null)} isZh={isZh} />
)}
</div>
)}
{err && (
<div style={{ fontSize: 11, color: 'var(--down)', marginTop: 8 }}> {err}</div>
)}
</div>
)
}
// ── Subcomponents ────────────────────────────────────────────────────────────
function WalletLinkPanel({ code, link, onCancel, isZh }: {
code: string; link: string; onCancel: () => void; isZh: boolean
}) {
const [copied, setCopied] = useState(false)
function copy() {
navigator.clipboard.writeText(code).then(() => {
setCopied(true); setTimeout(() => setCopied(false), 1500)
}).catch(() => {})
}
return (
<div style={{ padding: 14, background: 'var(--bg-sunk)',
borderRadius: 8, border: '1px solid var(--line)' }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>
{isZh ? '绑定钱包(Pro:' : 'Link wallet (Pro):'}
</div>
<ol style={{ paddingLeft: 18, fontSize: 12, color: 'var(--ink-2)',
lineHeight: 1.7, marginBottom: 12 }}>
<li>
{isZh ? '打开机器人:' : 'Open the bot:'}&nbsp;
<a href={link} target="_blank" rel="noopener noreferrer"
style={{ color: 'var(--amber, #f59e0b)', fontWeight: 600 }}>
{link.replace('https://', '')}
</a>
</li>
<li>
{isZh ? '发送' : 'Send'} <code style={{ background: 'var(--bg)', padding: '1px 6px',
borderRadius: 3 }}>/start {code}</code>
<button onClick={copy} style={{
marginLeft: 6, fontSize: 11, padding: '2px 8px', borderRadius: 4,
border: '1px solid var(--line)', background: 'var(--bg)',
cursor: 'pointer', color: 'var(--ink-3)',
}}>{copied ? (isZh ? '✓ 已复制' : '✓ copied') : (isZh ? '复制验证码' : 'copy code')}</button>
</li>
</ol>
<div style={{ fontSize: 11, color: 'var(--ink-4)', marginBottom: 10 }}>
{isZh ? '验证码 10 分钟内有效,绑定成功后这里会自动刷新。' : 'Code expires in 10 min. This panel updates automatically once linked.'}
</div>
<button onClick={onCancel} style={{
fontSize: 11, color: 'var(--ink-3)', background: 'none',
border: 'none', cursor: 'pointer', padding: 0,
}}>{isZh ? '取消' : 'Cancel'}</button>
</div>
)
}