KOL count: 29 → 25 across marketing/SEO copy
Backend KOL_FEEDS trimmed from 29 to 25 (dead feeds removed).
Sync all hardcoded count mentions:
- layout.tsx JSON-LD, page.tsx (metric + comparison + copy)
- kol/page.tsx, KolPageClient.tsx ("and 26 more" → "and 22 more")
- glossary/page.tsx, opengraph-image.tsx
- public/llms.txt, llms-full.txt
- drop removed KOLs (Dragonfly Capital, Nic Carter) from named lists
Bundles other in-flight frontend work already in the working tree.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,9 +6,11 @@ import { useAccount, useConnect, useSignMessage } from 'wagmi'
|
||||
import {
|
||||
getUserPublic,
|
||||
getUser,
|
||||
getTelegramStatus,
|
||||
setUserSettings,
|
||||
setHlApiKey,
|
||||
setManualWindow,
|
||||
setAutoTrade,
|
||||
subscribe,
|
||||
type UserSettings,
|
||||
} from '@/lib/api'
|
||||
@@ -30,18 +32,27 @@ const DEFAULT_SETTINGS: UserSettings = {
|
||||
trump_enabled: false, macro_enabled: false,
|
||||
}
|
||||
|
||||
// The backend treats active_from/active_until as a DAILY RECURRING time window
|
||||
// (it extracts only .time() and compares with the current UTC time). Storing a
|
||||
// full datetime-local value was misleading — the date part was silently ignored
|
||||
// so users thought they were setting a date range when they were setting a
|
||||
// time-of-day schedule. We now use type="time" and store as a fixed-epoch ISO
|
||||
// so the backend's .time() extraction gives the right HH:MM:SS.
|
||||
function isoToLocalInput(iso: string | null): string {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d.getTime())) return ''
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
// Return just HH:MM for the time input
|
||||
return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`
|
||||
}
|
||||
function localInputToIso(v: string): string | null {
|
||||
if (!v) return null
|
||||
const d = new Date(v)
|
||||
if (isNaN(d.getTime())) return null
|
||||
return d.toISOString()
|
||||
// v is HH:MM from <input type="time">. Store as 2000-01-01T{HH:MM}:00Z so
|
||||
// the backend's .time() extraction returns the correct UTC time.
|
||||
const [hh, mm] = v.split(':')
|
||||
if (!hh || !mm) return null
|
||||
return `2000-01-01T${hh.padStart(2,'0')}:${mm.padStart(2,'0')}:00Z`
|
||||
}
|
||||
|
||||
export default function BotConfigPanel() {
|
||||
@@ -51,7 +62,7 @@ export default function BotConfigPanel() {
|
||||
const { signMessageAsync } = useSignMessage()
|
||||
const {
|
||||
isSubscribed, hlApiKeySet, hlApiKeyMasked,
|
||||
setSubscribed, setBotReadiness, setHlApiKeySet,
|
||||
setSubscribed, setBotReadiness, setHlApiKeySet, setPaperMode: setPaperModeStore,
|
||||
} = useDashboardStore()
|
||||
|
||||
const [settings, setSettings] = useState<UserSettings>(DEFAULT_SETTINGS)
|
||||
@@ -75,24 +86,66 @@ export default function BotConfigPanel() {
|
||||
const [mwErr, setMwErr] = useState('')
|
||||
const [mwTick, setMwTick] = useState(0)
|
||||
const [paperMode, setPaperMode] = useState(false)
|
||||
const [autoTrade, setAutoTrade_] = useState(false)
|
||||
const [atState, setAtState] = useState<'idle'|'signing'|'saving'|'err'>('idle')
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [loadState, setLoadState] = useState<'idle'|'loading'|'loaded'|'err'>('idle')
|
||||
const [loadErr, setLoadErr] = useState('')
|
||||
const [connectErr, setConnectErr] = useState('')
|
||||
// Telegram binding state. Macro Vibes (System-2) is manage-only via the
|
||||
// Telegram /adopt command, so a Macro-only user who hasn't bound Telegram
|
||||
// cannot actually hand any position to the bot — readiness must reflect that.
|
||||
// The unauthenticated status call returns only { bound } (no chat_id), which
|
||||
// is all we need here.
|
||||
const [tgBound, setTgBound] = useState<boolean | null>(null)
|
||||
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!address) { setTgBound(null); return }
|
||||
let cancelled = false
|
||||
getTelegramStatus(address.toLowerCase())
|
||||
.then(s => { if (!cancelled) setTgBound(!!s.bound) })
|
||||
.catch(() => { if (!cancelled) setTgBound(null) })
|
||||
return () => { cancelled = true }
|
||||
}, [address])
|
||||
|
||||
// B33: when the connected wallet changes, immediately wipe all private
|
||||
// settings so the previous wallet's config never leaks into the new one.
|
||||
useEffect(() => {
|
||||
setSettings(DEFAULT_SETTINGS)
|
||||
setPaperMode(false)
|
||||
setAutoTrade_(false)
|
||||
setTpConfigured(false)
|
||||
setSlConfigured(false)
|
||||
setUseBudget(false)
|
||||
setUseSchedule(false)
|
||||
setFromLocal('')
|
||||
setUntilLocal('')
|
||||
setManualUntil(null)
|
||||
setApiKey('')
|
||||
setDirty(false)
|
||||
setSaveState('idle'); setSaveErr('')
|
||||
setKeyState('idle'); setKeyErr('')
|
||||
setSubState('idle'); setSubErr('')
|
||||
setLoadState('idle'); setLoadErr('')
|
||||
setConnectErr('')
|
||||
}, [address])
|
||||
|
||||
function applyUserPayload(u: {
|
||||
active: boolean
|
||||
hl_api_key_set: boolean
|
||||
hl_api_key_masked: string | null
|
||||
paper_mode?: boolean
|
||||
auto_trade?: boolean
|
||||
settings: UserSettings
|
||||
manual_window_until?: string | null
|
||||
}) {
|
||||
setSubscribed(u.active)
|
||||
setPaperMode(!!u.paper_mode)
|
||||
setPaperModeStore(!!u.paper_mode) // sync to global store for B40/B41
|
||||
setAutoTrade_(!!u.auto_trade)
|
||||
setHlApiKeySet(u.hl_api_key_set, u.hl_api_key_masked ?? undefined)
|
||||
setManualUntil(u.manual_window_until ?? null)
|
||||
if (u.settings) {
|
||||
@@ -126,6 +179,7 @@ export default function BotConfigPanel() {
|
||||
setSubscribed(pub.active)
|
||||
setHlApiKeySet(pub.hl_api_key_set)
|
||||
setBotReadiness(pub.hl_api_key_set ? 'saved' : 'unknown')
|
||||
setAutoTrade_(!!pub.auto_trade)
|
||||
if (!pub.active) return
|
||||
const cached = getCachedViewEnvelope('view_user', address)
|
||||
if (!cached) return
|
||||
@@ -141,11 +195,8 @@ export default function BotConfigPanel() {
|
||||
if (!address) return
|
||||
setLoadErr(''); setLoadState('loading')
|
||||
try {
|
||||
const ok = await confirmSign({
|
||||
label: 'View account settings',
|
||||
description: 'Read your Trump Alpha subscription state, risk settings, and API key status. The signature is only for identity verification — no on-chain action is performed.',
|
||||
})
|
||||
if (!ok) { setLoadState('idle'); return }
|
||||
// Read-only identity check — go straight to MetaMask, no pre-confirm sheet.
|
||||
// getOrCreateViewEnvelope caches the result for 4 min so repeat visits are free.
|
||||
const env = await getOrCreateViewEnvelope({ action: 'view_user', wallet: address, signMessageAsync })
|
||||
const u = await getUser(address, env)
|
||||
applyUserPayload(u)
|
||||
@@ -161,9 +212,34 @@ export default function BotConfigPanel() {
|
||||
setSubscribed(pub.active)
|
||||
setHlApiKeySet(pub.hl_api_key_set)
|
||||
setBotReadiness(pub.hl_api_key_set ? 'saved' : 'unknown')
|
||||
setAutoTrade_(!!pub.auto_trade)
|
||||
return pub
|
||||
}
|
||||
|
||||
async function flipAutoTrade(on: boolean) {
|
||||
if (!address || atState !== 'idle') return
|
||||
const ok = await confirmSign({
|
||||
label: on ? 'Enable Auto-Trade' : 'Disable Auto-Trade',
|
||||
description: on
|
||||
? 'Bot will open real positions on Hyperliquid when qualifying Trump signals fire. Stop-loss and de-risking remain active.'
|
||||
: 'Bot stops opening new trades. Risk controls on existing positions keep running.',
|
||||
danger: on,
|
||||
})
|
||||
if (!ok) return
|
||||
setAtState('signing')
|
||||
try {
|
||||
const env = await signRequest({ action: 'set_auto_trade', wallet: address, body: { enabled: on }, signMessageAsync })
|
||||
setAtState('saving')
|
||||
const r = await setAutoTrade(env, on)
|
||||
setAutoTrade_(r.auto_trade)
|
||||
setAtState('idle')
|
||||
} catch (e: unknown) {
|
||||
console.error('set_auto_trade failed', e)
|
||||
setAtState('err')
|
||||
setTimeout(() => setAtState('idle'), 3000)
|
||||
}
|
||||
}
|
||||
|
||||
function updateSettings(patch: Partial<UserSettings>) {
|
||||
setSettings(s => ({ ...s, ...patch }))
|
||||
setDirty(true)
|
||||
@@ -202,7 +278,7 @@ export default function BotConfigPanel() {
|
||||
if (!address) return
|
||||
const ok = await confirmSign({
|
||||
label: 'Save trading settings',
|
||||
description: 'Settings are saved to the server and apply to all new trades from this point forward. Open positions are not affected.',
|
||||
description: 'Applied to all new trades going forward. Open positions are not affected.',
|
||||
})
|
||||
if (!ok) return
|
||||
setSaveErr(''); setSaveState('signing')
|
||||
@@ -213,7 +289,11 @@ export default function BotConfigPanel() {
|
||||
schedFrom = localInputToIso(fromLocal)
|
||||
schedUntil = localInputToIso(untilLocal)
|
||||
if (!schedFrom || !schedUntil) { setSaveErr('Pick both a start and end time'); setSaveState('err'); return }
|
||||
if (new Date(schedUntil).getTime() <= new Date(schedFrom).getTime()) { setSaveErr('End must be after start'); setSaveState('err'); return }
|
||||
// Cross-midnight windows (e.g. 22:00–02:00) are VALID — the backend's
|
||||
// _is_in_active_window treats af_t > au_t as a wrap-around window
|
||||
// (now >= from OR now <= until). Only reject when start === end, which
|
||||
// would describe a zero-length (or full-day-ambiguous) window.
|
||||
if (new Date(schedUntil).getTime() === new Date(schedFrom).getTime()) { setSaveErr('Start and end can’t be the same time'); setSaveState('err'); return }
|
||||
}
|
||||
const tp = settings.take_profit_pct, sl = settings.stop_loss_pct, bd = settings.daily_budget_usd
|
||||
if (trumpOn) {
|
||||
@@ -244,7 +324,7 @@ export default function BotConfigPanel() {
|
||||
}
|
||||
const ok = await confirmSign({
|
||||
label: 'Link Hyperliquid API key',
|
||||
description: 'The API key is stored encrypted on the server and used by the bot to place trades on Hyperliquid for you.',
|
||||
description: 'Stored encrypted on the server. Used by the bot to open and close positions only — no withdrawal access.',
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) return
|
||||
@@ -304,7 +384,7 @@ export default function BotConfigPanel() {
|
||||
if (!address) return
|
||||
const ok = await confirmSign({
|
||||
label: 'Switch to live trading',
|
||||
description: 'Your subscription will change from paper mode to live. For your safety, Auto-Trade is turned OFF on this switch — you must re-enable it explicitly while live. No trade opens immediately; you still need to add a Hyperliquid API key first.',
|
||||
description: 'Switches to live trading. Auto-Trade is turned OFF automatically — re-enable it explicitly. You still need a Hyperliquid API key before the bot can trade.',
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) return
|
||||
@@ -360,10 +440,9 @@ export default function BotConfigPanel() {
|
||||
if (!isSubscribed) {
|
||||
return (
|
||||
<div className="card" id="bot-config-panel" style={{ padding: '28px 28px 24px', marginBottom: 28 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 4 }}>Set up your trading bot</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.65, marginBottom: 24 }}>
|
||||
The bot places trades on Hyperliquid with a trade-only API key — it can never withdraw funds.
|
||||
Start in paper mode to try it safely, or choose Live if you already have a Hyperliquid API key.
|
||||
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 4 }}>Set up your bot</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.55, marginBottom: 24 }}>
|
||||
Trade-only API key — no withdrawals. Try Paper first, or Live if you already have a key.
|
||||
</div>
|
||||
|
||||
{/* Paper / Live choice */}
|
||||
@@ -421,8 +500,8 @@ export default function BotConfigPanel() {
|
||||
return (
|
||||
<div className="card" id="bot-config-panel" style={{ padding: '28px 28px 24px', marginBottom: 28 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 4 }}>Sign in to view your settings</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.65, marginBottom: 20 }}>
|
||||
Your settings are private and wallet-bound. Sign once to load them — the permission is cached for 4 minutes so you won't be prompted again while you browse.
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.55, marginBottom: 20 }}>
|
||||
Settings are wallet-private. Sign once to load — cached for 4 min.
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<button className="btn amber" style={{ padding: '10px 22px', fontSize: 13 }} onClick={handleLoadSettings} disabled={loadState === 'loading'}>
|
||||
@@ -442,6 +521,13 @@ export default function BotConfigPanel() {
|
||||
if (!trumpOn && !macroOn) missingItems.push('enable Trump Signal or Macro Vibes below')
|
||||
if (trumpOn && !tpConfigured) missingItems.push('set a Trump Signal take-profit %')
|
||||
if (trumpOn && !slConfigured) missingItems.push('set a Trump Signal stop-loss %')
|
||||
// Macro Vibes is manage-only through the Telegram /adopt command. If Macro is
|
||||
// the only enabled system and Telegram isn't bound, the bot can't manage any
|
||||
// position — so "ready" would be a lie. (tgBound === null = status unknown /
|
||||
// still loading: don't block on it.)
|
||||
if (macroOn && !trumpOn && tgBound === false) {
|
||||
missingItems.push('connect Telegram (Settings → Telegram) so the bot can manage Macro positions via /adopt')
|
||||
}
|
||||
const botReady = missingItems.length === 0
|
||||
|
||||
// Manual window countdown
|
||||
@@ -461,6 +547,77 @@ export default function BotConfigPanel() {
|
||||
return (
|
||||
<div style={{ marginBottom: 28 }} id="bot-config-panel">
|
||||
|
||||
{/* ── Onboarding stepper ─────────────────────────────────────────────── */}
|
||||
{(!isSubscribed || !(hlApiKeySet || paperMode) || !autoTrade) && (
|
||||
<div className="card" style={{ padding: '14px 18px 16px', marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase', color: 'var(--ink-4)', marginBottom: 14 }}>
|
||||
Setup progress
|
||||
</div>
|
||||
|
||||
{/* Steps row: steps are fixed-width, connectors flex-grow to fill */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', marginBottom: 14 }}>
|
||||
{([
|
||||
{ label: 'Subscribe', done: isSubscribed },
|
||||
{ label: 'Add HL key', done: hlApiKeySet || paperMode },
|
||||
{ label: 'Enable Auto-Trade', done: autoTrade },
|
||||
] as const).map((step, i, arr) => {
|
||||
const isActive = !step.done && (i === 0 || arr[i - 1].done)
|
||||
const isLast = i === arr.length - 1
|
||||
return (
|
||||
<div key={step.label} style={{
|
||||
display: 'flex', alignItems: 'flex-start',
|
||||
flex: isLast ? '0 0 auto' : 1, // last step: natural width; others: expand via connector
|
||||
minWidth: 0,
|
||||
}}>
|
||||
{/* Circle + label — fixed, never stretches */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
||||
<div style={{
|
||||
width: 24, height: 24, borderRadius: '50%',
|
||||
fontSize: 11, fontWeight: 700,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: step.done ? 'var(--up)' : isActive ? 'var(--amber, #f59e0b)' : 'var(--bg-sunk)',
|
||||
color: step.done ? '#fff' : isActive ? '#000' : 'var(--ink-4)',
|
||||
border: `2px solid ${step.done ? 'var(--up)' : isActive ? 'var(--amber, #f59e0b)' : 'var(--line)'}`,
|
||||
boxSizing: 'border-box',
|
||||
}}>
|
||||
{step.done ? '✓' : i + 1}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 10, lineHeight: 1.3,
|
||||
textAlign: isLast ? 'right' : 'center',
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
color: step.done ? 'var(--up)' : isActive ? 'var(--ink)' : 'var(--ink-4)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{step.label}
|
||||
</div>
|
||||
</div>
|
||||
{/* Connector — takes all remaining space between this and next step */}
|
||||
{!isLast && (
|
||||
<div style={{
|
||||
flex: 1, height: 2, marginTop: 11,
|
||||
background: step.done ? 'var(--up)' : 'var(--line)',
|
||||
transition: 'background 0.3s',
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Bottom progress bar */}
|
||||
<div style={{ height: 3, borderRadius: 999, background: 'var(--bg-sunk)', overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
height: '100%', borderRadius: 999,
|
||||
background: 'var(--up)',
|
||||
width: `${Math.round(([isSubscribed, hlApiKeySet || paperMode, autoTrade].filter(Boolean).length / 3) * 100)}%`,
|
||||
transition: 'width .4s ease',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── HL API Key ─────────────────────────────────────────────────────── */}
|
||||
<div className="card" style={{ padding: '16px 20px', marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
@@ -481,7 +638,9 @@ export default function BotConfigPanel() {
|
||||
? <span style={{ color: 'var(--up)' }}>📝 Paper mode — no real money involved. Add an API key below to switch to live.</span>
|
||||
: hlApiKeySet && !apiKey
|
||||
? <><span className="mono">{hlApiKeyMasked ?? '···'}</span><span> · trade-only · cannot withdraw</span></>
|
||||
: 'Generate at app.hyperliquid.xyz/API — paste the private key here. Never your MetaMask key.'}
|
||||
: <>Don't have Hyperliquid?{' '}
|
||||
<a href="https://app.hyperliquid.xyz" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--amber)', textDecoration: 'none' }}>Sign up free ↗</a>
|
||||
{' '}· Then go to API → generate a trade-only key → paste below.</>}
|
||||
</div>
|
||||
</div>
|
||||
{/* Paper mode: show upgrade path instead of key input */}
|
||||
@@ -526,10 +685,74 @@ export default function BotConfigPanel() {
|
||||
))}
|
||||
</div>
|
||||
{keyState === 'err' && <div style={{ fontSize: 12, color: 'var(--down)', marginTop: 8, paddingLeft: 50 }}>{keyErr}</div>}
|
||||
{keyState === 'ok' && <div style={{ fontSize: 11, color: 'var(--up)', marginTop: 6, paddingLeft: 50 }}>Saved. Run one small test trade to confirm the live path end-to-end.</div>}
|
||||
{keyState === 'ok' && <div style={{ fontSize: 11, color: 'var(--up)', marginTop: 6, paddingLeft: 50 }}>Saved. Enable Auto-Trade below, then try a paper trade first to confirm the live path works.</div>}
|
||||
{!paperMode && !hlApiKeySet && (
|
||||
<div style={{
|
||||
marginTop: 10, paddingLeft: 50,
|
||||
display: 'flex', alignItems: 'flex-start', gap: 6,
|
||||
}}>
|
||||
<span style={{ fontSize: 13, flexShrink: 0, marginTop: 1 }}>⚠️</span>
|
||||
<div style={{ fontSize: 11, color: 'var(--down)', lineHeight: 1.5 }}>
|
||||
<strong>Do not paste your main wallet private key.</strong>{' '}
|
||||
This field only accepts a Hyperliquid <em>trade-only API key</em> — generate one at{' '}
|
||||
<a href="https://app.hyperliquid.xyz/API" target="_blank" rel="noopener noreferrer"
|
||||
style={{ color: 'var(--down)', textDecorationColor: 'var(--down)' }}>
|
||||
app.hyperliquid.xyz/API
|
||||
</a>
|
||||
{' '}→ “Generate API wallet”. A trade-only key cannot withdraw funds.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{subState === 'err' && subErr && <div style={{ fontSize: 12, color: 'var(--down)', marginTop: 8 }}>{subErr}</div>}
|
||||
</div>
|
||||
|
||||
{/* ── Auto-Trade master switch ───────────────────────────────────────── */}
|
||||
{isSubscribed && (hlApiKeySet || paperMode) && (
|
||||
<div className="card" style={{ padding: '14px 20px', marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 2 }}>Auto-Trade</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-4)', lineHeight: 1.4 }}>
|
||||
{autoTrade
|
||||
? 'ON — bot opens positions automatically when qualifying Trump signals fire.'
|
||||
: 'OFF — signals are shown but no trades are opened. Turn on when ready to go live.'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => flipAutoTrade(true)}
|
||||
disabled={atState !== 'idle'}
|
||||
style={{
|
||||
padding: '6px 14px', borderRadius: 6, fontSize: 12, fontWeight: 700, cursor: 'pointer',
|
||||
border: 'none',
|
||||
background: autoTrade ? 'var(--up)' : 'var(--bg-sunk)',
|
||||
color: autoTrade ? '#fff' : 'var(--ink-4)',
|
||||
opacity: atState !== 'idle' ? 0.5 : 1,
|
||||
}}
|
||||
>ON</button>
|
||||
<button
|
||||
onClick={() => flipAutoTrade(false)}
|
||||
disabled={atState !== 'idle'}
|
||||
style={{
|
||||
padding: '6px 14px', borderRadius: 6, fontSize: 12, fontWeight: 700, cursor: 'pointer',
|
||||
border: '1px solid var(--line)',
|
||||
background: !autoTrade ? 'var(--ink)' : 'transparent',
|
||||
color: !autoTrade ? 'var(--bg)' : 'var(--ink-4)',
|
||||
opacity: atState !== 'idle' ? 0.5 : 1,
|
||||
}}
|
||||
>OFF</button>
|
||||
</div>
|
||||
</div>
|
||||
{atState !== 'idle' && (
|
||||
<div style={{ fontSize: 11, marginTop: 8, color: atState === 'err' ? 'var(--down)' : 'var(--ink-4)' }}>
|
||||
{atState === 'signing' ? 'Waiting for wallet signature…'
|
||||
: atState === 'saving' ? 'Saving…'
|
||||
: 'Failed to update — please try again.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Trump Signal ───────────────────────────────────────────────────── */}
|
||||
<div id="config-trump" className="card" style={{ padding: 0, overflow: 'hidden', marginBottom: 12 }}>
|
||||
{/* Header */}
|
||||
@@ -560,7 +783,7 @@ export default function BotConfigPanel() {
|
||||
<div className="form-row">
|
||||
<div className="form-row-label">
|
||||
Per-trade size
|
||||
<span className="hint">Notional in USD — margin ≈ ${(settings.position_size_usd / settings.leverage).toFixed(2)} at {settings.leverage}×</span>
|
||||
<span className="hint">How much to bet per trade. At {settings.leverage}×, HL holds ~${(settings.position_size_usd / settings.leverage).toFixed(0)} as collateral.</span>
|
||||
</div>
|
||||
<div className="form-row-control">
|
||||
<div className="num-field">
|
||||
@@ -575,7 +798,7 @@ export default function BotConfigPanel() {
|
||||
<div className="form-row">
|
||||
<div className="form-row-label">
|
||||
Leverage
|
||||
<span className="hint">Event-driven scalp — use lower leverage if unsure</span>
|
||||
<span className="hint">How aggressive the position is. Start low (2–3×) and increase once you've seen the bot trade live.</span>
|
||||
</div>
|
||||
<div className="form-row-control">
|
||||
<div className="slider-field">
|
||||
@@ -584,13 +807,18 @@ export default function BotConfigPanel() {
|
||||
<div className="ticks"><span>1×</span><span>25×</span><span>50×</span></div>
|
||||
</div>
|
||||
<span className="slider-readout">{settings.leverage}×</span>
|
||||
{settings.leverage > 10 && (
|
||||
<div className="settings-note warn" style={{ marginTop: 6 }}>
|
||||
{settings.leverage}× is high for event-driven signals. A 1.5% stop-loss at {settings.leverage}× means the trade closes on a {(1.5 / settings.leverage).toFixed(1)}% price move against you. Consider 3–5× until you see how the bot performs live.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-row-label">
|
||||
Min AI confidence
|
||||
<span className="hint">Skip signals below this score (0 = take all, 100 = only the highest-conviction)</span>
|
||||
<span className="hint">Filter out weak signals. Higher = fewer trades, but only the strongest ones.</span>
|
||||
</div>
|
||||
<div className="form-row-control">
|
||||
<div className="slider-field">
|
||||
@@ -607,7 +835,7 @@ export default function BotConfigPanel() {
|
||||
<div className="form-row">
|
||||
<div className="form-row-label">
|
||||
Take profit <span style={{ color: 'var(--down)' }}>*</span>
|
||||
<span className="hint">Auto-close when unrealised gain hits this target. Required.</span>
|
||||
<span className="hint">Bot locks in profit when the position gains this much. Required.</span>
|
||||
</div>
|
||||
<div className="form-row-control">
|
||||
<div className="num-field">
|
||||
@@ -623,7 +851,7 @@ export default function BotConfigPanel() {
|
||||
<div className="form-row" style={{ marginBottom: 16 }}>
|
||||
<div className="form-row-label">
|
||||
Stop loss <span style={{ color: 'var(--down)' }}>*</span>
|
||||
<span className="hint">Auto-close when drawdown hits this limit. Required.</span>
|
||||
<span className="hint">Bot cuts the loss when the position falls this much. Required.</span>
|
||||
</div>
|
||||
<div className="form-row-control">
|
||||
<div className="num-field">
|
||||
@@ -655,8 +883,8 @@ export default function BotConfigPanel() {
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 2 }}>
|
||||
{macroOn
|
||||
? 'You open a BTC long on Hyperliquid, then use /adopt in the Telegram bot to hand it to the bot for exit management.'
|
||||
: 'Disabled — Macro Vibes alerts will not include bot management instructions.'}
|
||||
? 'When a signal fires: open a BTC long on Hyperliquid → type /adopt in the Telegram bot → bot manages the exit for you.'
|
||||
: 'Disabled — Macro Vibes alerts sent without bot management. Enable + set up Telegram to activate.'}
|
||||
</div>
|
||||
</div>
|
||||
<Switch on={macroOn} onChange={v => updateSettings({ macro_enabled: v })} tone="up" />
|
||||
@@ -719,7 +947,7 @@ export default function BotConfigPanel() {
|
||||
<div className="form-row" style={{ marginBottom: 16 }}>
|
||||
<div className="form-row-label">
|
||||
BTC bottom leverage
|
||||
<span className="hint">Separate from Trump leverage. The bot de-risks in stages before exchange liquidation.</span>
|
||||
<span className="hint">Higher leverage = less room for BTC to drop before the bot starts reducing the position.</span>
|
||||
</div>
|
||||
<div className="form-row-control">
|
||||
<div className="slider-field">
|
||||
@@ -729,9 +957,9 @@ export default function BotConfigPanel() {
|
||||
</div>
|
||||
<span className="slider-readout">{lev}×</span>
|
||||
<div className={`settings-note ${risky ? 'warn' : ''}`} style={{ marginTop: 6 }}>
|
||||
At {lev}× it sheds ⅓ near −{(prot * 0.6).toFixed(0)}%, ⅓ near −{(prot * 0.8).toFixed(0)}%, fully out by −{prot.toFixed(0)}%.
|
||||
Exchange liquidation ≈ −{liq.toFixed(0)}%.
|
||||
{risky ? ' Above 2×, a normal correction can push you out early.' : ' Wide enough to survive a normal correction.'}
|
||||
{risky
|
||||
? `⚠️ At ${lev}×, BTC only needs to drop ${prot.toFixed(0)}% before the bot fully closes the position. A normal correction can trigger early exits — use lower leverage unless you're comfortable with that.`
|
||||
: `At ${lev}×, the bot has room for a ${prot.toFixed(0)}% BTC drop before closing. It reduces the position gradually as the drawdown deepens, so you don't lose everything at once.`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -751,7 +979,7 @@ export default function BotConfigPanel() {
|
||||
cursor: 'pointer', borderBottom: showAdvanced ? '1px solid var(--line)' : 'none',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--ink-2)' }}>Advanced</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--ink-2)' }}>Risk limits & schedule</span>
|
||||
<span style={{
|
||||
fontSize: 11, color: 'var(--ink-4)',
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
@@ -770,7 +998,7 @@ export default function BotConfigPanel() {
|
||||
<div className="form-row">
|
||||
<div className="form-row-label">
|
||||
Daily trading cap
|
||||
<span className="hint">Optional — bot stops opening new trades once total notional crosses this limit in a UTC day.</span>
|
||||
<span className="hint">Daily spending cap — bot stops opening new trades once it hits this amount.</span>
|
||||
</div>
|
||||
<div className="form-row-control" style={{ flexWrap: 'wrap', gap: 10 }}>
|
||||
<Switch on={useBudget} onChange={v => { setUseBudget(v); setDirty(true) }} />
|
||||
@@ -792,7 +1020,7 @@ export default function BotConfigPanel() {
|
||||
<div className="form-row">
|
||||
<div className="form-row-label">
|
||||
Trading schedule
|
||||
<span className="hint">Bot only accepts signals inside this window. Times in your browser's local timezone. Leave off to trade anytime.</span>
|
||||
<span className="hint">Daily recurring UTC window — bot only opens new trades within this time range. Overnight windows (e.g. 22:00–02:00) are allowed. Leave off for 24/7.</span>
|
||||
</div>
|
||||
<div className="form-row-control" style={{ flexWrap: 'wrap' }}>
|
||||
<Switch on={useSchedule} onChange={v => { setUseSchedule(v); setDirty(true) }} />
|
||||
@@ -800,46 +1028,48 @@ export default function BotConfigPanel() {
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<div className="num-field">
|
||||
<span className="prefix">From</span>
|
||||
<input type="datetime-local" lang="en-US" value={fromLocal}
|
||||
onChange={e => { setFromLocal(e.target.value); setDirty(true) }} style={{ width: 190 }} />
|
||||
<input type="time" value={fromLocal}
|
||||
onChange={e => { setFromLocal(e.target.value); setDirty(true) }} style={{ width: 110 }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}>→</span>
|
||||
<div className="num-field">
|
||||
<span className="prefix">Until</span>
|
||||
<input type="datetime-local" lang="en-US" value={untilLocal}
|
||||
onChange={e => { setUntilLocal(e.target.value); setDirty(true) }} style={{ width: 190 }} />
|
||||
<input type="time" value={untilLocal}
|
||||
onChange={e => { setUntilLocal(e.target.value); setDirty(true) }} style={{ width: 110 }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Manual window */}
|
||||
<div className="form-row" style={{ marginBottom: 16 }}>
|
||||
<div className="form-row-label">
|
||||
Override window
|
||||
<span className="hint">Open a timed override so the bot accepts signals for the next 1–24 hours — useful for high-conviction catalysts like CPI or FOMC releases.</span>
|
||||
{/* Override window — only relevant when a schedule is set */}
|
||||
{useSchedule && (
|
||||
<div className="form-row" style={{ marginBottom: 16 }}>
|
||||
<div className="form-row-label">
|
||||
Override window
|
||||
<span className="hint">Temporarily bypass your schedule. Useful when a high-conviction event (e.g. CPI, FOMC) happens outside your trading hours.</span>
|
||||
</div>
|
||||
<div className="form-row-control" style={{ flexWrap: 'wrap', gap: 8 }}>
|
||||
{armed ? (
|
||||
<>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--up)' }}>● Override active — {remainingLabel} remaining</span>
|
||||
<button className="btn ghost" style={{ padding: '6px 14px', fontSize: 12 }}
|
||||
onClick={() => handleManualWindow(0)} disabled={mwBusy}>
|
||||
{mwBusy ? 'Sign…' : 'Cancel'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
[4, 12, 24].map(h => (
|
||||
<button key={h} className="btn ghost" style={{ padding: '6px 12px', fontSize: 12 }}
|
||||
onClick={() => handleManualWindow(h)} disabled={mwBusy}>
|
||||
{mwBusy && mwState === 'signing' ? 'Sign…' : `+${h}h`}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
{mwState === 'err' && <span style={{ fontSize: 11, color: 'var(--down)', width: '100%' }}>{mwErr}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row-control" style={{ flexWrap: 'wrap', gap: 8 }}>
|
||||
{armed ? (
|
||||
<>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--up)' }}>● Override active — {remainingLabel} remaining</span>
|
||||
<button className="btn ghost" style={{ padding: '6px 14px', fontSize: 12 }}
|
||||
onClick={() => handleManualWindow(0)} disabled={mwBusy}>
|
||||
{mwBusy ? 'Sign…' : 'Cancel override'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
[1, 4, 24].map(h => (
|
||||
<button key={h} className="btn ghost" style={{ padding: '6px 12px', fontSize: 12 }}
|
||||
onClick={() => handleManualWindow(h)} disabled={mwBusy}>
|
||||
{mwBusy && mwState === 'signing' ? 'Sign…' : `Override ${h}h`}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
{mwState === 'err' && <span style={{ fontSize: 11, color: 'var(--down)', width: '100%' }}>{mwErr}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useLocale } from 'next-intl'
|
||||
import type { BotTrade, TrumpPost } from '@/types'
|
||||
import type { BotTrade } from '@/types'
|
||||
import Pagination from '@/components/ui/Pagination'
|
||||
|
||||
// ── Formatters ────────────────────────────────────────────────────────────────
|
||||
@@ -16,7 +16,8 @@ function fmtMoney(n: number, opts: { sign?: boolean; decimals?: number } = {}) {
|
||||
return '$' + s
|
||||
}
|
||||
function fmtPct(n: number) { return (n >= 0 ? '+' : '') + n.toFixed(2) + '%' }
|
||||
function fmtHold(s: number) {
|
||||
function fmtHold(s: number | null | undefined) {
|
||||
if (s == null) return '—'
|
||||
if (s < 60) return s + 's'
|
||||
const m = Math.floor(s / 60)
|
||||
if (m < 60) return m + 'm'
|
||||
@@ -24,18 +25,36 @@ function fmtHold(s: number) {
|
||||
}
|
||||
|
||||
interface Props {
|
||||
trades: BotTrade[]
|
||||
posts: TrumpPost[]
|
||||
loading: boolean
|
||||
trades: BotTrade[]
|
||||
loading: boolean
|
||||
locked?: boolean // true = waiting for wallet signature, not "no trades"
|
||||
}
|
||||
|
||||
// ASSETS filter is derived dynamically from the trade set (see useMemo below)
|
||||
// so new assets (TRUMP, BNB, DOGE, LINK, AAVE, ...) appear automatically.
|
||||
const SIDES = ['all', 'long', 'short'] as const
|
||||
|
||||
// Human-readable labels for raw signal-source identifiers. Keeps the trade
|
||||
// history readable for non-technical users (matches PostCards source labels).
|
||||
const SOURCE_LABEL: Record<string, string> = {
|
||||
truth: 'Trump',
|
||||
btc_bottom_reversal: 'BTC Macro Bottom',
|
||||
funding_reversal: 'Funding Reversal',
|
||||
kol_divergence: 'KOL Divergence',
|
||||
sma_reclaim: 'SMA Reclaim',
|
||||
rsi_reversal: 'RSI Reversal',
|
||||
breakout: 'Breakout',
|
||||
adopted: 'Adopted',
|
||||
manual: 'Manual',
|
||||
unknown: 'Unknown',
|
||||
}
|
||||
function sourceLabel(src: string): string {
|
||||
return SOURCE_LABEL[src?.toLowerCase()] ?? src
|
||||
}
|
||||
|
||||
const TRADES_PER_PAGE = 25
|
||||
|
||||
export default function TradeTable({ trades, posts, loading }: Props) {
|
||||
export default function TradeTable({ trades, loading, locked = false }: Props) {
|
||||
const locale = useLocale()
|
||||
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
|
||||
const [assetFilter, setAssetFilter] = useState('all')
|
||||
@@ -63,12 +82,31 @@ export default function TradeTable({ trades, posts, loading }: Props) {
|
||||
return Array.from(set).sort()
|
||||
}, [trades])
|
||||
|
||||
// Reset filters that no longer apply to the loaded trade set. On a wallet
|
||||
// switch the trades prop changes but the local filter state persists, so a
|
||||
// source/asset/side the previous wallet had could stay "stuck" — and when
|
||||
// the new wallet has a single source the breakdown card (sources.length > 1)
|
||||
// disappears, removing the only Clear affordance. Clamping here guarantees
|
||||
// the user always sees their full new history.
|
||||
useEffect(() => {
|
||||
if (sourceFilter !== 'all' && !sources.includes(sourceFilter)) setSourceFilter('all')
|
||||
if (assetFilter !== 'all' && !assets.includes(assetFilter)) setAssetFilter('all')
|
||||
}, [sources, assets, sourceFilter, assetFilter])
|
||||
|
||||
// Per-source PnL aggregate — the critical view for "which module makes money".
|
||||
// Computed BEFORE the asset/side filter so the source breakdown reflects the
|
||||
// full universe, not whatever sub-filter is currently applied.
|
||||
// Apply asset/side/paper filters for the source breakdown so the cards stay
|
||||
// consistent with the KPI and table rows below. We intentionally do NOT apply
|
||||
// sourceFilter here — filtering by source would trivially make one card 100%.
|
||||
const filteredForSources = useMemo(() => trades.filter(t => {
|
||||
if (assetFilter !== 'all' && t.asset !== assetFilter) return false
|
||||
if (sideFilter !== 'all' && t.side !== sideFilter) return false
|
||||
if (hidePaper && t.is_paper) return false
|
||||
return true
|
||||
}), [trades, assetFilter, sideFilter, hidePaper])
|
||||
|
||||
const perSource = useMemo(() => {
|
||||
const acc: Record<string, { trades: number; pnl: number; wins: number; paper: number }> = {}
|
||||
for (const t of trades) {
|
||||
for (const t of filteredForSources) {
|
||||
const k = t.trigger_source || 'unknown'
|
||||
if (!acc[k]) acc[k] = { trades: 0, pnl: 0, wins: 0, paper: 0 }
|
||||
acc[k].trades += 1
|
||||
@@ -79,7 +117,7 @@ export default function TradeTable({ trades, posts, loading }: Props) {
|
||||
if (t.is_paper) acc[k].paper += 1
|
||||
}
|
||||
return acc
|
||||
}, [trades])
|
||||
}, [filteredForSources])
|
||||
|
||||
const filtered = useMemo(() => trades.filter(t => {
|
||||
if (sourceFilter !== 'all' && (t.trigger_source || 'unknown') !== sourceFilter) return false
|
||||
@@ -98,8 +136,9 @@ export default function TradeTable({ trades, posts, loading }: Props) {
|
||||
const totalPnl = priced.reduce((s, t) => s + (t.pnl_usd ?? 0), 0)
|
||||
const wins = priced.filter(t => (t.pnl_usd ?? 0) > 0).length
|
||||
const losses = priced.length - wins
|
||||
const avgHold = filtered.length > 0
|
||||
? Math.round(filtered.reduce((s, t) => s + t.hold_seconds, 0) / filtered.length)
|
||||
const withHold = filtered.filter(t => t.hold_seconds !== null)
|
||||
const avgHold = withHold.length > 0
|
||||
? Math.round(withHold.reduce((s, t) => s + (t.hold_seconds ?? 0), 0) / withHold.length)
|
||||
: 0
|
||||
|
||||
return (
|
||||
@@ -111,7 +150,7 @@ export default function TradeTable({ trades, posts, loading }: Props) {
|
||||
fontSize: 11, fontWeight: 600, letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 10,
|
||||
}}>
|
||||
{isZh ? '按信号来源拆分盈亏' : 'P&L by signal source'}
|
||||
{isZh ? '按信号来源拆分盈亏' : 'Which signal makes money'}
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
@@ -142,7 +181,7 @@ export default function TradeTable({ trades, posts, loading }: Props) {
|
||||
fontSize: 11, fontWeight: 600, color: 'var(--ink-2)',
|
||||
marginBottom: 4, overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{src}
|
||||
{sourceLabel(src)}
|
||||
{s.paper > 0 && (
|
||||
<span style={{
|
||||
fontSize: 9, marginLeft: 6, padding: '1px 5px',
|
||||
@@ -171,7 +210,7 @@ export default function TradeTable({ trades, posts, loading }: Props) {
|
||||
border: '1px solid var(--line)', borderRadius: 4,
|
||||
background: 'transparent', cursor: 'pointer', color: 'var(--ink-2)' }}
|
||||
>
|
||||
{isZh ? `清除(当前为 “${sourceFilter}”)` : `Clear (showing “${sourceFilter}”)`}
|
||||
{isZh ? `清除(当前为 “${sourceLabel(sourceFilter)}”)` : `Clear (showing “${sourceLabel(sourceFilter)}”)`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -255,7 +294,7 @@ export default function TradeTable({ trades, posts, loading }: Props) {
|
||||
<th>{isZh ? '开仓' : 'Entry'}</th>
|
||||
<th>{isZh ? '平仓' : 'Exit'}</th>
|
||||
<th>{isZh ? '持仓' : 'Hold'}</th>
|
||||
<th>{isZh ? '触发内容' : 'Trigger'}</th>
|
||||
<th>{isZh ? '触发信号' : 'What triggered it'}</th>
|
||||
<th>P&L</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -263,12 +302,13 @@ export default function TradeTable({ trades, posts, loading }: Props) {
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} style={{ textAlign: 'center', padding: 40, color: 'var(--ink-3)' }}>
|
||||
{isZh ? '没有符合条件的交易。' : 'No trades found'}
|
||||
{locked
|
||||
? (isZh ? '签名解锁后即可查看交易历史。' : 'Sign to unlock your trade history above.')
|
||||
: (isZh ? '没有符合条件的交易。' : 'No trades found')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{pageRows.map(t => {
|
||||
const tp = posts.find(p => p.id === t.trigger_post_id)
|
||||
const roi =
|
||||
t.entry_price && t.exit_price != null
|
||||
? ((t.exit_price - t.entry_price) / t.entry_price) * 100 * (t.side === 'long' ? 1 : -1)
|
||||
@@ -283,7 +323,7 @@ export default function TradeTable({ trades, posts, loading }: Props) {
|
||||
padding: '2px 7px', borderRadius: 4,
|
||||
background: 'var(--bg-sunk)',
|
||||
}}>
|
||||
{src}
|
||||
{sourceLabel(src)}
|
||||
</span>
|
||||
{t.is_paper && (
|
||||
<span style={{
|
||||
@@ -310,9 +350,9 @@ export default function TradeTable({ trades, posts, loading }: Props) {
|
||||
<td className="mono">{t.exit_price != null ? '$' + t.exit_price.toLocaleString() : '—'}</td>
|
||||
<td className="mono" style={{ color: 'var(--ink-2)' }}>{fmtHold(t.hold_seconds)}</td>
|
||||
<td style={{ maxWidth: 260 }}>
|
||||
{tp ? (
|
||||
{t.trigger_post_text ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--ink-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>
|
||||
{tp.text.slice(0, 60)}…
|
||||
{t.trigger_post_text.slice(0, 60)}…
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}>—</span>
|
||||
|
||||
Reference in New Issue
Block a user