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:
+73
-5
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user