diff --git a/packages/homepage/src/sections/CtaSection.tsx b/packages/homepage/src/sections/CtaSection.tsx index 9d2465dc..c2701b75 100644 --- a/packages/homepage/src/sections/CtaSection.tsx +++ b/packages/homepage/src/sections/CtaSection.tsx @@ -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 @@ -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 ( @@ -79,9 +84,11 @@ export function CtaSection({ locale }: CtaSectionProps) { - - {content.btn2} - + {!isUS && ( + + {content.btn2} + + )} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 70e81257..b117027f 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -2,3 +2,4 @@ export * from './slug' export * from './region' export * from './navigation' export * from './i18n' +export * from './us-user' diff --git a/packages/utils/src/us-user.ts b/packages/utils/src/us-user.ts new file mode 100644 index 00000000..883d347f --- /dev/null +++ b/packages/utils/src/us-user.ts @@ -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 | 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 { + 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 { + 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 +} diff --git a/src/components/mdx/Pricing.tsx b/src/components/mdx/Pricing.tsx index 1abfb020..82a85526 100644 --- a/src/components/mdx/Pricing.tsx +++ b/src/components/mdx/Pricing.tsx @@ -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 ─────────────────────────────────────────────────────────────── @@ -224,38 +225,51 @@ 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, - }, - { - 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, - }, -] +// 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 | 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 = {} + 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)' }, @@ -263,12 +277,6 @@ const FREE_ICONS = [ { 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], @@ -331,15 +339,19 @@ const CheckCircleIcon = () => ( // ── Helper ──────────────────────────────────────────────────────────────────── -function getPlanCycle(plan: { cycles: Record }, 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(null) + const [skus, setSkus] = useState | 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(() => { @@ -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>( () => ({ @@ -452,7 +468,8 @@ export function Pricing({ locale: initialLocale = 'en' }: { locale?: 'en' | 'zh- - {/* Paid Plans */} + {/* Paid Plans(美国用户隐藏) */} + {!isUS && (
{content.billing.label}
- {BILLING_CYCLES.map((c) => ( + {availableCycles.map((c) => ( @@ -497,7 +514,10 @@ export function Pricing({ locale: initialLocale = 'en' }: { locale?: 'en' | 'zh-
{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 (
@@ -514,20 +534,20 @@ export function Pricing({ locale: initialLocale = 'en' }: { locale?: 'en' | 'zh-

{p.name}

- {p.currency} - {pc.price} - {cycleSuffix} - {pc.discount && ( - -{pc.discount}% - )} + {sku?.currency} + {sku ? sku.real_price : '—'} + {sku && {cycleSuffix}} + {discount > 0 && -{discount}%}
- {pc.approxMo ? ( + {showApprox ? (
- ≈{p.currency}{pc.approxMo}{content.suffix.mo} + ≈{sku.currency} {sku.per_month_price} + {content.suffix.mo}
- ) : pc.discount ? ( + ) : null} + {discount > 0 ? (
- {content.plan.was} {p.currency}{p.cycles.monthly.price}{content.suffix.mo} + {content.plan.was} {sku?.currency} {sku?.price}
) : null}

{p.tagline}

@@ -566,6 +586,7 @@ export function Pricing({ locale: initialLocale = 'en' }: { locale?: 'en' | 'zh-
+ )} {/* Feature Matrix */}
diff --git a/src/components/shell/TopNav.tsx b/src/components/shell/TopNav.tsx index 92ce3541..ac97bb71 100644 --- a/src/components/shell/TopNav.tsx +++ b/src/components/shell/TopNav.tsx @@ -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' @@ -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}/`