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:
k
2026-06-09 22:55:27 +08:00
parent 9e0f6554cb
commit 4c3c8c6f87
57 changed files with 3464 additions and 1855 deletions
+73 -5
View File
@@ -5,11 +5,18 @@ import type {
} from '@/types'
import type { SignedEnvelope } from './signedRequest'
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
function getServerApiBase() {
return (
process.env.API_BASE_URL ||
process.env.INTERNAL_API_URL ||
process.env.NEXT_PUBLIC_API_URL ||
'http://localhost:8000'
)
}
function getApiOrigin() {
return typeof window === 'undefined'
? `${BASE_URL}/api`
? `${getServerApiBase()}/api`
: '/api/proxy/api'
}
@@ -29,6 +36,53 @@ async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
return res.json() as Promise<T>
}
export interface PostListResponse {
items: TrumpPost[]
total: number
page: number
limit: number
counts: {
all: number
actionable: number
buy: number
short: number
off_topic: number
}
source_counts: {
source: string
count: number
latest: string | null
}[]
}
export async function getPostsPage(
limit = 20,
page = 1,
source?: string,
filters?: {
sourceIn?: string[]
sourceNotIn?: string[]
archiveOnly?: boolean
sentiment?: 'bullish' | 'bearish' | 'neutral'
signal?: 'buy' | 'short' | 'actionable'
aiScoredOnly?: boolean
},
): Promise<PostListResponse> {
const params = new URLSearchParams({
limit: String(limit),
page: String(page),
})
if (source) params.set('source', source)
if (filters?.sourceIn?.length) params.set('source_in', filters.sourceIn.join(','))
if (filters?.sourceNotIn?.length) params.set('source_not_in', filters.sourceNotIn.join(','))
if (filters?.archiveOnly) params.set('archive_only', 'true')
if (filters?.sentiment) params.set('sentiment', filters.sentiment)
if (filters?.signal) params.set('signal', filters.signal)
if (filters?.aiScoredOnly) params.set('ai_scored_only', 'true')
const q = `/posts-paged?${params.toString()}`
return fetchJson<PostListResponse>(q)
}
export async function getPosts(limit = 20, page = 1, source?: string): Promise<TrumpPost[]> {
const q = `/posts?limit=${limit}&page=${page}` + (source ? `&source=${encodeURIComponent(source)}` : '')
return fetchJson<TrumpPost[]>(q)
@@ -139,6 +193,11 @@ export interface UserPublic {
circuit_breaker_reason?: string | null
/** Master Auto-Trade gate. false (default) = signals shown, not traded. */
auto_trade?: boolean
/** Per-system enable flags. bot_engine gates System-1 (Trump) on
* trump_enabled and System-2 (Macro) on macro_enabled. Auto-Trade ON
* with trump_enabled=false still does NOT open on a Trump signal. */
trump_enabled?: boolean
macro_enabled?: boolean
}
export interface SignalSource {
@@ -403,11 +462,14 @@ export async function setManualWindow(
// ── KOL module ────────────────────────────────────────────────────
export async function getKolPosts(opts: {
handle?: string; source?: string; limit?: number; page?: number
} = {}): Promise<{ items: KolPostSummary[]; page: number; limit: number }> {
handle?: string; source?: string; signalsOnly?: boolean; ticker?: string; days?: number; limit?: number; page?: number
} = {}): Promise<{ items: KolPostSummary[]; page: number; limit: number; total: number }> {
const p = new URLSearchParams()
if (opts.handle) p.set('handle', opts.handle)
if (opts.source) p.set('source', opts.source)
if (opts.signalsOnly) p.set('signals_only', 'true')
if (opts.ticker) p.set('ticker', opts.ticker)
if (opts.days) p.set('days', String(opts.days))
p.set('limit', String(opts.limit ?? 50))
p.set('page', String(opts.page ?? 1))
return fetchJson(`/kol/posts?${p.toString()}`)
@@ -520,7 +582,13 @@ export interface TelegramStatus {
export interface TelegramInitResp {
code: string; deep_link: string; expires_in_seconds: number
}
export async function getTelegramStatus(wallet: string): Promise<TelegramStatus> {
export async function getTelegramStatus(wallet: string, env?: SignedEnvelope): Promise<TelegramStatus> {
// Pass signature when available so the backend returns full binding details.
// Without it the endpoint returns only `bound: boolean` to prevent third-party de-anonymisation.
if (env) {
const qs = new URLSearchParams({ timestamp: String(env.timestamp), signature: env.signature })
return fetchJson<TelegramStatus>(`/telegram/${wallet}/status?${qs}`)
}
return fetchJson<TelegramStatus>(`/telegram/${wallet}/status`)
}
// SignedEnvelope is already imported at the top of this file. Below we
+105
View File
@@ -0,0 +1,105 @@
/**
* Mobile wallet detection and deep-link helpers.
*
* On mobile browsers (Safari / Chrome) there is no injected `window.ethereum`,
* so the `injected()` wagmi connector returns nothing and the user sees
* "No wallet provider found". The fix is to detect this state early and offer
* deep links that open MetaMask / Trust / Coinbase / OKX and land the user
* inside the wallet's in-app browser at the current dApp URL.
*
* Deep-link patterns used:
* MetaMask https://metamask.app.link/dapp/<host><path> (universal link → iOS/Android)
* Trust https://link.trustwallet.com/open_url?coin_id=60&url=<encoded>
* Coinbase https://go.cb-wallet.com/dapp?url=<encoded>
* OKX https://www.okx.com/download?deeplink=okx%3A%2F%2Fmain%2Fdapp%2Fbrowser%3Furl%3D<encoded>
*
* All links open in a new tab (_blank) so if the user doesn't have the app
* installed they land on the wallet's download page without breaking nav.
*/
/** True when the JS runtime has access to browser APIs. */
const isBrowser = typeof window !== 'undefined'
/**
* Rough mobile UA check — covers iOS (iPhone/iPad) and Android.
* Intentionally excludes desktop Chrome/Firefox even if the viewport is narrow.
*/
export function isMobileDevice(): boolean {
if (!isBrowser) return false
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
}
/**
* Returns true when an injected EIP-1193 provider is present in the window
* (i.e. the user is already inside MetaMask's browser, has a browser extension,
* or Coinbase Wallet's in-app browser).
*/
export function hasInjectedWallet(): boolean {
if (!isBrowser) return false
// window.ethereum is the canonical EIP-1193 injection point.
// Some wallets also inject under their own namespace but all reputable ones
// also set window.ethereum (or window.web3 as a fallback).
return !!(window as unknown as { ethereum?: unknown }).ethereum
}
/**
* True when the user is on mobile AND has no injected wallet — the state
* where we should show the mobile wallet picker instead of a bare error.
*/
export function needsMobileWallet(): boolean {
return isMobileDevice() && !hasInjectedWallet()
}
export interface WalletLink {
name: string
/** Short subtitle shown under the name */
hint: string
/** URL that opens the wallet app and navigates to `dappUrl` */
href: string
/** CSS color for the icon background */
color: string
/** Short letter(s) displayed when no SVG icon is available */
abbr: string
}
/**
* Build the deep-link set for the given dApp URL.
* Pass `window.location.href` (or a canonical URL) as `dappUrl`.
*/
export function getWalletLinks(dappUrl: string): WalletLink[] {
const enc = encodeURIComponent(dappUrl)
// MetaMask universal link: strip the protocol and pass host+path
// e.g. "https://trumpsignal.com/en" → "trumpsignal.com/en"
const hostPath = dappUrl.replace(/^https?:\/\//, '')
return [
{
name: 'MetaMask',
hint: 'Most popular — iOS & Android',
href: `https://metamask.app.link/dapp/${hostPath}`,
color: '#E8831D',
abbr: '🦊',
},
{
name: 'Trust Wallet',
hint: 'Binance-backed, iOS & Android',
href: `https://link.trustwallet.com/open_url?coin_id=60&url=${enc}`,
color: '#3375BB',
abbr: '🛡',
},
{
name: 'Coinbase Wallet',
hint: 'No Coinbase account needed',
href: `https://go.cb-wallet.com/dapp?url=${enc}`,
color: '#1652F0',
abbr: '🔵',
},
{
name: 'OKX Wallet',
hint: 'iOS & Android',
href: `https://www.okx.com/download?deeplink=${encodeURIComponent(`okx://main/dapp/browser?url=${enc}`)}`,
color: '#000000',
abbr: 'OKX',
},
]
}
+118
View File
@@ -0,0 +1,118 @@
import type { TrumpPost } from '@/types'
import { getPosts, getPostsPage, type PostListResponse } from './api'
const LIVE_ARCHIVE_SOURCES = new Set([
'truth',
'btc_bottom_reversal',
'funding_reversal',
'kol_divergence',
])
function isAiScored(post: Pick<TrumpPost, 'ai_confidence' | 'ai_reasoning'>): boolean {
return (post.ai_confidence ?? 0) > 0 || !!post.ai_reasoning
}
function buildSourceCounts(items: TrumpPost[]): PostListResponse['source_counts'] {
const counts = new Map<string, { count: number; latest: string | null }>()
for (const post of items) {
const hit = counts.get(post.source)
if (!hit) {
counts.set(post.source, { count: 1, latest: post.published_at })
continue
}
hit.count += 1
if (!hit.latest || post.published_at > hit.latest) hit.latest = post.published_at
}
return Array.from(counts.entries())
.map(([source, meta]) => ({ source, count: meta.count, latest: meta.latest }))
.sort((a, b) => b.count - a.count || a.source.localeCompare(b.source))
}
export function buildPostListFallbackResponse(
items: TrumpPost[],
source: string,
limit: number,
): PostListResponse {
return {
items,
total: items.length,
page: 1,
limit,
counts: {
all: items.length,
actionable: items.filter(p => p.signal === 'buy' || p.signal === 'short').length,
buy: items.filter(p => p.signal === 'buy').length,
short: items.filter(p => p.signal === 'short').length,
off_topic: items.filter(p => !isAiScored(p)).length,
},
source_counts: [{ source, count: items.length, latest: items[0]?.published_at ?? null }],
}
}
export function buildArchiveFallbackResponse(
allPosts: TrumpPost[],
page: number,
limit: number,
source = 'all',
): PostListResponse {
const archivePosts = allPosts.filter(post => !LIVE_ARCHIVE_SOURCES.has(post.source))
const sourceCounts = buildSourceCounts(archivePosts)
const filtered = source === 'all'
? archivePosts
: archivePosts.filter(post => post.source === source)
const offset = (page - 1) * limit
const items = filtered.slice(offset, offset + limit)
return {
items,
total: filtered.length,
page,
limit,
counts: {
all: filtered.length,
actionable: filtered.filter(p => p.signal === 'buy' || p.signal === 'short').length,
buy: filtered.filter(p => p.signal === 'buy').length,
short: filtered.filter(p => p.signal === 'short').length,
off_topic: filtered.filter(p => !isAiScored(p)).length,
},
source_counts: sourceCounts,
}
}
export async function getInitialPostPage(
limit: number,
page: number,
options: {
source?: string
filters?: {
sourceIn?: string[]
sourceNotIn?: string[]
archiveOnly?: boolean
sentiment?: 'bullish' | 'bearish' | 'neutral'
signal?: 'buy' | 'short' | 'actionable'
aiScoredOnly?: boolean
}
legacyFallbackSource?: string
legacyFallback?: () => Promise<PostListResponse | null>
},
): Promise<PostListResponse | null> {
return getPostsPage(limit, page, options.source, options.filters).catch(async (e) => {
// Only fall back to legacy /posts on 404 (new endpoint not yet deployed).
// Non-404 errors (500, network) should NOT silently mask as empty data —
// a server-side fallback on 500 produces initial HTML that the client
// cannot reproduce on re-fetch (it shows an error instead), causing a
// hydration mismatch. Return null so the page renders empty and the
// client fetches cleanly on mount.
const detail = e instanceof Error ? e.message : ''
if (!detail.includes('404')) return null
if (options.legacyFallback) {
return options.legacyFallback()
}
if (!options.legacyFallbackSource || options.filters?.archiveOnly || options.filters?.sourceIn || options.filters?.sourceNotIn) {
return null
}
const fallbackItems = await getPosts(limit, page, options.legacyFallbackSource).catch(() => null)
return fallbackItems ? buildPostListFallbackResponse(fallbackItems, options.legacyFallbackSource, limit) : null
})
}
+25 -4
View File
@@ -1,9 +1,16 @@
import { createConfig, createStorage, http } from 'wagmi'
import { mainnet } from 'wagmi/chains'
import { injected } from 'wagmi/connectors'
import { injected, walletConnect } from 'wagmi/connectors'
export const chains = [mainnet] as const
// WalletConnect v2 requires a free project ID from https://cloud.walletconnect.com
// Set NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID in .env.local to enable:
// - QR code scanning (desktop → mobile wallet)
// - All WalletConnect-compatible mobile wallets
// Without it, only injected (browser extension) wallets are available.
const wcProjectId = process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID
export const config = createConfig({
chains,
connectors: [
@@ -12,10 +19,24 @@ export const config = createConfig({
// Brave, Frame, Trust, OKX…) fail to connect, and broke in browsers where
// window.ethereum isn't MetaMask. wagmi v2 auto-discovers all injected
// wallets via EIP-6963 (multiInjectedProviderDiscovery, on by default), so
// a generic injected() connector covers them. This still uses the native
// injected provider path (NOT the MetaMask SDK), so it avoids the SDK
// account-sync bug that motivated the original pin.
// a generic injected() connector covers them.
injected({ shimDisconnect: false }),
// WalletConnect v2 — enabled when NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID is set.
// Provides QR-code pairing (desktop ↔ mobile) and all WalletConnect wallets.
// Get a free project ID at https://cloud.walletconnect.com
...(wcProjectId
? [walletConnect({
projectId: wcProjectId,
metadata: {
name: 'Trump Alpha',
description: 'Live crypto signals — Trump posts, BTC macro, KOL divergence',
url: process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com',
icons: ['https://trumpsignal.com/icon'],
},
showQrModal: true,
})]
: []),
],
transports: {
[mainnet.id]: http(),
+9 -1
View File
@@ -27,7 +27,15 @@ interface WsContextValue {
const WsContext = createContext<WsContextValue | null>(null)
const WS_URL = process.env.NEXT_PUBLIC_WS_URL || 'ws://localhost:8000'
// In production, NEXT_PUBLIC_WS_URL must be set (e.g. wss://api.trumpsignal.com).
// Fallback auto-upgrades ws→wss when the page is served over HTTPS to avoid
// mixed-content blocks, and falls back to ws:// for local dev.
const _wsEnv = process.env.NEXT_PUBLIC_WS_URL
const WS_URL = _wsEnv || (
typeof window !== 'undefined' && window.location.protocol === 'https:'
? 'wss://localhost:8000'
: 'ws://localhost:8000'
)
// Exponential backoff: 2s → 4s → 8s → … capped at 60s, plus ±30% jitter
// so a server restart doesn't get hit by all clients at the same instant.