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>
This commit is contained in:
@@ -0,0 +1,417 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useLocale } from 'next-intl'
|
||||
import type { TrumpPost } from '@/types'
|
||||
import { getPosts, getFundingSnapshot, type FundingSnapshot } from '@/lib/api'
|
||||
import { swrFetch } from '@/lib/cache'
|
||||
import PostRow from '@/components/dashboard/PostCards'
|
||||
import SystemControl from '@/components/signals/SystemControl'
|
||||
|
||||
/**
|
||||
* System 2 — BTC bottom-reversal. Its own dedicated page.
|
||||
* Shows the scanner control + ONLY source === 'btc_bottom_reversal' signals.
|
||||
*/
|
||||
|
||||
const SENTIMENTS = ['all', 'bullish', 'bearish', 'neutral'] as const
|
||||
type SentimentFilter = (typeof SENTIMENTS)[number]
|
||||
type BtcTab = 'bottom' | 'funding'
|
||||
|
||||
interface BtcSignalPageProps {
|
||||
initialPosts?: TrumpPost[] | null
|
||||
initialFundingSnapshot?: FundingSnapshot | null
|
||||
}
|
||||
|
||||
export default function BtcSignalPage({
|
||||
initialPosts = null,
|
||||
initialFundingSnapshot = null,
|
||||
}: BtcSignalPageProps) {
|
||||
const locale = useLocale()
|
||||
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
|
||||
const [posts, setPosts] = useState<TrumpPost[]>(initialPosts ?? [])
|
||||
const [loading, setLoading] = useState(initialPosts === null)
|
||||
const [loadErr, setLoadErr] = useState('')
|
||||
const [sentFilter, setSentFilter] = useState<SentimentFilter>('all')
|
||||
const [tab, setTab] = useState<BtcTab>('bottom')
|
||||
|
||||
useEffect(() => {
|
||||
// BTC on-chain signals update daily — 30 min TTL is safe
|
||||
swrFetch(
|
||||
'posts-500',
|
||||
3 * 60_000,
|
||||
() => getPosts(500, 1),
|
||||
fresh => setPosts(fresh),
|
||||
)
|
||||
.then(p => { setPosts(p); setLoadErr('') })
|
||||
.catch(e => setLoadErr(e instanceof Error ? e.message : (isZh ? '信号加载失败' : 'Failed to load signals')))
|
||||
.finally(() => setLoading(false))
|
||||
}, [isZh])
|
||||
|
||||
// Tab-scoped post source. 'bottom' = MVRV/200WMA confluence,
|
||||
// 'funding' = funding-rate extreme reversal.
|
||||
const tabSource = tab === 'bottom' ? 'btc_bottom_reversal' : 'funding_reversal'
|
||||
const btcPosts = useMemo(
|
||||
() => posts.filter(p => (p.source || '') === tabSource),
|
||||
[posts, tabSource],
|
||||
)
|
||||
const filtered = useMemo(
|
||||
() => btcPosts.filter(p => sentFilter === 'all' || p.sentiment === sentFilter),
|
||||
[btcPosts, sentFilter],
|
||||
)
|
||||
|
||||
const sentimentLabels: Record<SentimentFilter, string> = {
|
||||
all: isZh ? '全部' : 'All',
|
||||
bullish: isZh ? '看多' : 'Bullish',
|
||||
bearish: isZh ? '看空' : 'Bearish',
|
||||
neutral: isZh ? '中性' : 'Neutral',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">{isZh ? '② BTC 信号' : '② BTC Signal'}</h1>
|
||||
<p className="page-sub">
|
||||
{tab === 'bottom'
|
||||
? `Macro cycle bottom · ${btcPosts.length} signals`
|
||||
: `Funding-rate extreme reversal · ${btcPosts.length} signals`}
|
||||
</p>
|
||||
</div>
|
||||
<span className="chip"><span className="live-dot" />Live</span>
|
||||
</div>
|
||||
|
||||
<SystemControl system="btc" />
|
||||
|
||||
{tab === 'bottom' && (
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.6, marginBottom: 12 }}>
|
||||
This module tracks Bitcoin macro-bottom conditions. It only fires a long-only cycle signal when at least two of three classic bottom markers agree: AHR999, the 200-week moving average, and Pi Cycle Bottom.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="nav-tabs" style={{
|
||||
background: 'var(--bg-sunk)', marginTop: 16, marginBottom: 12,
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setTab('bottom')}
|
||||
className={`nav-tab ${tab === 'bottom' ? 'active' : ''}`}
|
||||
style={{ border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
{isZh ? '周期底部' : 'Macro Bottom'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('funding')}
|
||||
className={`nav-tab ${tab === 'funding' ? 'active' : ''}`}
|
||||
style={{ border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
{isZh ? '资金费率反转' : 'Funding Reversal'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === 'bottom' && (
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.5, marginBottom: 12 }}>
|
||||
<>
|
||||
Fires when <strong>≥2 of 3</strong> classic bottom signals agree:{' '}
|
||||
<strong>AHR999 < 0.45</strong> · <strong>price ≤ 200-week
|
||||
MA</strong> · <strong>Pi Cycle Bottom</strong>. Long only, low frequency
|
||||
(2–4 fires per cycle). Scans daily 00:45 UTC. Designed to ride the
|
||||
full cycle: holds up to <strong>18 months</strong>, trailing-stop
|
||||
exit, no take-profit. Keep leverage ≤2× — amplification comes from
|
||||
pyramiding, not from cranking leverage.
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'funding' && (
|
||||
<FundingPanel
|
||||
isZh={isZh}
|
||||
initialSnapshot={initialFundingSnapshot}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', margin: '16px 0 12px' }}>
|
||||
{SENTIMENTS.map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setSentFilter(f)}
|
||||
style={{
|
||||
padding: '4px 10px', borderRadius: 6, border: '1px solid var(--line)',
|
||||
background: sentFilter === f ? 'var(--ink)' : 'transparent',
|
||||
color: sentFilter === f ? 'var(--bg)' : 'var(--ink-3)',
|
||||
fontSize: 11, cursor: 'pointer', fontWeight: sentFilter === f ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{sentimentLabels[f]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && <BtcSkeleton />}
|
||||
{!loading && loadErr && (
|
||||
<div className="card" style={{ padding: 24, textAlign: 'center', color: 'var(--down)' }}>
|
||||
⚠️ {`Couldn’t load signals — ${loadErr}`}
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<button className="btn ghost" style={{ fontSize: 12, padding: '6px 14px' }}
|
||||
onClick={() => location.reload()}>{isZh ? '重试' : 'Retry'}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!loading && !loadErr && filtered.length === 0 && (
|
||||
<div className="card" style={{ padding: 48, textAlign: 'center', color: 'var(--ink-3)' }}>
|
||||
{tab === 'bottom'
|
||||
? 'No macro-bottom signals yet.'
|
||||
: 'No funding-reversal signals yet.'}
|
||||
<div style={{ fontSize: 12, marginTop: 8 }}>
|
||||
{tab === 'bottom'
|
||||
? 'This scanner is intentionally rare — only fires at genuine macro bottoms (daily 00:45 UTC).'
|
||||
: 'Fires when 30-day cumulative funding exceeds ±3% AND starts mean-reverting. See the live panel above for current state.'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!loading && filtered.length > 0 && (
|
||||
<div className="post-stream">
|
||||
{filtered.map(p => <PostRow key={p.id} post={p} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Funding rate live panel ─────────────────────────────────────────────────
|
||||
function FundingPanel({
|
||||
isZh,
|
||||
initialSnapshot = null,
|
||||
}: {
|
||||
isZh: boolean
|
||||
initialSnapshot?: FundingSnapshot | null
|
||||
}) {
|
||||
const [snap, setSnap] = useState<FundingSnapshot | null>(initialSnapshot)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
function load() {
|
||||
swrFetch(
|
||||
'funding-snapshot',
|
||||
5 * 60_000, // 5 min — funding cadence is 1–8h, no point polling faster
|
||||
() => getFundingSnapshot(),
|
||||
fresh => { if (alive) setSnap(fresh) },
|
||||
)
|
||||
.then(s => { if (alive) { setSnap(s); setErr('') } })
|
||||
.catch(e => { if (alive) setErr(e instanceof Error ? e.message : (isZh ? '加载失败' : 'load failed')) })
|
||||
}
|
||||
load()
|
||||
const id = setInterval(load, 5 * 60_000)
|
||||
return () => { alive = false; clearInterval(id) }
|
||||
}, [isZh])
|
||||
|
||||
if (err) {
|
||||
return (
|
||||
<div className="card" style={{ padding: 14, color: 'var(--down)', fontSize: 12 }}>
|
||||
{`Funding snapshot failed — ${err}`}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!snap) {
|
||||
return (
|
||||
<div className="skeleton-card">
|
||||
<div className="skeleton sk-line sk-w-3q" />
|
||||
<div className="skeleton sk-title sk-w-half" />
|
||||
<div className="skeleton sk-line sk-w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!snap.ok) {
|
||||
return (
|
||||
<div className="card" style={{ padding: 14, color: 'var(--ink-3)', fontSize: 12 }}>
|
||||
{`Funding data unavailable — ${snap.error || 'unknown error'}`}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Color the cumulative figure by direction + extremity.
|
||||
const cum = snap.cum_30d_pct ?? 0
|
||||
const thr = snap.extreme_threshold_pct ?? 3
|
||||
const extreme = Math.abs(cum) >= thr
|
||||
const cumColor = extreme ? (cum > 0 ? 'var(--down)' : 'var(--up)') : 'var(--ink)'
|
||||
|
||||
// Direction hint if extreme (longs paying = SHORT setup; shorts paying = LONG)
|
||||
const directionHint = extreme
|
||||
? (cum > 0
|
||||
? 'Longs are crowded; reversal bias is SHORT.'
|
||||
: 'Shorts are crowded; reversal bias is LONG.')
|
||||
: 'Funding remains inside the normal range.'
|
||||
|
||||
return (
|
||||
<div className="card" style={{ padding: 16, marginBottom: 12 }}>
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'baseline',
|
||||
flexWrap: 'wrap', gap: 12, marginBottom: 12,
|
||||
}}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700 }}>
|
||||
BTC perp funding · live
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-4)' }}>
|
||||
{`${snap.cadence_hours}h cadence · ${snap.coverage_days}d coverage · Binance`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
|
||||
gap: 12, marginBottom: 12,
|
||||
}}>
|
||||
<StatBox
|
||||
label="Latest cycle"
|
||||
value={`${snap.latest_rate_pct?.toFixed(4)}%`}
|
||||
/>
|
||||
<StatBox
|
||||
label="Last 24h avg"
|
||||
value={`${snap.last_24h_avg_pct?.toFixed(4)}%`}
|
||||
/>
|
||||
<StatBox
|
||||
label="30d cumulative"
|
||||
value={`${cum >= 0 ? '+' : ''}${cum.toFixed(2)}%`}
|
||||
color={cumColor}
|
||||
sub={`extreme @ ±${thr}%`}
|
||||
/>
|
||||
<StatBox
|
||||
label="Signal"
|
||||
value={snap.signal_fired
|
||||
? '🚨 FIRED'
|
||||
: extreme
|
||||
? '⏳ Watching'
|
||||
: '✓ Quiet'}
|
||||
color={snap.signal_fired ? 'var(--down)' : extreme ? 'var(--amber, #f59e0b)' : 'var(--up)'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
padding: '8px 12px', borderRadius: 6, fontSize: 12,
|
||||
background: extreme ? 'var(--down-soft, rgba(220,38,38,.08))' : 'var(--bg-sunk)',
|
||||
color: extreme ? 'var(--down)' : 'var(--ink-3)',
|
||||
marginBottom: 12,
|
||||
}}>
|
||||
{directionHint}
|
||||
{snap.debug?.reason && (
|
||||
<span style={{ color: 'var(--ink-4)', marginLeft: 8 }}>
|
||||
({String(snap.debug.reason).replaceAll('_', ' ')})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{snap.history && snap.history.length > 1 && (
|
||||
<FundingSparkline
|
||||
history={snap.history}
|
||||
cadenceHours={snap.cadence_hours ?? 8}
|
||||
extremeCumPct={thr}
|
||||
isZh={isZh}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatBox({ label, value, color, sub }: {
|
||||
label: string; value: string; color?: string; sub?: string
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: 10, background: 'var(--bg-sunk)', borderRadius: 6,
|
||||
border: '1px solid var(--line)',
|
||||
}}>
|
||||
<div style={{ fontSize: 10, color: 'var(--ink-4)', textTransform: 'uppercase',
|
||||
letterSpacing: '.06em', marginBottom: 4 }}>{label}</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: color || 'var(--ink)',
|
||||
fontVariantNumeric: 'tabular-nums' }}>{value}</div>
|
||||
{sub && <div style={{ fontSize: 10, color: 'var(--ink-4)', marginTop: 2 }}>{sub}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Tiny inline SVG sparkline of the last 7 days of funding cycles.
|
||||
// `extremeCumPct` is the 30d cumulative threshold (e.g. 3.0). To project it as
|
||||
// a per-cycle reference line we divide by the number of cycles in 30 days at
|
||||
// the venue's cadence (24h × 30d / cadence_h). This is the per-cycle rate that
|
||||
// — sustained for 30 days — would hit the extreme bucket.
|
||||
function FundingSparkline({ history, cadenceHours, extremeCumPct, isZh }: {
|
||||
history: { t: number; rate_pct: number }[]
|
||||
cadenceHours: number
|
||||
extremeCumPct: number
|
||||
isZh: boolean
|
||||
}) {
|
||||
const W = 600, H = 90, PAD = 6
|
||||
const cyclesIn30d = Math.max(1, (30 * 24) / Math.max(cadenceHours, 0.5))
|
||||
const perCycleThr = extremeCumPct / cyclesIn30d // e.g. 3% / 90 ≈ 0.033%
|
||||
|
||||
const rates = history.map(h => h.rate_pct)
|
||||
// Ensure threshold lines are always visible in the y-range
|
||||
const minR = Math.min(...rates, -perCycleThr * 1.2)
|
||||
const maxR = Math.max(...rates, perCycleThr * 1.2)
|
||||
const span = maxR - minR || 1
|
||||
const x = (i: number) => PAD + (i / (history.length - 1)) * (W - 2 * PAD)
|
||||
const y = (r: number) => PAD + (1 - (r - minR) / span) * (H - 2 * PAD)
|
||||
const zeroY = y(0)
|
||||
const posThrY = y(perCycleThr)
|
||||
const negThrY = y(-perCycleThr)
|
||||
const path = history.map((h, i) => `${i === 0 ? 'M' : 'L'} ${x(i)} ${y(h.rate_pct)}`).join(' ')
|
||||
const last = history[history.length - 1]
|
||||
// Color the dot by whether the latest cycle is inside the danger band
|
||||
const inDanger = Math.abs(last.rate_pct) >= perCycleThr
|
||||
const dotColor = inDanger
|
||||
? (last.rate_pct > 0 ? 'var(--down)' : 'var(--up)')
|
||||
: 'var(--amber, #f59e0b)'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between',
|
||||
fontSize: 10, color: 'var(--ink-4)', marginBottom: 4,
|
||||
}}>
|
||||
<span>{isZh ? '7 日资金费率(单周期 %)' : '7-day funding (per-cycle %)'}</span>
|
||||
<span>{isZh ? `危险区间:每周期 ±${perCycleThr.toFixed(3)}%` : `danger band: ±${perCycleThr.toFixed(3)}% / cycle`}</span>
|
||||
</div>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: '100%', height: H, display: 'block' }}>
|
||||
{/* danger zones — shaded above +thr and below -thr */}
|
||||
<rect x={PAD} y={PAD} width={W - 2 * PAD} height={Math.max(0, posThrY - PAD)}
|
||||
fill="var(--down)" opacity={0.07} />
|
||||
<rect x={PAD} y={negThrY} width={W - 2 * PAD} height={Math.max(0, H - PAD - negThrY)}
|
||||
fill="var(--up)" opacity={0.07} />
|
||||
|
||||
{/* zero line */}
|
||||
<line x1={PAD} x2={W - PAD} y1={zeroY} y2={zeroY}
|
||||
stroke="var(--line-2, var(--line))" strokeWidth={1} />
|
||||
{/* positive / negative threshold lines */}
|
||||
<line x1={PAD} x2={W - PAD} y1={posThrY} y2={posThrY}
|
||||
stroke="var(--down)" strokeWidth={1} strokeDasharray="3 3" opacity={0.6} />
|
||||
<line x1={PAD} x2={W - PAD} y1={negThrY} y2={negThrY}
|
||||
stroke="var(--up)" strokeWidth={1} strokeDasharray="3 3" opacity={0.6} />
|
||||
|
||||
{/* funding curve */}
|
||||
<path d={path} fill="none" stroke="var(--amber, #f59e0b)" strokeWidth={1.5} />
|
||||
{/* current value dot */}
|
||||
<circle cx={x(history.length - 1)} cy={y(last.rate_pct)} r={3.5} fill={dotColor} />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BtcSkeleton() {
|
||||
return (
|
||||
<div className="post-stream" style={{ marginTop: 8 }}>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="skeleton-card" style={{ marginBottom: 8 }}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div className="skeleton sk-line-sm" style={{ width: 80 }} />
|
||||
<div className="skeleton sk-line-sm" style={{ width: 60 }} />
|
||||
</div>
|
||||
<div className="skeleton sk-title sk-w-3q" />
|
||||
<div className="skeleton sk-line sk-w-full" />
|
||||
<div className="skeleton sk-line sk-w-half" />
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
||||
<div className="skeleton sk-line-sm" style={{ width: 56 }} />
|
||||
<div className="skeleton sk-line-sm" style={{ width: 80 }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { getFundingSnapshot, getPosts } from '@/lib/api'
|
||||
import type { Metadata } from 'next'
|
||||
import BtcPageClient from './BtcPageClient'
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'
|
||||
export const revalidate = 30
|
||||
|
||||
interface BtcPageProps {
|
||||
params: Promise<{ locale: string }>
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: BtcPageProps): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
|
||||
|
||||
return {
|
||||
title: isZh ? 'BTC 周期底部信号扫描器' : 'BTC Bottom Reversal Scanner',
|
||||
description: isZh
|
||||
? 'Trump Alpha 的比特币周期底部扫描器,使用 AHR999、200 周均线和 Pi Cycle Bottom 三项经典信号做 2-of-3 共振判断,识别低频、高确定性的 BTC 长线底部区域。'
|
||||
: 'Bitcoin macro-bottom scanner using a 2-of-3 price confluence: AHR999 deep-value zone, price near the 200-week moving average, and Pi Cycle Bottom. Rare, high-conviction long-only signals.',
|
||||
keywords: isZh
|
||||
? [
|
||||
'BTC 底部信号',
|
||||
'比特币周期底部',
|
||||
'AHR999',
|
||||
'200 周均线',
|
||||
'Pi Cycle Bottom',
|
||||
'比特币反转信号',
|
||||
'加密价格共振',
|
||||
]
|
||||
: [
|
||||
'Bitcoin bottom signal',
|
||||
'AHR999',
|
||||
'200-week moving average',
|
||||
'Pi Cycle Bottom',
|
||||
'BTC cycle bottom',
|
||||
'Bitcoin macro bottom',
|
||||
'Bitcoin reversal signal',
|
||||
'crypto price confluence',
|
||||
],
|
||||
openGraph: {
|
||||
title: isZh ? 'BTC 周期底部信号扫描器 | Trump Alpha' : 'BTC Bottom Reversal Scanner | Trump Alpha',
|
||||
description: isZh
|
||||
? '用 AHR999、200 周均线和 Pi Cycle Bottom 识别比特币宏观底部。低频,但每次触发都服务于长周期反转。'
|
||||
: 'Bitcoin macro-bottom detector: 2-of-3 confluence across AHR999, 200-week MA, and Pi Cycle Bottom. Rare, high-conviction long-only signals.',
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${siteUrl}/${locale}/btc`,
|
||||
languages: {
|
||||
en: `${siteUrl}/en/btc`,
|
||||
'x-default': `${siteUrl}/en/btc`,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function BtcPage() {
|
||||
const [posts, fundingSnapshot] = await Promise.all([
|
||||
getPosts(500, 1).catch(() => null),
|
||||
getFundingSnapshot().catch(() => null),
|
||||
])
|
||||
|
||||
return (
|
||||
<BtcPageClient
|
||||
initialPosts={posts}
|
||||
initialFundingSnapshot={fundingSnapshot}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user