Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions packages/homepage/src/sections/CtaSection.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Locale } from '@longbridge/openapi-utils'
import { useEffect, useState } from 'react'
import { isUSUser, type Locale } from '@longbridge/openapi-utils'

// 1:1 port of the legacy VitePress "Final CTA" section (NewHomePage/index.vue,
// the `.final-cta` section). Markup, copy, inline styles, and CSS mirror the
Expand Down Expand Up @@ -53,6 +54,10 @@ function localePath(locale: Locale, path: string) {
}

export function CtaSection({ locale }: CtaSectionProps) {
const [isUS, setIsUS] = useState(false)
useEffect(() => {
isUSUser().then(setIsUS)
}, [])
const content = LOCALE[locale] ?? LOCALE.en

return (
Expand All @@ -79,9 +84,11 @@ export function CtaSection({ locale }: CtaSectionProps) {
<path d="m12 5 7 7-7 7" />
</svg>
</a>
<a className="btn btn-outline btn-lg" href={localePath(locale, '/pricing')}>
{content.btn2}
</a>
{!isUS && (
<a className="btn btn-outline btn-lg" href={localePath(locale, '/pricing')}>
{content.btn2}
</a>
)}
</div>
</div>
</section>
Expand Down
1 change: 1 addition & 0 deletions packages/utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './slug'
export * from './region'
export * from './navigation'
export * from './i18n'
export * from './us-user'
54 changes: 54 additions & 0 deletions packages/utils/src/us-user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* 当前访问者是否按美国用户处理(定价入口 / 付费行情模块对美国用户隐藏)。
*
* 判定顺序:
* 1. 已登录:app_id cookie 是否美国租户
* 2. 未登录:region cookie(大小写不敏感)
* 3. region 也没有:请求 portal `/api/_ping`,取响应头 `x-ip-region`
*
* 仅客户端调用(在 effect 里),结果在当前页面内缓存;判定失败按非美国处理,保持原有展示。
*/

// 与 packages/tryit/src/utils/app-id.ts、BaseLayout 的 apiProxyBootstrap 中的美国租户 app_id 一致
const US_APP_IDS = ['longbridge_us', 'longbridge_us_uat']

let cached: Promise<boolean> | undefined

function readCookie(name: string): string | undefined {
const parts = `; ${document.cookie}`.split(`; ${name}=`)
return parts.length === 2 ? decodeURIComponent(parts.pop()!.split(';').shift() || '') : undefined
}

function isLoggedIn(): boolean {
try {
return !!(window as unknown as { longportInternal?: { isLogin?: () => boolean } }).longportInternal?.isLogin?.()
} catch {
return false
}
}

async function ipRegionIsUS(): Promise<boolean> {
const base = (window as unknown as { __API_PROXY_URL__?: string }).__API_PROXY_URL__
if (!base) return false
try {
const res = await fetch(`${base}/api/_ping`, { credentials: 'omit' })
return (res.headers.get('x-ip-region') || '').toUpperCase() === 'US'
} catch {
return false
}
}

export function isUSUser(): Promise<boolean> {
if (!cached) {
cached = (async () => {
if (isLoggedIn()) {
const appId = readCookie('app_id') || readCookie('x-original-app-id')
return !!appId && US_APP_IDS.includes(appId)
}
const region = readCookie('region')
if (region) return region.toUpperCase() === 'US'
return ipRegionIsUS()
})()
}
return cached
}
139 changes: 80 additions & 59 deletions src/components/mdx/Pricing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* No AppNav / AppFooter — BaseLayout.astro already renders TopNav + Footer.
*/
import { useState, useMemo, useEffect } from 'react'
import { isUSUser } from '@longbridge/openapi-utils'
import './Pricing.css'

// ── Inline i18n ───────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -224,51 +225,58 @@ const LOCALE = {

// ── Static data ───────────────────────────────────────────────────────────────

interface PlanCycle {
// 价格 / 币种 / 可选周期都取自行情商店接口(quote_product_list),这里只保留展示用元数据
const PAID_PLANS = [
{ id: 'hk-lv2', packageKey: 'HK_L2_International_OpenAPI', color: 'var(--lb-market-hk)', hasBadge: true },
{ id: 'opra', packageKey: 'US_OPRA_LV1_OpenAPI', color: 'var(--lb-ai-mention)', hasBadge: false },
]

interface QuoteSku {
card_duration: number
currency: string
price: number
discount?: number
approxMo?: number
real_price: number
per_month_price?: number
}

const PAID_PLANS = [
{
id: 'hk-lv2',
currency: 'HK$',
color: 'var(--lb-market-hk)',
hasBadge: true,
cycles: {
auto: { price: 558, discount: 22 },
monthly: { price: 718 },
quarterly: { price: 1428, discount: 34, approxMo: 476 },
annual: { price: 5288, discount: 39, approxMo: 441 },
} as Record<string, PlanCycle>,
},
{
id: 'opra',
currency: 'HK$',
color: 'var(--lb-ai-mention)',
hasBadge: false,
cycles: {
auto: { price: 22, discount: 45 },
monthly: { price: 40 },
quarterly: { price: 83, discount: 31, approxMo: 28 },
annual: { price: 269, discount: 44, approxMo: 22 },
} as Record<string, PlanCycle>,
},
]
// card_duration:1/2/3 = 月/季/年(一次性),5/6/7 = 月/季/年(自动续订),同 h5hub opa CARD_DURATION
const BILLING_CYCLES = [
{ key: 'auto', duration: 5, suffix: 'mo' },
{ key: 'monthly', duration: 1, suffix: 'mo' },
{ key: 'quarterly', duration: 2, suffix: 'quarter' },
{ key: 'annual', duration: 3, suffix: 'year', badge: true },
] as const

type CycleKey = (typeof BILLING_CYCLES)[number]['key']

/** 拉取行情商店 SKU,按 package_key 分组(PAID_PLANS 只取 *_OpenAPI 包);失败返回 null */
async function fetchOpenApiSkus(): Promise<Record<string, QuoteSku[]> | null> {
const base = window.__API_PROXY_URL__
if (!base) return null
try {
const res = await fetch(`${base}/api/forward/v2/rewards/quote_product_list?account_channel=lb`, {
headers: { 'x-app-id': 'longbridge' },
})
const json = await res.json()
if (json?.code !== 0) return null
const map: Record<string, QuoteSku[]> = {}
for (const section of json.data?.section ?? []) {
for (const product of section.products ?? []) {
map[product.package_key] = product.skus ?? []
}
}
return map
} catch {
return null
}
}

const FREE_ICONS = [
{ icon: 'shield', color: 'var(--lb-up)' },
{ icon: 'chart', color: 'var(--lb-status-neutral)' },
{ icon: 'bolt', color: 'var(--lb-brand)' },
]

const BILLING_CYCLES = [
{ key: 'auto' },
{ key: 'monthly' },
{ key: 'quarterly' },
{ key: 'annual', badge: true },
]

const MATRIX_ROW_DATA = [
[1, 1, 1, 1, 1],
Expand Down Expand Up @@ -331,15 +339,19 @@ const CheckCircleIcon = () => (

// ── Helper ────────────────────────────────────────────────────────────────────

function getPlanCycle(plan: { cycles: Record<string, PlanCycle> }, cycleKey: string): PlanCycle {
return plan.cycles[cycleKey] ?? plan.cycles.monthly
}

// ── Component ─────────────────────────────────────────────────────────────────

export function Pricing({ locale: initialLocale = 'en' }: { locale?: 'en' | 'zh-CN' | 'zh-HK' } = {}) {
const [locale, setLocale] = useState<'en' | 'zh-CN' | 'zh-HK'>(initialLocale)
const [cycle, setCycle] = useState('auto')
const [cycle, setCycle] = useState<CycleKey | null>(null)
const [skus, setSkus] = useState<Record<string, QuoteSku[]> | null>(null)
const [isUS, setIsUS] = useState(false)

useEffect(() => {
isUSUser().then(setIsUS)
fetchOpenApiSkus().then(setSkus)
}, [])

// Detect locale from URL pathname on client (fallback / safety net)
useEffect(() => {
Expand All @@ -351,11 +363,15 @@ export function Pricing({ locale: initialLocale = 'en' }: { locale?: 'en' | 'zh-

const content = useMemo(() => LOCALE[locale], [locale])

const cycleSuffix = useMemo(() => {
if (cycle === 'quarterly') return content.suffix.quarter
if (cycle === 'annual') return content.suffix.year
return content.suffix.mo
}, [cycle, content])
const availableCycles = useMemo(
() =>
BILLING_CYCLES.filter((c) =>
PAID_PLANS.some((p) => skus?.[p.packageKey]?.some((sku) => sku.card_duration === c.duration))
),
[skus]
)
const activeCycle = availableCycles.find((c) => c.key === cycle) ?? availableCycles[0]
const cycleSuffix = activeCycle ? content.suffix[activeCycle.suffix] : content.suffix.mo

const cycleLabels = useMemo<Record<string, string>>(
() => ({
Expand Down Expand Up @@ -452,7 +468,8 @@ export function Pricing({ locale: initialLocale = 'en' }: { locale?: 'en' | 'zh-
</div>
</section>

{/* Paid Plans */}
{/* Paid Plans(美国用户隐藏) */}
{!isUS && (
<section className="section" style={{ paddingTop: '32px' }}>
<div className="section-inner">
<div
Expand All @@ -478,15 +495,15 @@ export function Pricing({ locale: initialLocale = 'en' }: { locale?: 'en' | 'zh-
<div className="pricing-cycle">
<span className="pricing-cycle-label">{content.billing.label}</span>
<div className="pricing-cycle-tabs">
{BILLING_CYCLES.map((c) => (
{availableCycles.map((c) => (
<button
key={c.key}
type="button"
className={`pricing-cycle-tab${cycle === c.key ? ' is-active' : ''}`}
className={`pricing-cycle-tab${activeCycle?.key === c.key ? ' is-active' : ''}`}
onClick={() => setCycle(c.key)}
>
{cycleLabels[c.key]}
{c.badge && (
{'badge' in c && (
<span className="pricing-cycle-badge">{content.cycle.badge}</span>
)}
</button>
Expand All @@ -497,7 +514,10 @@ export function Pricing({ locale: initialLocale = 'en' }: { locale?: 'en' | 'zh-

<div className="pricing-plans-grid">
{plans.map((p) => {
const pc = getPlanCycle(p, cycle)
const sku = skus?.[p.packageKey]?.find((x) => x.card_duration === activeCycle?.duration)
const discount =
sku && sku.price > sku.real_price ? Math.round((1 - sku.real_price / sku.price) * 100) : 0
const showApprox = sku?.per_month_price && activeCycle?.suffix !== 'mo'
return (
<div key={p.id} className="pricing-plan-card">
<div className="pricing-plan-head">
Expand All @@ -514,20 +534,20 @@ export function Pricing({ locale: initialLocale = 'en' }: { locale?: 'en' | 'zh-
</div>
<h3 className="pricing-plan-name">{p.name}</h3>
<div className="pricing-plan-price">
<span className="pricing-plan-cur">{p.currency}</span>
<span className="pricing-plan-num">{pc.price}</span>
<span className="pricing-plan-suf">{cycleSuffix}</span>
{pc.discount && (
<span className="pricing-plan-discount">-{pc.discount}%</span>
)}
<span className="pricing-plan-cur">{sku?.currency}</span>
<span className="pricing-plan-num">{sku ? sku.real_price : '—'}</span>
{sku && <span className="pricing-plan-suf">{cycleSuffix}</span>}
{discount > 0 && <span className="pricing-plan-discount">-{discount}%</span>}
</div>
{pc.approxMo ? (
{showApprox ? (
<div className="pricing-plan-approx">
≈{p.currency}{pc.approxMo}{content.suffix.mo}
≈{sku.currency} {sku.per_month_price}
{content.suffix.mo}
</div>
) : pc.discount ? (
) : null}
{discount > 0 ? (
<div className="pricing-plan-was">
{content.plan.was} <s>{p.currency}{p.cycles.monthly.price}{content.suffix.mo}</s>
{content.plan.was} <s>{sku?.currency} {sku?.price}</s>
</div>
) : null}
<p className="pricing-plan-tag">{p.tagline}</p>
Expand Down Expand Up @@ -566,6 +586,7 @@ export function Pricing({ locale: initialLocale = 'en' }: { locale?: 'en' | 'zh-
</div>
</div>
</section>
)}

{/* Feature Matrix */}
<section className="section" style={{ paddingTop: '48px', paddingBottom: '80px' }}>
Expand Down
16 changes: 12 additions & 4 deletions src/components/shell/TopNav.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { t, type Locale } from '@longbridge/openapi-utils'
import { t, isUSUser, type Locale } from '@longbridge/openapi-utils'
import { nav as navEn } from '../../data/nav.en'
import { nav as navZhCN } from '../../data/nav.zh-CN'
import { nav as navZhHK } from '../../data/nav.zh-HK'
Expand Down Expand Up @@ -78,9 +78,17 @@ export default function TopNav({ locale, pathname: initialPath = '/' }: Props) {
// CN region hides Pricing / Docs / the Features menu (legacy CN_HIDDEN_KEYS).
const isCnRegion = import.meta.env.PUBLIC_REGION === 'cn'
const CN_HIDDEN_LINKS = new Set(['/pricing', '/docs'])
const visibleNavItems = isCnRegion
? navItems.filter((n) => !n.link || !CN_HIDDEN_LINKS.has(n.link))
: navItems
// 美国用户隐藏 Pricing(判定规则见 isUSUser)
const [isUS, setIsUS] = useState(false)
useEffect(() => {
isUSUser().then(setIsUS)
}, [])
const visibleNavItems = navItems.filter((n) => {
if (!n.link) return true
if (isCnRegion && CN_HIDDEN_LINKS.has(n.link)) return false
if (isUS && n.link === '/pricing') return false
return true
})

const homeHref = locale === 'en' ? '/' : `/${locale}/`

Expand Down
Loading