From 52e8c902e4ac86f85e06488ab86bc988af0a8f04 Mon Sep 17 00:00:00 2001 From: twosugar Date: Mon, 26 Oct 2026 15:10:32 +0800 Subject: [PATCH 1/2] feat(pricing): hide pricing entries and paid quote plans for US users Longbridge US does not sell these OpenAPI quote packages, but the navbar "Pricing", the homepage "See pricing" button and the "Subscribe only to what you need" plans on /pricing were shown to everyone (with HK$ prices). Adds isUSUser() in @longbridge/openapi-utils, decided in order: 1. logged in: app_id cookie is a US tenant (longbridge_us / _uat) 2. not logged in: region cookie is US (case-insensitive) 3. no region cookie: x-ip-region header from the portal /api/_ping Result is cached per page; any failure counts as non-US, so the page keeps its current content. The three entries hide when isUSUser() resolves true. The free tier, comparison table and FAQ on /pricing are unchanged. Co-Authored-By: Claude Opus 5.5 --- packages/homepage/src/sections/CtaSection.tsx | 15 ++++-- packages/utils/src/index.ts | 1 + packages/utils/src/us-user.ts | 54 +++++++++++++++++++ src/components/mdx/Pricing.tsx | 10 +++- src/components/shell/TopNav.tsx | 16 ++++-- 5 files changed, 87 insertions(+), 9 deletions(-) create mode 100644 packages/utils/src/us-user.ts 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..e998333b 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 ─────────────────────────────────────────────────────────────── @@ -340,6 +341,11 @@ function getPlanCycle(plan: { cycles: Record }, cycleKey: str 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 [isUS, setIsUS] = useState(false) + + useEffect(() => { + isUSUser().then(setIsUS) + }, []) // Detect locale from URL pathname on client (fallback / safety net) useEffect(() => { @@ -452,7 +458,8 @@ export function Pricing({ locale: initialLocale = 'en' }: { locale?: 'en' | 'zh- - {/* Paid Plans */} + {/* Paid Plans(美国用户隐藏) */} + {!isUS && (
+ )} {/* 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}/` From 7439e65c6704141d6d51a1682beed46df85499ed Mon Sep 17 00:00:00 2001 From: twosugar Date: Mon, 26 Oct 2026 15:10:32 +0800 Subject: [PATCH 2/2] feat(pricing): load paid quote plan prices from the quote store API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The paid plans had hard-coded HK$ prices that no longer matched the store (e.g. HK LV2 OpenAPI monthly 718 on the page vs 498 in the store). Prices now come from /v2/rewards/quote_product_list (no login needed, x-app-id: longbridge, account_channel=lb), matched by package_key (HK_L2_International_OpenAPI, US_OPRA_LV1_OpenAPI): - currency comes from each SKU (no hard-coded symbol) - current price is real_price; per_month_price is shown for quarterly and annual; when price > real_price the discount and original price are shown - billing cycle tabs are built from card_duration (1/2/3 = month / quarter / year, 5 = auto-renew) and only include cycles the API returns; the default is the first available one - while loading or if the request fails, prices show "—" Plan names, taglines and feature lists stay as static copy. Co-Authored-By: Claude Opus 5.5 --- src/components/mdx/Pricing.tsx | 129 ++++++++++++++++++--------------- 1 file changed, 71 insertions(+), 58 deletions(-) diff --git a/src/components/mdx/Pricing.tsx b/src/components/mdx/Pricing.tsx index e998333b..82a85526 100644 --- a/src/components/mdx/Pricing.tsx +++ b/src/components/mdx/Pricing.tsx @@ -225,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)' }, @@ -264,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], @@ -332,19 +339,18 @@ 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) @@ -357,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>( () => ({ @@ -485,15 +495,15 @@ export function Pricing({ locale: initialLocale = 'en' }: { locale?: 'en' | 'zh-
{content.billing.label}
- {BILLING_CYCLES.map((c) => ( + {availableCycles.map((c) => ( @@ -504,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 (
@@ -521,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}