diff --git a/.env.example b/.env.example index 55edf36..ca08874 100644 --- a/.env.example +++ b/.env.example @@ -27,3 +27,12 @@ CLOUDFLARE_WEB_ANALYTICS_SITE_TAG= # Root email whitelist for privileged user-creation actions (comma-separated) # Default: admin@svc.plus ROOT_EMAIL_WHITELIST=admin@svc.plus + +# Stripe public price ids used by /prices, product pages, and /panel/subscription +# These values are safe to expose to the browser. Use Stripe test-mode price ids for local/dev. +NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_PAYGO= +NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_SUBSCRIPTION= +NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_PAYGO= +NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_SUBSCRIPTION= +NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_PAYGO= +NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION= diff --git a/README.md b/README.md index cf842f5..bc7317a 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,21 @@ cp .env.example .env 更多说明见 `docs/getting-started/installation.md` 和 `.env.example`。 +## Stripe 配置 (Stripe Billing Setup) + +`/prices`、产品页和账户中心的购买入口现在统一读取前端公开的 Stripe `price_id`: + +| 变量 | 用途 | +| -------------------------------------------------- | ------------------- | +| `NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_PAYGO` | Xstream 按量购买 | +| `NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_SUBSCRIPTION` | Xstream 订阅 | +| `NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_PAYGO` | XScopeHub 按量购买 | +| `NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_SUBSCRIPTION` | XScopeHub 订阅 | +| `NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_PAYGO` | XCloudFlow 按量购买 | +| `NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION` | XCloudFlow 订阅 | + +这些值应填写为 Stripe Dashboard 中对应套餐的 `price_...` 标识。联调步骤见 `docs/integrations/stripe-billing.md`。 + ## 核心特性 & 技术栈 (Features & Tech Stack) 核心特性: @@ -107,6 +122,7 @@ yarn typecheck - OIDC: `docs/integrations/oidc-auth.md` - Cloudflare Web Analytics: `docs/integrations/cloudflare-web-analytics.md` +- Stripe billing: `docs/integrations/stripe-billing.md` - Assistant / Integrations env setup: `docs/getting-started/installation.md` - Chinese installation guide: `docs/zh/getting-started/installation.md` diff --git a/docs/integrations/stripe-billing.md b/docs/integrations/stripe-billing.md new file mode 100644 index 0000000..6d6ee01 --- /dev/null +++ b/docs/integrations/stripe-billing.md @@ -0,0 +1,51 @@ +# Stripe Billing Integration + +This console now routes all purchase entry points through Stripe: + +- `/prices` +- product detail pages +- `/panel/subscription` + +The browser only needs public Stripe `price_id` values. Secret keys stay in `accounts.svc.plus`. + +## Required Environment Variables + +Set these in `console.svc.plus`: + +```bash +NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_PAYGO=price_xxx +NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_SUBSCRIPTION=price_xxx +NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_PAYGO=price_xxx +NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_SUBSCRIPTION=price_xxx +NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_PAYGO=price_xxx +NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION=price_xxx +``` + +If a value is missing, the related purchase button stays visible but reports that Stripe pricing is not configured. + +## Local Integration Checklist + +1. Configure all `NEXT_PUBLIC_STRIPE_PRICE_*` values with Stripe test-mode `price_...` ids. +2. Start `accounts.svc.plus` with Stripe server-side settings. +3. Start this console with `yarn dev`. +4. Sign in with a normal user account. +5. Open `/prices` or `/panel/subscription` and start checkout. +6. Complete a Stripe test payment. +7. Confirm the browser returns to `/panel/subscription?checkout=success...`. +8. Confirm the subscription record appears in the subscription panel. +9. Open "Manage Stripe billing" and confirm the customer portal opens. + +## Expected Flow + +1. The console calls `/api/auth/stripe/checkout`. +2. The BFF proxies the request to `accounts.svc.plus` using the current account session. +3. `accounts.svc.plus` creates the Stripe Checkout Session. +4. Stripe redirects back to the console. +5. Stripe webhooks update the account service subscription record. +6. The console reads the final state from `/api/auth/subscriptions`. + +## Notes + +- The console does not store Stripe secret keys. +- Sensitive payment methods such as crypto QR flows are intentionally removed from the purchase UI. +- Use Stripe test mode first; do not validate this flow against live prices until webhook delivery is confirmed. diff --git a/src/app/(auth)/login/LoginContent.tsx b/src/app/(auth)/login/LoginContent.tsx index 557a520..e68ace4 100644 --- a/src/app/(auth)/login/LoginContent.tsx +++ b/src/app/(auth)/login/LoginContent.tsx @@ -57,6 +57,7 @@ export default function LoginContent({ const errorParam = searchParams.get("error"); const registeredParam = searchParams.get("registered"); const setupMfaParam = searchParams.get("setupMfa"); + const redirectParam = searchParams.get("redirect"); const normalize = useCallback( (value: string) => @@ -87,7 +88,10 @@ export default function LoginContent({ } setIsSubmitting(true); - setAlert({ type: "success", message: alerts.submit ?? "Authenticating..." }); + setAlert({ + type: "success", + message: alerts.submit ?? "Authenticating...", + }); const exchangeToken = async () => { try { @@ -319,7 +323,11 @@ export default function LoginContent({ const data: { redirectTo?: string } = await response .json() .catch(() => ({})); - router.push(data?.redirectTo || "/"); + const redirectTarget = + redirectParam && redirectParam.startsWith("/") + ? redirectParam + : undefined; + router.push(redirectTarget || data?.redirectTo || "/"); router.refresh(); } catch (error) { console.error("Failed to submit login request", error); @@ -328,7 +336,14 @@ export default function LoginContent({ setIsSubmitting(false); } }, - [alerts, deriveSameOriginLoginFallback, isSubmitting, normalize, router], + [ + alerts, + deriveSameOriginLoginFallback, + isSubmitting, + normalize, + redirectParam, + router, + ], ); const socialButtons = useMemo(() => { diff --git a/src/app/api/auth/stripe/checkout/route.ts b/src/app/api/auth/stripe/checkout/route.ts new file mode 100644 index 0000000..d8101c9 --- /dev/null +++ b/src/app/api/auth/stripe/checkout/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { getAccountSession } from "@server/account/session"; +import { getAccountServiceApiBaseUrl } from "@server/serviceConfig"; + +const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl(); + +export async function POST(request: NextRequest) { + const session = await getAccountSession(request); + if (!session.user || !session.token) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + + const payload = await request.json().catch(() => ({})); + const response = await fetch(`${ACCOUNT_API_BASE}/stripe/checkout`, { + method: "POST", + headers: { + Authorization: `Bearer ${session.token}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(payload), + cache: "no-store", + }); + + const data = await response.json().catch(() => ({})); + return NextResponse.json(data, { status: response.status }); +} diff --git a/src/app/api/auth/stripe/portal/route.ts b/src/app/api/auth/stripe/portal/route.ts new file mode 100644 index 0000000..d271f06 --- /dev/null +++ b/src/app/api/auth/stripe/portal/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { getAccountSession } from "@server/account/session"; +import { getAccountServiceApiBaseUrl } from "@server/serviceConfig"; + +const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl(); + +export async function POST(request: NextRequest) { + const session = await getAccountSession(request); + if (!session.user || !session.token) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + + const payload = await request.json().catch(() => ({})); + const response = await fetch(`${ACCOUNT_API_BASE}/stripe/portal`, { + method: "POST", + headers: { + Authorization: `Bearer ${session.token}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(payload), + cache: "no-store", + }); + + const data = await response.json().catch(() => ({})); + return NextResponse.json(data, { status: response.status }); +} diff --git a/src/app/prices/page.tsx b/src/app/prices/page.tsx index ea849be..b01e982 100644 --- a/src/app/prices/page.tsx +++ b/src/app/prices/page.tsx @@ -1,169 +1,269 @@ "use client"; -import React from "react"; -import UnifiedNavigation from "../../components/UnifiedNavigation"; -import Footer from "../../components/Footer"; -import { Check, Shield } from "lucide-react"; +import React, { useState } from "react"; import Link from "next/link"; +import { Check, Shield } from "lucide-react"; + +import CheckoutStatusBanner from "@components/billing/CheckoutStatusBanner"; +import { startStripeCheckout } from "@components/billing/stripe-client"; +import Footer from "../../components/Footer"; +import UnifiedNavigation from "../../components/UnifiedNavigation"; import { useLanguage } from "../../i18n/LanguageProvider"; +import { PRODUCT_LIST, type BillingPlan } from "@modules/products/registry"; + +type PricingCard = { + key: string; + productSlug?: string; + name: string; + price: string; + period?: string; + description: string; + features: string[]; + button: string; + highlight?: boolean; + href?: string; + billingPlan?: BillingPlan; +}; export default function PricesPage() { - const { language } = useLanguage(); - const isChinese = language === "zh"; + const { language } = useLanguage(); + const isChinese = language === "zh"; + const [statusMessage, setStatusMessage] = useState(null); + const billingCards: PricingCard[] = PRODUCT_LIST.flatMap((product) => { + const cards: PricingCard[] = []; + if (product.billing?.saas) { + cards.push({ + key: `${product.slug}-subscription`, + productSlug: product.slug, + name: `${product.name} ${isChinese ? "订阅版" : "Subscription"}`, + price: `$${product.billing.saas.price.toFixed(2)}`, + period: product.billing.saas.interval + ? `/${product.billing.saas.interval}` + : undefined, + description: product.billing.saas.description || "", + features: [ + isChinese ? "Stripe 自动续费" : "Recurring billing with Stripe", + isChinese + ? "购买后自动同步到账户" + : "Syncs back to your account automatically", + isChinese + ? "支持客户门户管理账单" + : "Manage billing in Stripe customer portal", + ], + button: isChinese ? "使用 Stripe 订阅" : "Subscribe with Stripe", + highlight: product.slug === "xstream", + billingPlan: product.billing.saas, + }); + } + if (product.billing?.paygo) { + cards.push({ + key: `${product.slug}-paygo`, + productSlug: product.slug, + name: `${product.name} ${isChinese ? "按量版" : "Pay as you go"}`, + price: `$${product.billing.paygo.price.toFixed(2)}`, + description: product.billing.paygo.description || "", + features: [ + isChinese ? "一次性 Stripe 结算" : "One-time Stripe checkout", + isChinese ? "适合弹性使用场景" : "Fits bursty or flexible usage", + isChinese + ? "订单自动写入账户中心" + : "Orders sync into your account center", + ], + button: isChinese ? "使用 Stripe 购买" : "Buy with Stripe", + billingPlan: product.billing.paygo, + }); + } + return cards; + }); - const content = { - title: isChinese ? "简单透明的价格" : "Simple, Transparent Pricing", - subtitle: isChinese - ? "选择适合您的计划,开启云原生之旅" - : "Choose the plan that's right for you and your team.", - plans: [ - { - name: isChinese ? "开源版 (Self-Host)" : "Open Source (Self-Host)", - price: isChinese ? "免费" : "Free", - period: isChinese ? "/永久" : "/forever", - description: isChinese ? "适合个人开发者,完全自主掌控" : "For individual developers, fully self-controlled", - features: isChinese - ? ["全套开源代码 (Apache 2.0)", "私有化部署 (Self-Hosted)", "基础 CI/CD 模板", "社区技术支持"] - : ["Full Open Source Code (Apache 2.0)", "Private Deployment (Self-Hosted)", "Basic CI/CD Templates", "Community Support"], - button: isChinese ? "下载" : "Download", - highlight: false, - href: "/download" - }, - { - name: isChinese ? "云端共享 (Cloud)" : "Cloud Shared", - price: "$1.9", - period: isChinese ? "起 / 月" : "/mo", - description: isChinese ? "零运维的云原生体验" : "Zero-ops cloud native experience", - features: isChinese - ? ["共享资源起步", "零运维体验", "适合初学者"] - : ["Shared resources start", "Zero-ops experience", "For beginners"], - button: isChinese ? "开始使用" : "Get Started", - highlight: false, - href: "/register" - }, - { - name: isChinese ? "基础版 (Basic)" : "Basic", - price: "$9.9", - period: isChinese ? "/ 月" : "/mo", - description: isChinese ? "托管式共享节点,省心省力" : "Managed shared nodes, hassle-free", - features: isChinese - ? ["托管式共享节点", "公开项目托管", "基础资源配额"] - : ["Managed shared nodes", "Public project hosting", "Basic resource quota"], - button: isChinese ? "选择计划" : "Choose Plan", - highlight: false, - href: "/register" - }, - { - name: isChinese ? "专业版 (Pro)" : "Pro", - price: "$19.9", - period: isChinese ? "/ 月" : "/mo", - description: isChinese ? "专为专业开发者打造" : "Built for professional developers", - features: isChinese - ? ["优先技术支持", "专属 AI 编程助手", "独享高性能节点", "全链路可信访问环境"] - : ["Priority Support", "Exclusive AI Coding Assistant", "Dedicated High-Performance Nodes", "Full-link Trusted Access Environment"], - button: isChinese ? "Stripe 注册验证" : "Verify with Stripe", - highlight: true, - href: "https://stripe.com" - }, - { - name: isChinese ? "定制版本" : "Custom Version", - price: isChinese ? "定制" : "Custom", - description: isChinese ? "量身定制的企业级解决方案" : "Tailored enterprise solutions", - features: isChinese - ? ["SLA 保证", "专属 1V1 支持", "SSO 单点登录", "量身定制解决方案", "包含专业版所有功能"] - : ["SLA Guarantee", "Exclusive 1V1 Support", "SSO Single Sign-On", "Tailored Solutions", "Includes all Pro features"], - button: isChinese ? "联系我们" : "Contact Sales", - highlight: false, - href: "/support" - } - ] - }; + const extraCards: PricingCard[] = [ + { + key: "open-source", + name: isChinese ? "开源版 (Self-Host)" : "Open Source (Self-Host)", + price: isChinese ? "免费" : "Free", + period: isChinese ? "/永久" : "/forever", + description: isChinese + ? "适合自托管团队,完全自主掌控。" + : "Best for self-hosted teams with full control.", + features: isChinese + ? ["开源代码", "私有化部署", "社区支持"] + : ["Open source code", "Self-host deployment", "Community support"], + button: isChinese ? "下载" : "Download", + href: "/download", + }, + { + key: "custom", + name: isChinese ? "定制版本" : "Custom Version", + price: isChinese ? "定制" : "Custom", + description: isChinese + ? "企业客户可联系销售获取定制交付。" + : "Contact sales for enterprise deployment and support.", + features: isChinese + ? ["企业支持", "专属交付", "定制 SLA"] + : ["Enterprise support", "Tailored delivery", "Custom SLA"], + button: isChinese ? "联系我们" : "Contact Sales", + href: "/support", + }, + ]; - return ( -
- + const cards = [...billingCards, ...extraCards]; -
-
+ const handleCheckout = async (card: PricingCard) => { + if ( + !card.billingPlan?.planId || + !card.billingPlan?.stripePriceId || + !card.productSlug + ) { + setStatusMessage( + isChinese + ? "该套餐尚未配置 Stripe 价格。" + : "Stripe pricing is not configured for this plan.", + ); + return; + } -
-
-

- {content.title} -

-

- {content.subtitle} -

-
+ try { + setStatusMessage(null); + await startStripeCheckout({ + planId: card.billingPlan.planId, + stripePriceId: card.billingPlan.stripePriceId, + mode: card.billingPlan.mode, + productSlug: card.productSlug, + sourcePath: "/prices", + }); + } catch (error) { + console.warn("Failed to start Stripe checkout", error); + setStatusMessage( + isChinese + ? "暂时无法跳转到 Stripe 结算。" + : "Unable to start Stripe checkout right now.", + ); + } + }; -
- {content.plans.map((plan, index) => ( -
- {plan.highlight && ( -
- {isChinese ? "推荐" : "Recommended"} -
- )} + return ( +
+ -
-

{plan.name}

-
- {plan.price} - {plan.period && {plan.period}} -
-

{plan.description}

-
+
+
-
- {plan.features.map((feature, i) => ( -
-
- -
- {feature} -
- ))} -
+
+
+

+ {isChinese ? "Stripe 统一定价" : "Stripe Unified Pricing"} +

+

+ {isChinese + ? "所有在线购买统一通过 Stripe 完成,历史敏感支付方式入口已移除。" + : "All online purchases now run through Stripe. Sensitive payment options have been removed."} +

+
- - {plan.button} - -
- ))} -
+ + {statusMessage ? ( +

+ {statusMessage} +

+ ) : null} -
-
- - {isChinese ? "所有支付由 Stripe 安全处理" : "Payments secured by Stripe"} -
-
+
+ {cards.map((card) => ( +
+ {card.highlight ? ( +
+ {isChinese ? "推荐" : "Recommended"} +
+ ) : null} + +
+

+ {card.name} +

+
+ + {card.price} + + {card.period ? ( + + {card.period} + + ) : null} +
+

+ {card.description} +

-
-
+
+ {card.features.map((feature) => ( +
+
+ +
+ + {feature} + +
+ ))} +
+ + {card.billingPlan ? ( + + ) : ( + + {card.button} + + )} +
+ ))} +
+ +
+
+ + {isChinese + ? "所有支付由 Stripe 安全处理" + : "Payments secured by Stripe"} +
+
- ); +
+ +
+
+ ); } diff --git a/src/components/UnifiedNavigation.tsx b/src/components/UnifiedNavigation.tsx index 2eb9e6b..f281d70 100644 --- a/src/components/UnifiedNavigation.tsx +++ b/src/components/UnifiedNavigation.tsx @@ -32,6 +32,7 @@ const getLabel = ( export default function UnifiedNavigation() { const pathname = usePathname(); const [menuOpen, setMenuOpen] = useState(false); + const [useMobileDrawer, setUseMobileDrawer] = useState(false); const [selectedChannels, setSelectedChannels] = useState([ "stable", ]); @@ -113,6 +114,36 @@ export default function UnifiedNavigation() { setAccountMenuOpen(false); }, [user]); + useEffect(() => { + setMenuOpen(false); + }, [pathname]); + + useEffect(() => { + if (typeof document === "undefined") { + return; + } + + document.body.style.overflow = menuOpen ? "hidden" : ""; + + return () => { + document.body.style.overflow = ""; + }; + }, [menuOpen]); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + const userAgent = window.navigator.userAgent || ""; + const platform = window.navigator.platform || ""; + const touchPoints = window.navigator.maxTouchPoints || 0; + const mobileOS = + /Android|iPhone|iPad|iPod/i.test(userAgent) || + (platform === "MacIntel" && touchPoints > 1); + setUseMobileDrawer(mobileOS); + }, []); + useEffect(() => { if (typeof window === "undefined") { return; @@ -167,13 +198,13 @@ export default function UnifiedNavigation() { const isHiddenRoute = pathname ? [ - "/login", - "/register", - "/xstream", - "/xcloudflow", - "/xscopehub", - "/blogs", - ].some((prefix) => pathname.startsWith(prefix)) + "/login", + "/register", + "/xstream", + "/xcloudflow", + "/xscopehub", + "/blogs", + ].some((prefix) => pathname.startsWith(prefix)) : false; if (isHiddenRoute) { @@ -197,10 +228,27 @@ export default function UnifiedNavigation() { }} className="sticky top-0 z-50 w-full border-b border-surface-border bg-background/95 text-text backdrop-blur transition-colors duration-150" > -
+
+ setMenuOpen(false)} + > + logo + + Cloud-Neutral + + -
@@ -219,33 +266,35 @@ export default function UnifiedNavigation() { {filteredMainNav.map((item) => { const active = isActive(item); if (item.showOn === "mobile") return null; - if (item.key === 'chat') { + if (item.key === "chat") { return ( - ) + ); } return ( {item.icon && } @@ -261,10 +310,11 @@ export default function UnifiedNavigation() { {item.icon && } @@ -285,7 +335,10 @@ export default function UnifiedNavigation() { onToggle={toggleChannel} variant="icon" /> - + @@ -426,7 +489,7 @@ export default function UnifiedNavigation() {
)} -
+

{isChinese ? "主导航" : "Main Navigation"}

@@ -434,7 +497,7 @@ export default function UnifiedNavigation() { {filteredMainNav.map((item) => { const active = isActive(item); if (item.showOn === "desktop") return null; - if (item.key === 'chat') { + if (item.key === "chat") { return ( - ) + ); } return ( setMenuOpen(false)} > {item.icon && ( )} - - {getLabel(item.label, language)} - + {getLabel(item.label, language)} ); })} @@ -490,18 +551,17 @@ export default function UnifiedNavigation() { setMenuOpen(false)} > {item.icon && ( )} - - {getLabel(item.label, language)} - + {getLabel(item.label, language)} ); })} @@ -517,10 +577,11 @@ export default function UnifiedNavigation() { setMenuOpen(false)} > {typeof item.label === "function" @@ -531,7 +592,7 @@ export default function UnifiedNavigation() {
-
+

{isChinese ? "设置" : "Settings"} diff --git a/src/components/billing/CheckoutStatusBanner.tsx b/src/components/billing/CheckoutStatusBanner.tsx new file mode 100644 index 0000000..fd583a7 --- /dev/null +++ b/src/components/billing/CheckoutStatusBanner.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useMemo } from "react"; +import { useSearchParams } from "next/navigation"; + +type CheckoutStatusBannerProps = { + className?: string; +}; + +export default function CheckoutStatusBanner({ + className, +}: CheckoutStatusBannerProps) { + const searchParams = useSearchParams(); + const checkoutStatus = searchParams.get("checkout"); + + const message = useMemo(() => { + switch (checkoutStatus) { + case "success": + return { + tone: "border-emerald-200 bg-emerald-50 text-emerald-800", + text: "Stripe 支付已完成,订阅状态正在同步到账户。", + }; + case "cancelled": + return { + tone: "border-amber-200 bg-amber-50 text-amber-800", + text: "你已取消本次 Stripe 结算,当前未产生新扣费。", + }; + default: + return null; + } + }, [checkoutStatus]); + + if (!message) { + return null; + } + + return ( +

+ {message.text} +
+ ); +} diff --git a/src/components/billing/CryptoBillingWidget.tsx b/src/components/billing/CryptoBillingWidget.tsx deleted file mode 100644 index 8716d25..0000000 --- a/src/components/billing/CryptoBillingWidget.tsx +++ /dev/null @@ -1,116 +0,0 @@ -'use client' - -import { useMemo, useState } from 'react' -import Image from 'next/image' - -import type { BillingPaymentMethod } from '@modules/products/registry' - -type CryptoBillingWidgetProps = { - method: BillingPaymentMethod - planName: string - planId?: string - kind: 'paygo' | 'subscription' - productSlug?: string - onRecord?: (payload: { - externalId: string - status?: string - paymentQr?: string - meta?: Record - }) => void -} - -export default function CryptoBillingWidget({ - method, - planName, - planId, - kind, - productSlug, - onRecord, -}: CryptoBillingWidgetProps) { - const [copied, setCopied] = useState(false) - - const label = useMemo(() => method.label || method.type.toUpperCase(), [method.label, method.type]) - const address = method.address?.trim() - const network = method.network?.trim() - const qrCode = method.qrCode?.trim() - - const handleCopy = async () => { - if (!address || typeof navigator === 'undefined' || !navigator.clipboard?.writeText) return - try { - await navigator.clipboard.writeText(address) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } catch (err) { - console.warn('Failed to copy payment address', err) - } - } - - const handleRecord = () => { - if (!onRecord) return - const externalId = `${method.type}-${planId || kind}-${Date.now()}` - onRecord({ - externalId, - status: 'pending', - paymentQr: qrCode, - meta: { - paymentMethod: method.type, - address, - network, - instructions: method.instructions, - planName, - productSlug, - }, - }) - } - - return ( -
-
-
-

{label}

- {network ?

网络 / Network: {network}

: null} -
- {qrCode ? 扫码支付 : null} -
- - {method.instructions ? ( -

{method.instructions}

- ) : ( -

扫码或复制地址完成支付后,点击同步到账户。

- )} - - {address ? ( -
-
- - {address} - - -
-
- ) : null} - - {qrCode ? ( -
- {`${label} -
- ) : null} - -
- -
-
- ) -} diff --git a/src/components/billing/stripe-client.ts b/src/components/billing/stripe-client.ts new file mode 100644 index 0000000..0dbb258 --- /dev/null +++ b/src/components/billing/stripe-client.ts @@ -0,0 +1,76 @@ +"use client"; + +type StripeCheckoutPayload = { + planId: string; + stripePriceId: string; + mode: "payment" | "subscription"; + productSlug: string; + sourcePath?: string; +}; + +type StripePortalPayload = { + returnPath?: string; +}; + +function buildLoginUrl(): string { + if (typeof window === "undefined") { + return "/login"; + } + const redirect = `${window.location.pathname}${window.location.search}${window.location.hash}`; + return `/login?redirect=${encodeURIComponent(redirect)}`; +} + +async function postJson( + url: string, + payload: Record, +): Promise { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + credentials: "include", + body: JSON.stringify(payload), + }); + + if (response.status === 401) { + window.location.href = buildLoginUrl(); + throw new Error("authentication_required"); + } + + const data = (await response.json().catch(() => ({}))) as TResponse & { + error?: string; + message?: string; + }; + if (!response.ok) { + throw new Error(data.message || data.error || "request_failed"); + } + return data; +} + +export async function startStripeCheckout( + payload: StripeCheckoutPayload, +): Promise { + const data = await postJson<{ url?: string }>( + "/api/auth/stripe/checkout", + payload, + ); + if (!data.url) { + throw new Error("stripe_checkout_url_missing"); + } + window.location.href = data.url; +} + +export async function openStripePortal( + payload: StripePortalPayload = {}, +): Promise { + const data = await postJson<{ url?: string }>( + "/api/auth/stripe/portal", + payload, + ); + if (!data.url) { + throw new Error("stripe_portal_url_missing"); + } + window.location.href = data.url; +} diff --git a/src/components/marketing/ProductBillingActions.tsx b/src/components/marketing/ProductBillingActions.tsx index 4cc4a7f..695a5b1 100644 --- a/src/components/marketing/ProductBillingActions.tsx +++ b/src/components/marketing/ProductBillingActions.tsx @@ -1,263 +1,177 @@ -'use client' +"use client"; -import { useCallback, useMemo, useState } from 'react' -import Link from 'next/link' +import { useState } from "react"; +import Link from "next/link"; -import { PayPalPayGoButton, PayPalSubscriptionButton } from '@components/billing/PayPalButtons' -import { resolveBillingClientId } from '@components/billing/utils' -import CryptoBillingWidget from '@components/billing/CryptoBillingWidget' -import type { BillingPaymentMethod, ProductConfig } from '@modules/products/registry' +import CheckoutStatusBanner from "@components/billing/CheckoutStatusBanner"; +import { startStripeCheckout } from "@components/billing/stripe-client"; +import type { BillingPlan, ProductConfig } from "@modules/products/registry"; type ProductBillingActionsProps = { - config: ProductConfig - lang: 'zh' | 'en' + config: ProductConfig; + lang: "zh" | "en"; +}; + +type BillingCardProps = { + config: ProductConfig; + lang: "zh" | "en"; + title: string; + linkLabel: string; + kind: "paygo" | "subscription"; + plan: BillingPlan; +}; + +function BillingCard({ + config, + lang, + title, + linkLabel, + kind, + plan, +}: BillingCardProps) { + const [statusMessage, setStatusMessage] = useState(null); + + const handleCheckout = async () => { + if (!plan.planId || !plan.stripePriceId) { + setStatusMessage( + lang === "zh" + ? "Stripe 价格尚未配置。" + : "Stripe pricing is not configured yet.", + ); + return; + } + + try { + setStatusMessage(null); + await startStripeCheckout({ + planId: plan.planId, + stripePriceId: plan.stripePriceId, + mode: plan.mode, + productSlug: config.slug, + sourcePath: `/${config.slug}`, + }); + } catch (error) { + console.warn("Failed to start Stripe checkout", error); + setStatusMessage( + lang === "zh" + ? "无法跳转到 Stripe 结算,请稍后重试。" + : "Failed to start Stripe checkout.", + ); + } + }; + + return ( +
+
+
+

+ {title} +

+

{plan.name}

+

{plan.description}

+

+ {plan.currency} {plan.price.toFixed(2)} + {plan.interval ? ` / ${plan.interval}` : ""} +

+
+ + {linkLabel} + +
+ +
+ +

+ {lang === "zh" + ? "购买前需要先登录,支付状态将自动同步到账户中心。" + : "Sign in before checkout. Subscription state will sync back to your account automatically."} +

+ {statusMessage ? ( +

{statusMessage}

+ ) : null} +
+
+ ); } -export default function ProductBillingActions({ config, lang }: ProductBillingActionsProps) { - const [statusMessage, setStatusMessage] = useState(null) - const billing = config.billing - - const clientId = useMemo(() => { - return resolveBillingClientId(billing?.saas?.clientId || billing?.paygo?.clientId) - }, [billing?.paygo?.clientId, billing?.saas?.clientId]) - - const handleSync = useCallback( - async (payload: { - externalId: string - kind: string - planId?: string - status: string - provider?: string - paymentMethod?: string - paymentQr?: string - meta?: Record - }) => { - try { - setStatusMessage(null) - const response = await fetch('/api/auth/subscriptions', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - provider: payload.provider || 'paypal', - paymentMethod: payload.paymentMethod || payload.provider || 'paypal', - paymentQr: payload.paymentQr, - ...payload, - }), - }) - - if (!response.ok) { - const data = await response.json().catch(() => ({})) - setStatusMessage((data?.message as string) || '同步支付信息失败,请稍后重试。') - return - } - - setStatusMessage(lang === 'zh' ? '支付记录已同步到账户。' : 'Payment synced to your account.') - } catch (error) { - console.warn('Failed to sync subscription', error) - setStatusMessage(lang === 'zh' ? '同步支付记录时出错。' : 'Failed to sync payment record.') - } - }, - [lang], - ) - - if (!billing) { - return null - } - - const paygo = billing.paygo - const saas = billing.saas +export default function ProductBillingActions({ + config, + lang, +}: ProductBillingActionsProps) { + const billing = config.billing; + const paygo = billing?.paygo; + const saas = billing?.saas; if (!paygo && !saas) { - return null + return null; } return ( -
+
-

- {lang === 'zh' ? '支付与订阅' : 'Payments & Subscription'} +

+ {lang === "zh" ? "支付与订阅" : "Payments & Subscription"}

- {lang === 'zh' - ? '直接在产品页面完成 PayPal / 以太坊 / USDT 支付与扫码,记录会同步到账户中心。' - : 'Complete PayPal, Ethereum, or USDT checkout with QR support and keep records in your account.'} + {lang === "zh" + ? "所有购买入口统一使用 Stripe 结算,支付结果会自动同步到账户中心。" + : "All purchase flows run through Stripe and sync back to your account automatically."}

- {clientId - ? lang === 'zh' - ? '使用 PayPal / 以太坊 / USDT 安全结算与扫码' - : 'PayPal, Ethereum, and USDT checkout with QR support' - : lang === 'zh' - ? '尚未配置 PayPal Client ID' - : 'PayPal Client ID is not configured'} + {lang === "zh" ? "Stripe 安全结算" : "Secure Stripe checkout"}
+ +
{paygo ? ( -
-
-
-

Pay-as-you-go

-

{paygo.name}

-

{paygo.description}

-

- {paygo.currency} {paygo.price.toFixed(2)} -

-
- - {lang === 'zh' ? '查看方案' : 'View editions'} - -
- -
- - handleSync({ - externalId: orderId, - kind: 'paygo', - planId: paygo.planId, - status: 'active', - provider: 'paypal', - paymentMethod: 'paypal', - meta: { ...paygo.meta, product: config.slug, paypal: data }, - }) - } - /> - - {paygo.paymentMethods?.length ? ( -
-

- {lang === 'zh' - ? '支持 PayPal / 以太坊 / USDT 扫码记录:' - : 'QR checkout for PayPal, Ethereum, and USDT:'} -

-
- {paygo.paymentMethods.map((method: BillingPaymentMethod) => ( - - handleSync({ - externalId: details.externalId, - kind: 'paygo', - planId: paygo.planId, - status: details.status || 'pending', - provider: method.type, - paymentMethod: method.type, - paymentQr: details.paymentQr, - meta: { ...paygo.meta, ...details.meta, product: config.slug }, - }) - } - /> - ))} -
-
- ) : null} -
-
+ ) : null} - {saas ? ( -
-
-
-

SaaS

-

{saas.name}

-

{saas.description}

-

- {saas.currency} {saas.price.toFixed(2)} / {saas.interval ?? 'month'} -

-
- - {lang === 'zh' ? '订阅详情' : 'Subscription details'} - -
- -
- - handleSync({ - externalId: subscriptionId, - kind: 'subscription', - planId: saas.planId, - status: 'active', - provider: 'paypal', - paymentMethod: 'paypal', - meta: { ...saas.meta, product: config.slug, paypal: data }, - }) - } - /> - - {saas.paymentMethods?.length ? ( -
-

- {lang === 'zh' - ? '订阅也可通过 PayPal / 以太坊 / USDT 扫码:' - : 'Subscriptions via PayPal, Ethereum, or USDT QR codes:'} -

-
- {saas.paymentMethods.map((method: BillingPaymentMethod) => ( - - handleSync({ - externalId: details.externalId, - kind: 'subscription', - planId: saas.planId, - status: details.status || 'pending', - provider: method.type, - paymentMethod: method.type, - paymentQr: details.paymentQr, - meta: { ...saas.meta, ...details.meta, product: config.slug }, - }) - } - /> - ))} -
-
- ) : null} -
-
+ ) : null}
- - {statusMessage ? ( -

- {statusMessage} -

- ) : null}
- ) + ); } diff --git a/src/modules/extensions/builtin/user-center/account/BillingOptionsPanel.tsx b/src/modules/extensions/builtin/user-center/account/BillingOptionsPanel.tsx index 357f605..01e35cf 100644 --- a/src/modules/extensions/builtin/user-center/account/BillingOptionsPanel.tsx +++ b/src/modules/extensions/builtin/user-center/account/BillingOptionsPanel.tsx @@ -1,388 +1,141 @@ -'use client' +"use client"; -import { useCallback, useEffect, useMemo, useState } from 'react' -import Image from 'next/image' +import { useMemo, useState } from "react"; -import { PayPalPayGoButton, PayPalSubscriptionButton } from '@components/billing/PayPalButtons' -import { resolveBillingClientId } from '@components/billing/utils' -import Card from '../components/Card' -import type { BillingPaymentMethod, BillingPlan, ProductConfig } from '@modules/products/registry' -import { PRODUCT_LIST } from '@modules/products/registry' - -type SyncPayload = { - externalId: string - kind: string - planId?: string - status: string - provider?: string - paymentMethod?: string - paymentQr?: string - meta?: Record -} +import CheckoutStatusBanner from "@components/billing/CheckoutStatusBanner"; +import { startStripeCheckout } from "@components/billing/stripe-client"; +import Card from "../components/Card"; +import type { BillingPlan, ProductConfig } from "@modules/products/registry"; +import { PRODUCT_LIST } from "@modules/products/registry"; type ProductOption = { - product: ProductConfig - plan: BillingPlan - kind: 'paygo' | 'subscription' - clientId: string -} + product: ProductConfig; + plan: BillingPlan; + kind: "paygo" | "subscription"; +}; -const kindLabel: Record<'paygo' | 'subscription', string> = { - paygo: 'PAY-AS-YOU-GO', - subscription: 'SAAS', -} - -const actionLabel: Record<'paygo' | 'subscription', string> = { - paygo: '立即购买', - subscription: '立即订阅', -} - -const methodHints: Record = { - paypal: '建议使用 PayPal App 扫码,支付成功后系统自动确认订单。', - ethereum: '使用以太坊(ERC20)转账,支付后订单会自动识别并激活。', - usdt: '使用 USDT(TRC20)转账,支付完成后自动续订或开通。', -} +const kindLabel: Record<"paygo" | "subscription", string> = { + paygo: "PAY-AS-YOU-GO", + subscription: "SAAS", +}; export default function BillingOptionsPanel() { - const [statusMessage, setStatusMessage] = useState(null) - const [selected, setSelected] = useState(null) - const primaryButtonClass = - 'inline-flex w-full items-center justify-center rounded-md bg-[var(--color-primary)] px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-[var(--color-primary-strong)]' - const qrClassName = 'mx-auto h-56 w-56 object-contain' - const paymentCardClass = - 'flex h-full flex-col gap-4 rounded-2xl border border-[color:var(--color-surface-border)] bg-[color:var(--color-surface)] p-5 shadow-sm' - - const products = useMemo( - () => PRODUCT_LIST.filter((item) => item.billing && (item.billing.paygo || item.billing.saas)), - [], - ) + const [statusMessage, setStatusMessage] = useState(null); + const [submitting, setSubmitting] = useState(null); const productOptions = useMemo(() => { - const options: ProductOption[] = [] - - products.forEach((product) => { - const paygo = product.billing?.paygo - const saas = product.billing?.saas - const clientId = resolveBillingClientId(saas?.clientId || paygo?.clientId) - - if (paygo) { - options.push({ product, plan: paygo, kind: 'paygo', clientId }) + const options: ProductOption[] = []; + PRODUCT_LIST.forEach((product) => { + if (product.billing?.paygo) { + options.push({ product, plan: product.billing.paygo, kind: "paygo" }); } - - if (saas) { - options.push({ product, plan: saas, kind: 'subscription', clientId }) + if (product.billing?.saas) { + options.push({ + product, + plan: product.billing.saas, + kind: "subscription", + }); } - }) + }); + return options; + }, []); - return options - }, [products]) - - useEffect(() => { - if (!selected && productOptions.length) { - setSelected(productOptions[0]) + const handleCheckout = async (option: ProductOption) => { + if (!option.plan.planId || !option.plan.stripePriceId) { + setStatusMessage("该套餐尚未配置 Stripe price_id。"); + return; } - }, [productOptions, selected]) - const handleSync = useCallback(async (payload: SyncPayload) => { + setSubmitting(option.plan.planId); + setStatusMessage(null); try { - setStatusMessage(null) - const response = await fetch('/api/auth/subscriptions', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - provider: payload.provider || 'paypal', - paymentMethod: payload.paymentMethod || payload.provider || 'paypal', - paymentQr: payload.paymentQr, - ...payload, - }), - }) - - if (!response.ok) { - const data = await response.json().catch(() => ({})) - setStatusMessage((data?.message as string) || '同步支付信息失败,请稍后重试。') - return - } - - setStatusMessage('支付记录已同步到账户。') + await startStripeCheckout({ + planId: option.plan.planId, + stripePriceId: option.plan.stripePriceId, + mode: option.plan.mode, + productSlug: option.product.slug, + sourcePath: "/panel/subscription", + }); } catch (error) { - console.warn('Failed to sync subscription', error) - setStatusMessage('同步支付记录时出错。') + console.warn("Failed to start Stripe checkout", error); + setStatusMessage("无法跳转到 Stripe 结算,请稍后重试。"); + } finally { + setSubmitting(null); } - }, []) + }; - const activeMethods = useMemo(() => { - if (!selected?.plan.paymentMethods) return [] - const order: BillingPaymentMethod['type'][] = ['paypal', 'ethereum', 'usdt'] - return order - .map((type) => selected.plan.paymentMethods?.find((method) => method.type === type)) - .filter(Boolean) as BillingPaymentMethod[] - }, [selected?.plan.paymentMethods]) - - const handleCryptoRecord = useCallback( - (method: BillingPaymentMethod) => { - if (!selected) return - const externalId = `${method.type}-${selected.plan.planId || selected.kind}-${Date.now()}` - - handleSync({ - externalId, - kind: selected.kind, - planId: selected.plan.planId, - status: 'pending', - provider: method.type, - paymentMethod: method.type, - paymentQr: method.qrCode, - meta: { - ...selected.plan.meta, - product: selected.product.slug, - paymentMethod: method.type, - address: method.address, - network: method.network, - instructions: method.instructions, - }, - }) - }, - [handleSync, selected], - ) - - if (!products.length) { - return null - } - - const renderCryptoCard = (method: BillingPaymentMethod) => { - const address = method.address?.trim() - const network = method.network?.trim() - const qrCode = method.qrCode?.trim() - - return ( -
-
-

{method.label || method.type}

-

{method.type === 'ethereum' ? 'ETH(ERC20)' : 'USDT(TRC20)'}

-

{methodHints[method.type]}

-
- - {qrCode ? ( -
- {`${method.label -
- ) : null} - - {address ? ( -
-
- 钱包地址 - {network ? ( - - {network} - - ) : null} -
-
- - {address} - - -
-
- ) : null} - -
- -
-
- ) - } - - const renderPayPalCard = (method: BillingPaymentMethod) => { - if (!selected) return null - - return ( -
-
-

PayPal

-

PayPal 扫码

-

{methodHints.paypal}

-
- - {method.qrCode ? ( -
- PayPal QR -
- ) : null} - -
-
- {selected.kind === 'paygo' ? ( - - handleSync({ - externalId: orderId, - kind: selected.kind, - planId: selected.plan.planId, - status: 'active', - provider: 'paypal', - paymentMethod: 'paypal', - meta: { ...selected.plan.meta, product: selected.product.slug, paypal: data }, - }) - } - /> - ) : ( - - handleSync({ - externalId: subscriptionId, - kind: selected.kind, - planId: selected.plan.planId, - status: 'active', - provider: 'paypal', - paymentMethod: 'paypal', - meta: { ...selected.plan.meta, product: selected.product.slug, paypal: data }, - }) - } - /> - )} -
- -
-
- ) + if (!productOptions.length) { + return null; } return ( -
-
-
-

Product Mode

-

先选产品模式,再挑支付方式

-

- PAY-AS-YOU-GO(流量包)与 SaaS 订阅并排展示,信息分层更清晰,避免混淆。 -

-

- 扫码支付 → 系统自动识别付款 → 自动激活 / 续订订单。 -

-
- {selected?.clientId ? ( -

- PayPal / 加密货币均已启用 -

- ) : ( -

- 尚未配置 PayPal Client ID -

- )} +
+
+

+ Stripe 结算 +

+

+ 所有套餐统一通过 Stripe + 购买。支付完成后,订阅状态会自动同步到账户中心。 +

+ + {statusMessage ? ( +

+ {statusMessage} +

+ ) : null} +
-
- {productOptions.map((option) => { - const isActive = - selected !== null && selected.plan.planId === option.plan.planId && selected.kind === option.kind - const isSubscription = option.kind === 'subscription' - const description = isSubscription - ? '全节点加速 + AI 网络优化。自动续费,可随时取消。' - : '一次购买可叠加,不自动续费。' - return ( -
-
-
-
-

- {kindLabel[option.kind]} -

-

{option.plan.name}

-
- - {isSubscription ? '自动续费' : '一次性购买'} - -
-

{description}

-

- {option.plan.currency} {option.plan.price.toFixed(0)} - {isSubscription && option.plan.interval ? ` / ${option.plan.interval}` : ''} -

-
-
- -
-
- ) - })} -
- - {selected ? ( -
-
-

Payment

-

三种扫码支付并列,统一卡片结构

+
+ {productOptions.map((option) => ( +
+
+

+ {option.product.name} · {kindLabel[option.kind]} +

+

+ {option.plan.name} +

- 标准流程:扫码支付 → 系统自动识别付款 → 自动激活 / 续订订单。二维码统一尺寸,地址区使用等宽字体并提供复制按钮。 + {option.plan.description} +

+

+ {option.plan.currency} {option.plan.price.toFixed(2)} + {option.plan.interval ? ` / ${option.plan.interval}` : ""}

-
- {activeMethods.map((method) => - method.type === 'paypal' ? renderPayPalCard(method) : renderCryptoCard(method), +
+ + {!option.plan.stripePriceId ? ( +

+ 该套餐需要先配置 Stripe price_id。 +

+ ) : ( +

+ 需要登录后购买,支付结果会自动回写到订阅记录。 +

)}
- ) : null} - - {statusMessage ?

{statusMessage}

: null} + ))}
- ) + ); } diff --git a/src/modules/extensions/builtin/user-center/account/SubscriptionPanel.tsx b/src/modules/extensions/builtin/user-center/account/SubscriptionPanel.tsx index bbd0ba9..09789ee 100644 --- a/src/modules/extensions/builtin/user-center/account/SubscriptionPanel.tsx +++ b/src/modules/extensions/builtin/user-center/account/SubscriptionPanel.tsx @@ -1,193 +1,252 @@ -'use client' +"use client"; -import { useCallback, useMemo, useState } from 'react' -import Image from 'next/image' -import useSWR from 'swr' +import { useCallback, useMemo, useState } from "react"; +import useSWR from "swr"; -import Card from '../components/Card' +import { openStripePortal } from "@components/billing/stripe-client"; +import Card from "../components/Card"; const fetcher = (url: string) => fetch(url, { - credentials: 'include', + credentials: "include", headers: { - Accept: 'application/json', + Accept: "application/json", }, - }).then((res) => res.json()) + }).then((res) => res.json()); type SubscriptionRecord = { - id: string - provider: string - kind?: string - planId?: string - status: string - paymentMethod?: string - paymentQr?: string - externalId: string - createdAt?: string - updatedAt?: string - cancelledAt?: string - meta?: Record -} + id: string; + provider: string; + kind?: string; + planId?: string; + status: string; + paymentMethod?: string; + externalId: string; + createdAt?: string; + updatedAt?: string; + cancelledAt?: string; + meta?: Record; +}; type SubscriptionResponse = { - subscriptions?: SubscriptionRecord[] - error?: string - message?: string -} + subscriptions?: SubscriptionRecord[]; + error?: string; + message?: string; +}; function formatDate(value?: string) { - if (!value) return '—' - const date = new Date(value) + if (!value) return "—"; + const date = new Date(value); if (Number.isNaN(date.getTime())) { - return value + return value; } - return date.toLocaleString() + return date.toLocaleString(); } export default function SubscriptionPanel() { - const { data, isLoading, mutate } = useSWR('/api/auth/subscriptions', fetcher) - const [submitting, setSubmitting] = useState(null) - const [error, setError] = useState(null) + const { data, isLoading, mutate } = useSWR( + "/api/auth/subscriptions", + fetcher, + ); + const [submitting, setSubmitting] = useState(null); + const [portalLoading, setPortalLoading] = useState(false); + const [error, setError] = useState(null); - const records = useMemo(() => data?.subscriptions ?? [], [data?.subscriptions]) + const records = useMemo( + () => data?.subscriptions ?? [], + [data?.subscriptions], + ); const handleCancel = useCallback( async (externalId: string) => { - if (!externalId) return - setSubmitting(externalId) - setError(null) + if (!externalId) return; + setSubmitting(externalId); + setError(null); try { - const response = await fetch('/api/auth/subscriptions/cancel', { - method: 'POST', + const response = await fetch("/api/auth/subscriptions/cancel", { + method: "POST", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, body: JSON.stringify({ externalId }), - }) + }); if (!response.ok) { - const payload = await response.json().catch(() => ({})) - setError((payload?.message as string) || '取消订阅失败,请稍后重试。') - return + const payload = await response.json().catch(() => ({})); + setError( + (payload?.message as string) || "取消订阅失败,请稍后重试。", + ); + return; } - await mutate() + await mutate(); } catch (err) { - console.warn('Failed to cancel subscription', err) - setError('取消订阅时发生错误。') + console.warn("Failed to cancel subscription", err); + setError("取消订阅时发生错误。"); } finally { - setSubmitting(null) + setSubmitting(null); } }, [mutate], - ) + ); return (
-

订阅与计费

+

+ 订阅与计费 +

- 查看你通过 PayPal / 以太坊 / USDT(含二维码扫码)的 Pay-as-you-go 与 SaaS 订阅,试用也会出现在这里。 + 查看 Stripe 购买记录、当前订阅状态,并直接进入客户门户管理账单。

+
{error ?

{error}

: null} {isLoading ? ( -

加载订阅中…

+

+ 加载订阅中… +

) : records.length === 0 ? ( -

暂无订阅记录。

+

+ 暂无订阅记录。 +

) : (
- {records.map((record) => ( -
-
-
-

{record.provider}

-

{record.kind ?? 'subscription'}

- {record.paymentMethod ? ( -

付款方式:{record.paymentMethod}

+ {records.map((record) => { + const canCancel = + record.provider === "stripe" && + (record.kind ?? "subscription") === "subscription"; + + return ( +
+
+
+

+ {record.provider} +

+

+ {record.kind ?? "subscription"} +

+ {record.paymentMethod ? ( +

+ 付款方式:{record.paymentMethod} +

+ ) : null} +
+ + {record.status} + +
+
+
+
Plan
+
+ {record.planId || "—"} +
+
+
+
External ID
+
+ {record.externalId} +
+
+
+
Created
+
+ {formatDate(record.createdAt)} +
+
+
+
Updated
+
+ {formatDate(record.updatedAt)} +
+
+ {typeof record.meta?.startsAt === "string" ? ( +
+
Starts
+
+ {formatDate(record.meta?.startsAt as string)} +
+
) : null} + {typeof record.meta?.expiresAt === "string" ? ( +
+
Expires
+
+ {formatDate(record.meta?.expiresAt as string)} +
+
+ ) : null} + {record.cancelledAt ? ( +
+
Cancelled
+
+ {formatDate(record.cancelledAt)} +
+
+ ) : null} + {record.meta?.note ? ( +
+
备注
+
+ {String(record.meta?.note)} +
+
+ ) : null} +
+
+
- - {record.status} -
-
-
-
Plan
-
{record.planId || '—'}
-
-
-
External ID
-
{record.externalId}
-
-
-
Created
-
{formatDate(record.createdAt)}
-
-
-
Updated
-
{formatDate(record.updatedAt)}
-
- {typeof record.meta?.startsAt === 'string' ? ( -
-
Starts
-
{formatDate(record.meta?.startsAt as string)}
-
- ) : null} - {typeof record.meta?.expiresAt === 'string' ? ( -
-
Expires
-
{formatDate(record.meta?.expiresAt as string)}
-
- ) : null} - {record.cancelledAt ? ( -
-
Cancelled
-
{formatDate(record.cancelledAt)}
-
- ) : null} - {record.meta?.note ? ( -
-
备注
-
{String(record.meta?.note)}
-
- ) : null} -
- {record.paymentQr ? ( -
- {`QR -
- ) : null} -
- -
-
- ))} + ); + })}
)} - ) + ); } diff --git a/src/modules/extensions/builtin/user-center/routes/subscription.tsx b/src/modules/extensions/builtin/user-center/routes/subscription.tsx index 1030797..74eb78c 100644 --- a/src/modules/extensions/builtin/user-center/routes/subscription.tsx +++ b/src/modules/extensions/builtin/user-center/routes/subscription.tsx @@ -1,64 +1,78 @@ -'use client' +"use client"; -import Breadcrumbs from '@/app/panel/components/Breadcrumbs' -import Card from '../components/Card' -import BillingOptionsPanel from '../account/BillingOptionsPanel' -import SubscriptionPanel from '../account/SubscriptionPanel' -import { useUserStore } from '@lib/userStore' +import Breadcrumbs from "@/app/panel/components/Breadcrumbs"; +import Card from "../components/Card"; +import BillingOptionsPanel from "../account/BillingOptionsPanel"; +import SubscriptionPanel from "../account/SubscriptionPanel"; +import { useUserStore } from "@lib/userStore"; export default function UserCenterSubscriptionRoute() { - const user = useUserStore((state) => state.user) - const isReadOnlyRole = Boolean(user?.isReadOnly) + const user = useUserStore((state) => state.user); + const isReadOnlyRole = Boolean(user?.isReadOnly); if (isReadOnlyRole) { return (

支付与订阅

- Demo 体验账号为只读模式,无需订阅或付费。你可以继续浏览控制台并体验核心功能。 + Demo + 体验账号为只读模式,无需订阅或付费。你可以继续浏览控制台并体验核心功能。

- ) + ); } return (

支付与订阅

- 支持 PayPal / ETH(ERC20)/ USDT(TRC20)扫码支付。支付成功后系统将自动识别并开通或续订服务。 + 所有套餐统一通过 Stripe + 结算,购买后会自动同步到订阅记录,并可在客户门户管理账单。

-

步骤 1:选择产品模式

-

PAYG(流量包)或 SaaS(订阅),两种模式分区展示,避免混淆。

+

+ 步骤 1:选择产品模式 +

+

+ 按量购买或订阅购买都会映射到统一的 Stripe 价格配置。 +

-

步骤 2:选择支付方式

-

PayPal / ETH / USDT 三种扫码方式并列,二维码统一尺寸,信息层级一致。

+

+ 步骤 2:跳转 Stripe +

+

+ 登录后直接进入 Stripe Checkout,避免本地保存任何敏感支付方式入口。 +

-

步骤 3:自动识别到账

-

扫码支付 → 系统自动识别付款 → 自动激活或续订订单,全程无需手工确认。

+

+ 步骤 3:自动识别到账 +

+

+ Stripe 回调与 webhook 会自动更新订阅状态,无需手工同步。 +

- ) + ); } diff --git a/src/modules/products/registry.ts b/src/modules/products/registry.ts index cfa29cc..4905d10 100644 --- a/src/modules/products/registry.ts +++ b/src/modules/products/registry.ts @@ -1,67 +1,66 @@ -import xcloudflow from './xcloudflow' -import xscopehub from './xscopehub' -import xstream from './xstream' +import xcloudflow from "./xcloudflow"; +import xscopehub from "./xscopehub"; +import xstream from "./xstream"; export type EditionLink = { - label: string - href: string - external?: boolean -} + label: string; + href: string; + external?: boolean; +}; export type Editions = { - selfhost: EditionLink[] - managed: EditionLink[] - paygo: EditionLink[] - saas: EditionLink[] -} + selfhost: EditionLink[]; + managed: EditionLink[]; + paygo: EditionLink[]; + saas: EditionLink[]; +}; export type ProductConfig = { - slug: string - name: string - title: string - title_en: string - tagline_zh: string - tagline_en: string - ogImage: string - repoUrl: string - docsQuickstart: string - docsApi: string - docsIssues: string - blogUrl: string - videosUrl: string - downloadUrl: string - editions: Editions + slug: string; + name: string; + title: string; + title_en: string; + tagline_zh: string; + tagline_en: string; + ogImage: string; + repoUrl: string; + docsQuickstart: string; + docsApi: string; + docsIssues: string; + blogUrl: string; + videosUrl: string; + downloadUrl: string; + editions: Editions; billing?: { - paygo?: BillingPlan - saas?: BillingPlan - } -} + paygo?: BillingPlan; + saas?: BillingPlan; + }; +}; -export type BillingPaymentMethod = { - type: 'paypal' | 'ethereum' | 'usdt' - label?: string - address?: string - network?: string - qrCode?: string - instructions?: string -} +export type StripeBillingMode = "payment" | "subscription"; export type BillingPlan = { - name: string - description?: string - price: number - currency: string - interval?: string - planId?: string - clientId?: string - meta?: Record - paymentMethods?: BillingPaymentMethod[] + name: string; + description?: string; + price: number; + currency: string; + interval?: string; + planId?: string; + stripePriceId?: string; + mode: StripeBillingMode; + meta?: Record; +}; + +export function readPublicStripePrice(key: string): string { + const value = process.env[key]; + return typeof value === "string" ? value.trim() : ""; } -export const PRODUCT_LIST: ProductConfig[] = [xstream, xscopehub, xcloudflow] +export const PRODUCT_LIST: ProductConfig[] = [xstream, xscopehub, xcloudflow]; export const PRODUCT_MAP = new Map( - PRODUCT_LIST.map((product) => [product.slug, product]) -) + PRODUCT_LIST.map((product) => [product.slug, product]), +); -export const getAllSlugs = (): string[] => PRODUCT_LIST.map((product) => product.slug) +export const getAllSlugs = (): string[] => + PRODUCT_LIST.map((product) => product.slug); diff --git a/src/modules/products/xcloudflow.ts b/src/modules/products/xcloudflow.ts index e730960..0d67019 100644 --- a/src/modules/products/xcloudflow.ts +++ b/src/modules/products/xcloudflow.ts @@ -1,104 +1,83 @@ -import type { BillingPaymentMethod, ProductConfig } from './registry' - -const sharedPaymentMethods: BillingPaymentMethod[] = [ - { - type: 'paypal', - label: 'PayPal 扫码', - qrCode: - 'https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=https://www.paypal.com/paypalme/xcontrol', - instructions: '使用 PayPal 客户端扫码或打开二维码链接完成支付。', - }, - { - type: 'ethereum', - label: '以太坊 / ETH', - network: 'ERC20', - address: '0x8ba1f109551bD432803012645Ac136ddd64DBA72', - qrCode: - 'https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=ethereum:0x8ba1f109551bD432803012645Ac136ddd64DBA72', - instructions: '完成链上转账后,点击同步扫码订单将记录存入账户。', - }, - { - type: 'usdt', - label: 'USDT', - network: 'TRC20', - address: 'TK9p9oxKGVfYB1D6UcqSgnZJx1f3w3Zz7B', - qrCode: - 'https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=usdt:TRC20:TK9p9oxKGVfYB1D6UcqSgnZJx1f3w3Zz7B', - instructions: '支持 USDT-TRC20,扫码完成后可同步到账单。', - }, -] +import { readPublicStripePrice, type ProductConfig } from "./registry"; const xcloudflow: ProductConfig = { - slug: 'xcloudflow', - name: 'XCloudFlow', - title: 'XCloudFlow — 多云工作流与自动化平台', - title_en: 'XCloudFlow — Multi-cloud Workflow Automation', - tagline_zh: '统一调度跨云资源,内置 AI 协作与合规审计。', - tagline_en: 'Coordinate multi-cloud workloads with AI assistance and governance built in.', - ogImage: 'https://www.svc.plus/assets/og/xcloudflow.png', - repoUrl: 'https://github.com/Cloud-Neutral/XCloudFlow', - docsQuickstart: 'https://www.svc.plus/xcloudflow/docs/quickstart', - docsApi: 'https://www.svc.plus/xcloudflow/docs/api', - docsIssues: 'https://github.com/Cloud-Neutral/XCloudFlow/issues', - blogUrl: 'https://www.svc.plus/blogs/tags/xcloudflow', - videosUrl: 'https://www.svc.plus/videos/xcloudflow', - downloadUrl: 'https://www.svc.plus/xcloudflow/downloads', + slug: "xcloudflow", + name: "XCloudFlow", + title: "XCloudFlow — 多云工作流与自动化平台", + title_en: "XCloudFlow — Multi-cloud Workflow Automation", + tagline_zh: "统一调度跨云资源,内置 AI 协作与合规审计。", + tagline_en: + "Coordinate multi-cloud workloads with AI assistance and governance built in.", + ogImage: "https://www.svc.plus/assets/og/xcloudflow.png", + repoUrl: "https://github.com/Cloud-Neutral/XCloudFlow", + docsQuickstart: "https://www.svc.plus/xcloudflow/docs/quickstart", + docsApi: "https://www.svc.plus/xcloudflow/docs/api", + docsIssues: "https://github.com/Cloud-Neutral/XCloudFlow/issues", + blogUrl: "https://www.svc.plus/blogs/tags/xcloudflow", + videosUrl: "https://www.svc.plus/videos/xcloudflow", + downloadUrl: "https://www.svc.plus/xcloudflow/downloads", editions: { selfhost: [ { - label: 'Terraform 模块', - href: 'https://github.com/Cloud-Neutral/XCloudFlow/tree/main/deploy/terraform', + label: "Terraform 模块", + href: "https://github.com/Cloud-Neutral/XCloudFlow/tree/main/deploy/terraform", external: true, }, { - label: '离线安装包', - href: 'https://www.svc.plus/xcloudflow/downloads', + label: "离线安装包", + href: "https://www.svc.plus/xcloudflow/downloads", external: true, }, ], managed: [ { - label: '专业托管', - href: 'https://www.svc.plus/contact?product=xcloudflow', + label: "专业托管", + href: "https://www.svc.plus/contact?product=xcloudflow", external: true, }, ], paygo: [ { - label: '按量计费', - href: 'https://www.svc.plus/pricing/xcloudflow', + label: "按量计费", + href: "https://www.svc.plus/pricing/xcloudflow", external: true, }, ], saas: [ { - label: '团队订阅', - href: 'https://www.svc.plus/xcloudflow/signup', + label: "团队订阅", + href: "https://www.svc.plus/xcloudflow/signup", external: true, }, ], }, billing: { paygo: { - name: 'CloudFlow 任务包', - description: '按量购买编排执行次数,灵活扩展。', + name: "CloudFlow 任务包", + description: "按量购买编排执行次数,灵活扩展。", price: 12, - currency: 'USD', - planId: 'XCLOUDFLOW-PAYGO', - meta: { tier: 'usage', product: 'xcloudflow' }, - paymentMethods: sharedPaymentMethods, + currency: "USD", + mode: "payment", + planId: "XCLOUDFLOW-PAYGO", + stripePriceId: readPublicStripePrice( + "NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_PAYGO", + ), + meta: { tier: "usage", product: "xcloudflow" }, }, saas: { - name: 'CloudFlow SaaS', - description: '托管版多云编排与管控,含团队协作。', + name: "CloudFlow SaaS", + description: "托管版多云编排与管控,含团队协作。", price: 59, - currency: 'USD', - interval: 'month', - planId: 'XCLOUDFLOW-SUBSCRIPTION', - meta: { tier: 'team', product: 'xcloudflow' }, - paymentMethods: sharedPaymentMethods, + currency: "USD", + interval: "month", + mode: "subscription", + planId: "XCLOUDFLOW-SUBSCRIPTION", + stripePriceId: readPublicStripePrice( + "NEXT_PUBLIC_STRIPE_PRICE_XCLOUDFLOW_SUBSCRIPTION", + ), + meta: { tier: "team", product: "xcloudflow" }, }, }, -} +}; -export default xcloudflow +export default xcloudflow; diff --git a/src/modules/products/xscopehub.ts b/src/modules/products/xscopehub.ts index c0c4f28..45f0e7c 100644 --- a/src/modules/products/xscopehub.ts +++ b/src/modules/products/xscopehub.ts @@ -1,104 +1,83 @@ -import type { BillingPaymentMethod, ProductConfig } from './registry' - -const sharedPaymentMethods: BillingPaymentMethod[] = [ - { - type: 'paypal', - label: 'PayPal 扫码', - qrCode: - 'https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=https://www.paypal.com/paypalme/xcontrol', - instructions: '打开 PayPal App 扫码或跳转二维码链接完成支付。', - }, - { - type: 'ethereum', - label: '以太坊 / ETH', - network: 'ERC20', - address: '0x8ba1f109551bD432803012645Ac136ddd64DBA72', - qrCode: - 'https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=ethereum:0x8ba1f109551bD432803012645Ac136ddd64DBA72', - instructions: '支持 ETH/USDT ERC20 转账,付款后在账户中心同步扫码订单。', - }, - { - type: 'usdt', - label: 'USDT', - network: 'TRC20', - address: 'TK9p9oxKGVfYB1D6UcqSgnZJx1f3w3Zz7B', - qrCode: - 'https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=usdt:TRC20:TK9p9oxKGVfYB1D6UcqSgnZJx1f3w3Zz7B', - instructions: 'USDT-TRC20 扫码转账完成后,点击同步记录到账户。', - }, -] +import { readPublicStripePrice, type ProductConfig } from "./registry"; const xscopehub: ProductConfig = { - slug: 'xscopehub', - name: 'XScopeHub', - title: 'XScopeHub — 云原生可观测性控制台', - title_en: 'XScopeHub — Cloud Observability Hub', - tagline_zh: '统一指标、日志、链路追踪,一站式智能告警。', - tagline_en: 'Unified metrics, logs, and traces with intelligent alerting in one hub.', - ogImage: 'https://www.svc.plus/assets/og/xscopehub.png', - repoUrl: 'https://github.com/Cloud-Neutral/XScopeHub', - docsQuickstart: 'https://www.svc.plus/xscopehub/docs/quickstart', - docsApi: 'https://www.svc.plus/xscopehub/docs/api', - docsIssues: 'https://github.com/Cloud-Neutral/XScopeHub/issues', - blogUrl: 'https://www.svc.plus/blogs/tags/xscopehub', - videosUrl: 'https://www.svc.plus/videos/xscopehub', - downloadUrl: 'https://www.svc.plus/xscopehub/downloads', + slug: "xscopehub", + name: "XScopeHub", + title: "XScopeHub — 云原生可观测性控制台", + title_en: "XScopeHub — Cloud Observability Hub", + tagline_zh: "统一指标、日志、链路追踪,一站式智能告警。", + tagline_en: + "Unified metrics, logs, and traces with intelligent alerting in one hub.", + ogImage: "https://www.svc.plus/assets/og/xscopehub.png", + repoUrl: "https://github.com/Cloud-Neutral/XScopeHub", + docsQuickstart: "https://www.svc.plus/xscopehub/docs/quickstart", + docsApi: "https://www.svc.plus/xscopehub/docs/api", + docsIssues: "https://github.com/Cloud-Neutral/XScopeHub/issues", + blogUrl: "https://www.svc.plus/blogs/tags/xscopehub", + videosUrl: "https://www.svc.plus/videos/xscopehub", + downloadUrl: "https://www.svc.plus/xscopehub/downloads", editions: { selfhost: [ { - label: '部署包下载', - href: 'https://www.svc.plus/xscopehub/downloads', + label: "部署包下载", + href: "https://www.svc.plus/xscopehub/downloads", external: true, }, { - label: 'Helm Chart', - href: 'https://github.com/Cloud-Neutral/XScopeHub/tree/main/deploy/helm', + label: "Helm Chart", + href: "https://github.com/Cloud-Neutral/XScopeHub/tree/main/deploy/helm", external: true, }, ], managed: [ { - label: '预约演示', - href: 'https://www.svc.plus/contact?product=xscopehub', + label: "预约演示", + href: "https://www.svc.plus/contact?product=xscopehub", external: true, }, ], paygo: [ { - label: '弹性计费', - href: 'https://www.svc.plus/pricing/xscopehub', + label: "弹性计费", + href: "https://www.svc.plus/pricing/xscopehub", external: true, }, ], saas: [ { - label: '立即订阅', - href: 'https://www.svc.plus/xscopehub/signup', + label: "立即订阅", + href: "https://www.svc.plus/xscopehub/signup", external: true, }, ], }, billing: { paygo: { - name: 'ScopeHub 数据查询包', - description: '按查询量或指标卡点购买,灵活接入。', + name: "ScopeHub 数据查询包", + description: "按查询量或指标卡点购买,灵活接入。", price: 15, - currency: 'USD', - planId: 'XSCOPEHUB-PAYGO', - meta: { tier: 'usage', product: 'xscopehub' }, - paymentMethods: sharedPaymentMethods, + currency: "USD", + mode: "payment", + planId: "XSCOPEHUB-PAYGO", + stripePriceId: readPublicStripePrice( + "NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_PAYGO", + ), + meta: { tier: "usage", product: "xscopehub" }, }, saas: { - name: 'ScopeHub SaaS', - description: '订阅可视化观测、看板与告警服务。', + name: "ScopeHub SaaS", + description: "订阅可视化观测、看板与告警服务。", price: 39, - currency: 'USD', - interval: 'month', - planId: 'XSCOPEHUB-SUBSCRIPTION', - meta: { tier: 'growth', product: 'xscopehub' }, - paymentMethods: sharedPaymentMethods, + currency: "USD", + interval: "month", + mode: "subscription", + planId: "XSCOPEHUB-SUBSCRIPTION", + stripePriceId: readPublicStripePrice( + "NEXT_PUBLIC_STRIPE_PRICE_XSCOPEHUB_SUBSCRIPTION", + ), + meta: { tier: "growth", product: "xscopehub" }, }, }, -} +}; -export default xscopehub +export default xscopehub; diff --git a/src/modules/products/xstream.ts b/src/modules/products/xstream.ts index b60d79e..90febec 100644 --- a/src/modules/products/xstream.ts +++ b/src/modules/products/xstream.ts @@ -1,102 +1,81 @@ -import type { BillingPaymentMethod, ProductConfig } from './registry' - -const sharedPaymentMethods: BillingPaymentMethod[] = [ - { - type: 'paypal', - label: 'PayPal 扫码', - qrCode: - 'https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=https://www.paypal.com/paypalme/xcontrol', - instructions: '使用 PayPal App 扫码,或在浏览器打开二维码链接完成支付。', - }, - { - type: 'ethereum', - label: '以太坊 / ETH', - network: 'ERC20', - address: '0x8ba1f109551bD432803012645Ac136ddd64DBA72', - qrCode: - 'https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=ethereum:0x8ba1f109551bD432803012645Ac136ddd64DBA72', - instructions: '转账后点击“同步扫码订单”即可在账户中心看到记录。', - }, - { - type: 'usdt', - label: 'USDT', - network: 'TRC20', - address: 'TK9p9oxKGVfYB1D6UcqSgnZJx1f3w3Zz7B', - qrCode: - 'https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=usdt:TRC20:TK9p9oxKGVfYB1D6UcqSgnZJx1f3w3Zz7B', - instructions: '支持 USDT-TRC20 扫码支付,完成后同步到订单记录。', - }, -] +import { readPublicStripePrice, type ProductConfig } from "./registry"; const xstream: ProductConfig = { - slug: 'xstream', - name: 'Xstream', - title: 'Xstream — 全球网络加速器', - title_en: 'Xstream — Global Network Accelerator', - tagline_zh: '极速连接|安全加密|AI 路径优化|实时监控。', - tagline_en: 'Fast connect | Secure encryption | AI path optimization | Live metrics.', - ogImage: 'https://www.svc.plus/assets/og/xstream.png', - repoUrl: 'https://github.com/Cloud-Neutral/Xstream', - docsQuickstart: 'https://github.com/Cloud-Neutral/Xstream#readme', - docsApi: 'https://github.com/Cloud-Neutral/Xstream/tree/main/docs', - docsIssues: 'https://github.com/Cloud-Neutral/Xstream/issues', - blogUrl: 'https://www.svc.plus/blogs', - videosUrl: 'https://www.svc.plus/videos', - downloadUrl: 'https://github.com/Cloud-Neutral/Xstream/releases', + slug: "xstream", + name: "Xstream", + title: "Xstream — 全球网络加速器", + title_en: "Xstream — Global Network Accelerator", + tagline_zh: "极速连接|安全加密|AI 路径优化|实时监控。", + tagline_en: + "Fast connect | Secure encryption | AI path optimization | Live metrics.", + ogImage: "https://www.svc.plus/assets/og/xstream.png", + repoUrl: "https://github.com/Cloud-Neutral/Xstream", + docsQuickstart: "https://github.com/Cloud-Neutral/Xstream#readme", + docsApi: "https://github.com/Cloud-Neutral/Xstream/tree/main/docs", + docsIssues: "https://github.com/Cloud-Neutral/Xstream/issues", + blogUrl: "https://www.svc.plus/blogs", + videosUrl: "https://www.svc.plus/videos", + downloadUrl: "https://github.com/Cloud-Neutral/Xstream/releases", editions: { selfhost: [ { - label: 'GitHub 仓库', - href: 'https://github.com/Cloud-Neutral/Xstream', + label: "GitHub 仓库", + href: "https://github.com/Cloud-Neutral/Xstream", external: true, }, { - label: '部署指南', - href: 'https://github.com/Cloud-Neutral/Xstream#deployment', + label: "部署指南", + href: "https://github.com/Cloud-Neutral/Xstream#deployment", external: true, }, ], managed: [ { - label: '联系咨询', - href: 'https://www.svc.plus/contact', + label: "联系咨询", + href: "https://www.svc.plus/contact", external: true, }, ], paygo: [ { - label: '价格与账单', - href: '/panel/subscription/pricing', + label: "价格与账单", + href: "/panel/subscription/pricing", }, ], saas: [ { - label: '注册与订阅', - href: '/panel/subscription/', + label: "注册与订阅", + href: "/panel/subscription/", }, ], }, billing: { paygo: { - name: 'Xstream 流量包', - description: '按量购买出口带宽或流量,适合弹性增长。', + name: "Xstream 流量包", + description: "按量购买出口带宽或流量,适合弹性增长。", price: 19, - currency: 'USD', - planId: 'XSTREAM-PAYGO', - meta: { tier: 'usage', product: 'xstream' }, - paymentMethods: sharedPaymentMethods, + currency: "USD", + mode: "payment", + planId: "XSTREAM-PAYGO", + stripePriceId: readPublicStripePrice( + "NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_PAYGO", + ), + meta: { tier: "usage", product: "xstream" }, }, saas: { - name: 'Xstream Pro', - description: '包含全球加速、AI 路径优化与实时观测的订阅计划。', + name: "Xstream Pro", + description: "包含全球加速、AI 路径优化与实时观测的订阅计划。", price: 49, - currency: 'USD', - interval: 'month', - planId: 'XSTREAM-SUBSCRIPTION', - meta: { tier: 'pro', product: 'xstream' }, - paymentMethods: sharedPaymentMethods, + currency: "USD", + interval: "month", + mode: "subscription", + planId: "XSTREAM-SUBSCRIPTION", + stripePriceId: readPublicStripePrice( + "NEXT_PUBLIC_STRIPE_PRICE_XSTREAM_SUBSCRIPTION", + ), + meta: { tier: "pro", product: "xstream" }, }, }, -} +}; -export default xstream +export default xstream;