Merge branch 'main' into release/v0.2

This commit is contained in:
Haitao Pan 2026-03-16 20:19:44 +08:00
commit eaa383bb16
19 changed files with 1320 additions and 1332 deletions

View File

@ -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=

View File

@ -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`

View File

@ -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.

View File

@ -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<AuthLayoutSocialButton[]>(() => {

View File

@ -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 });
}

View File

@ -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 });
}

View File

@ -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<string | null>(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 (
<div className="min-h-screen bg-background text-text transition-colors duration-150 flex flex-col">
<UnifiedNavigation />
const cards = [...billingCards, ...extraCards];
<main className="flex-1 relative overflow-hidden pt-24 pb-20">
<div
className="absolute inset-0 bg-gradient-app-from opacity-20 pointer-events-none"
aria-hidden
/>
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;
}
<div className="relative mx-auto max-w-7xl px-6">
<div className="text-center max-w-3xl mx-auto mb-20 space-y-4">
<h1 className="text-4xl font-bold tracking-tight text-heading sm:text-6xl">
{content.title}
</h1>
<p className="text-lg text-text-muted">
{content.subtitle}
</p>
</div>
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.",
);
}
};
<div className="grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-6 max-w-7xl mx-auto">
{content.plans.map((plan, index) => (
<div
key={index}
className={`
relative rounded-2xl p-6 border
${plan.highlight
? 'border-primary bg-primary/5 shadow-2xl shadow-primary/10 transform hover:-translate-y-1 transition-all duration-300'
: 'border-surface-border bg-surface hover:border-surface-border-hover'
}
flex flex-col h-full
`}
>
{plan.highlight && (
<div className="absolute -top-4 left-1/2 -translate-x-1/2 bg-primary text-white text-[10px] font-bold px-2 py-0.5 rounded-full uppercase tracking-wider whitespace-nowrap">
{isChinese ? "推荐" : "Recommended"}
</div>
)}
return (
<div className="min-h-screen bg-background text-text transition-colors duration-150 flex flex-col">
<UnifiedNavigation />
<div className="mb-6">
<h3 className="text-base font-semibold text-text-muted mb-2 truncate">{plan.name}</h3>
<div className="flex items-baseline gap-1">
<span className="text-3xl font-bold text-heading">{plan.price}</span>
{plan.period && <span className="text-xs text-text-muted">{plan.period}</span>}
</div>
<p className="text-xs text-text-subtle mt-3 line-clamp-2 min-h-[2.5em]">{plan.description}</p>
</div>
<main className="flex-1 relative overflow-hidden pt-24 pb-20">
<div
className="absolute inset-0 bg-gradient-app-from opacity-20 pointer-events-none"
aria-hidden
/>
<div className="flex-1 space-y-3 mb-6">
{plan.features.map((feature, i) => (
<div key={i} className="flex items-start gap-2">
<div className={`mt-0.5 rounded-full p-0.5 ${plan.highlight ? 'bg-primary/20 text-primary' : 'bg-surface-muted text-text-muted'}`}>
<Check size={12} />
</div>
<span className="text-xs text-text-muted leading-tight">{feature}</span>
</div>
))}
</div>
<div className="relative mx-auto max-w-7xl px-6">
<div className="text-center max-w-3xl mx-auto mb-10 space-y-4">
<h1 className="text-4xl font-bold tracking-tight text-heading sm:text-6xl">
{isChinese ? "Stripe 统一定价" : "Stripe Unified Pricing"}
</h1>
<p className="text-lg text-text-muted">
{isChinese
? "所有在线购买统一通过 Stripe 完成,历史敏感支付方式入口已移除。"
: "All online purchases now run through Stripe. Sensitive payment options have been removed."}
</p>
</div>
<Link
href={plan.href}
className={`
w-full py-2 rounded-lg text-xs font-semibold text-center transition-colors
${plan.highlight
? 'bg-primary text-white hover:bg-primary-hover shadow-lg shadow-primary/25'
: 'bg-surface-muted text-text hover:bg-surface-hover border border-surface-border'
}
`}
>
{plan.button}
</Link>
</div>
))}
</div>
<CheckoutStatusBanner className="mx-auto mb-6 max-w-3xl" />
{statusMessage ? (
<p className="mx-auto mb-6 max-w-3xl rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
{statusMessage}
</p>
) : null}
<div className="mt-20 max-w-4xl mx-auto text-center space-y-8">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-surface border border-surface-border text-xs font-medium text-text-muted">
<Shield size={14} />
{isChinese ? "所有支付由 Stripe 安全处理" : "Payments secured by Stripe"}
</div>
</div>
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{cards.map((card) => (
<div
key={card.key}
className={`relative flex h-full flex-col rounded-2xl border p-6 ${
card.highlight
? "border-primary bg-primary/5 shadow-2xl shadow-primary/10"
: "border-surface-border bg-surface"
}`}
>
{card.highlight ? (
<div className="absolute -top-4 left-1/2 -translate-x-1/2 rounded-full bg-primary px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-white">
{isChinese ? "推荐" : "Recommended"}
</div>
) : null}
<div className="mb-6">
<h3 className="text-base font-semibold text-text-muted mb-2">
{card.name}
</h3>
<div className="flex items-baseline gap-1">
<span className="text-3xl font-bold text-heading">
{card.price}
</span>
{card.period ? (
<span className="text-xs text-text-muted">
{card.period}
</span>
) : null}
</div>
<p className="mt-3 min-h-[2.5em] text-xs text-text-subtle">
{card.description}
</p>
</div>
</main>
<Footer />
<div className="mb-6 flex-1 space-y-3">
{card.features.map((feature) => (
<div key={feature} className="flex items-start gap-2">
<div
className={`mt-0.5 rounded-full p-0.5 ${
card.highlight
? "bg-primary/20 text-primary"
: "bg-surface-muted text-text-muted"
}`}
>
<Check size={12} />
</div>
<span className="text-xs leading-tight text-text-muted">
{feature}
</span>
</div>
))}
</div>
{card.billingPlan ? (
<button
type="button"
onClick={() => void handleCheckout(card)}
className={`w-full rounded-lg py-2 text-xs font-semibold transition-colors ${
card.highlight
? "bg-primary text-white hover:bg-primary-hover"
: "border border-surface-border bg-surface-muted text-text hover:bg-surface-hover"
}`}
>
{card.button}
</button>
) : (
<Link
href={card.href || "/"}
className={`w-full rounded-lg py-2 text-center text-xs font-semibold transition-colors ${
card.highlight
? "bg-primary text-white hover:bg-primary-hover"
: "border border-surface-border bg-surface-muted text-text hover:bg-surface-hover"
}`}
>
{card.button}
</Link>
)}
</div>
))}
</div>
<div className="mt-20 max-w-4xl mx-auto text-center space-y-8">
<div className="inline-flex items-center gap-2 rounded-full border border-surface-border bg-surface px-4 py-2 text-xs font-medium text-text-muted">
<Shield size={14} />
{isChinese
? "所有支付由 Stripe 安全处理"
: "Payments secured by Stripe"}
</div>
</div>
</div>
);
</main>
<Footer />
</div>
);
}

View File

@ -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<ReleaseChannel[]>([
"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"
>
<div className="lg:hidden flex items-center justify-between px-4 py-3 bg-background">
<div className="lg:hidden flex items-center justify-between border-b border-surface-border/70 bg-background px-4 py-3">
<Link
href="/"
className="flex items-center gap-2"
onClick={() => setMenuOpen(false)}
>
<Image
src="/icons/cloudnative_32.png"
alt="logo"
width={24}
height={24}
className="h-6 w-6"
unoptimized
/>
<span className="text-base font-semibold tracking-tight text-text">
Cloud-Neutral
</span>
</Link>
<button
onClick={() => setMenuOpen(!menuOpen)}
className="p-2 -ml-2 rounded-xl bg-surface-muted hover:bg-surface-hover text-text transition-colors"
className="rounded-xl bg-surface-muted p-2 text-text transition-colors hover:bg-surface-hover"
aria-label="Toggle menu"
>
{menuOpen ? (
@ -209,7 +257,6 @@ export default function UnifiedNavigation() {
<Menu className="w-5 h-5" />
)}
</button>
<div className="w-10" />
</div>
<div className="hidden lg:block mx-auto w-full max-w-7xl px-6 sm:px-8">
@ -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 (
<button
key={item.key}
onClick={() => {
toggleOpen();
}}
className={`flex items-center gap-1.5 px-2 py-1.5 rounded-lg transition-colors whitespace-nowrap ${active
? "bg-primary/10 text-primary"
: "text-text-muted hover:text-text hover:bg-surface-muted"
}`}
className={`flex items-center gap-1.5 px-2 py-1.5 rounded-lg transition-colors whitespace-nowrap ${
active
? "bg-primary/10 text-primary"
: "text-text-muted hover:text-text hover:bg-surface-muted"
}`}
>
{item.icon && <item.icon className="w-4 h-4" />}
<span className="text-[13px] tracking-tight">
{getLabel(item.label, language)}
</span>
</button>
)
);
}
return (
<Link
key={item.key}
href={item.href}
className={`flex items-center gap-1.5 px-2 py-1.5 rounded-lg transition-colors whitespace-nowrap ${active
? "bg-primary/10 text-primary"
: "text-text-muted hover:text-text hover:bg-surface-muted"
}`}
className={`flex items-center gap-1.5 px-2 py-1.5 rounded-lg transition-colors whitespace-nowrap ${
active
? "bg-primary/10 text-primary"
: "text-text-muted hover:text-text hover:bg-surface-muted"
}`}
>
{item.icon && <item.icon className="w-4 h-4" />}
<span className="text-[13px] tracking-tight">
@ -261,10 +310,11 @@ export default function UnifiedNavigation() {
<Link
key={item.key}
href={item.href}
className={`flex items-center gap-1.5 px-2 py-1.5 rounded-lg transition-colors whitespace-nowrap ${active
? "bg-primary/10 text-primary"
: "text-text-muted hover:text-text hover:bg-surface-muted"
}`}
className={`flex items-center gap-1.5 px-2 py-1.5 rounded-lg transition-colors whitespace-nowrap ${
active
? "bg-primary/10 text-primary"
: "text-text-muted hover:text-text hover:bg-surface-muted"
}`}
>
{item.icon && <item.icon className="w-4 h-4" />}
<span className="text-[13px] tracking-tight">
@ -285,7 +335,10 @@ export default function UnifiedNavigation() {
onToggle={toggleChannel}
variant="icon"
/>
<DropdownMenu.Root open={accountMenuOpen} onOpenChange={setAccountMenuOpen}>
<DropdownMenu.Root
open={accountMenuOpen}
onOpenChange={setAccountMenuOpen}
>
<DropdownMenu.Trigger asChild>
<button
type="button"
@ -320,14 +373,17 @@ export default function UnifiedNavigation() {
>
<Link
href={item.href}
className={`flex h-[38px] flex-row-reverse items-center justify-between gap-3 px-3 rounded-lg text-[13px] font-medium transition-all group select-none ${item.key === 'logout'
? "text-rose-500 hover:bg-rose-500/10 hover:text-rose-600 focus:bg-rose-500/10 focus:text-rose-600"
: "text-text-muted hover:bg-primary/10 hover:text-primary focus:bg-primary/10 focus:text-primary"
}`}
className={`flex h-[38px] flex-row-reverse items-center justify-between gap-3 px-3 rounded-lg text-[13px] font-medium transition-all group select-none ${
item.key === "logout"
? "text-rose-500 hover:bg-rose-500/10 hover:text-rose-600 focus:bg-rose-500/10 focus:text-rose-600"
: "text-text-muted hover:bg-primary/10 hover:text-primary focus:bg-primary/10 focus:text-primary"
}`}
onClick={() => setAccountMenuOpen(false)}
>
{item.icon && (
<item.icon className={`w-4 h-4 shrink-0 opacity-60 group-hover:opacity-100 transition-opacity ${item.key === 'logout' ? 'text-rose-500' : 'text-current'}`} />
<item.icon
className={`w-4 h-4 shrink-0 opacity-60 group-hover:opacity-100 transition-opacity ${item.key === "logout" ? "text-rose-500" : "text-current"}`}
/>
)}
<span className="flex-1 text-right">
{typeof item.label === "function"
@ -377,12 +433,18 @@ export default function UnifiedNavigation() {
{menuOpen && (
<div className="fixed inset-0 z-[60] lg:hidden">
<div
className="absolute inset-0 bg-black/40 backdrop-blur-sm transition-opacity"
className="absolute inset-0 bg-black/30 backdrop-blur-sm transition-opacity"
onClick={() => setMenuOpen(false)}
/>
<div className="absolute inset-y-0 left-0 w-80 max-w-[85vw] bg-background shadow-2xl transition-transform duration-300 ease-in-out">
<div className="flex h-full flex-col overflow-y-auto border-r border-surface-border">
<div className="flex items-center justify-between border-b border-surface-border p-4">
<div
className={`absolute bg-background shadow-2xl transition-transform duration-300 ease-in-out ${
useMobileDrawer
? "inset-y-0 left-0 w-[min(86vw,22rem)] border-r border-surface-border"
: "inset-0"
}`}
>
<div className="flex h-full flex-col overflow-y-auto">
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-surface-border bg-background px-4 pb-3 pt-[max(0.75rem,env(safe-area-inset-top))]">
<Link
href="/"
className="flex items-center gap-2"
@ -402,7 +464,8 @@ export default function UnifiedNavigation() {
</Link>
<button
onClick={() => setMenuOpen(false)}
className="rounded-lg p-2 text-text-muted hover:bg-surface-muted transition-colors"
className="rounded-lg p-2 text-text-muted transition-colors hover:bg-surface-muted"
aria-label={isChinese ? "关闭菜单" : "Close menu"}
>
<X className="h-5 w-5" />
</button>
@ -426,7 +489,7 @@ export default function UnifiedNavigation() {
</div>
)}
<div className="flex-1 p-4">
<div className="flex-1 px-4 pb-4 pt-5">
<p className="px-2 text-[10px] font-bold uppercase tracking-widest text-text-muted opacity-50 mb-2">
{isChinese ? "主导航" : "Main Navigation"}
</p>
@ -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 (
<button
key={item.key}
@ -442,36 +505,34 @@ export default function UnifiedNavigation() {
toggleOpen();
setMenuOpen(false);
}}
className={`flex w-full items-center px-4 py-3 rounded-xl text-sm font-medium transition-colors ${active
? "bg-primary/10 text-primary"
: "text-text hover:bg-surface-muted"
}`}
className={`flex w-full items-center px-4 py-3 rounded-xl text-sm font-medium transition-colors ${
active
? "bg-primary/10 text-primary"
: "text-text hover:bg-surface-muted"
}`}
>
{item.icon && (
<item.icon className="mr-3 h-5 w-5 opacity-70" />
)}
<span>
{getLabel(item.label, language)}
</span>
<span>{getLabel(item.label, language)}</span>
</button>
)
);
}
return (
<Link
key={item.key}
href={item.href}
className={`flex items-center px-4 py-3 rounded-xl text-sm font-medium transition-colors ${active
? "bg-primary/10 text-primary"
: "text-text hover:bg-surface-muted"
}`}
className={`flex items-center px-4 py-3 rounded-xl text-sm font-medium transition-colors ${
active
? "bg-primary/10 text-primary"
: "text-text hover:bg-surface-muted"
}`}
onClick={() => setMenuOpen(false)}
>
{item.icon && (
<item.icon className="mr-3 h-5 w-5 opacity-70" />
)}
<span>
{getLabel(item.label, language)}
</span>
<span>{getLabel(item.label, language)}</span>
</Link>
);
})}
@ -490,18 +551,17 @@ export default function UnifiedNavigation() {
<Link
key={item.key}
href={item.href}
className={`flex items-center px-4 py-3 rounded-xl text-sm font-medium transition-colors ${active
? "bg-primary/10 text-primary"
: "text-text hover:bg-surface-muted"
}`}
className={`flex items-center px-4 py-3 rounded-xl text-sm font-medium transition-colors ${
active
? "bg-primary/10 text-primary"
: "text-text hover:bg-surface-muted"
}`}
onClick={() => setMenuOpen(false)}
>
{item.icon && (
<item.icon className="mr-3 h-5 w-5 opacity-70" />
)}
<span>
{getLabel(item.label, language)}
</span>
<span>{getLabel(item.label, language)}</span>
</Link>
);
})}
@ -517,10 +577,11 @@ export default function UnifiedNavigation() {
<Link
key={item.key}
href={item.href}
className={`flex w-full items-center justify-center rounded-xl py-3 text-sm font-bold transition ${item.key === "logout"
? "bg-rose-500/10 text-rose-600 shadow-sm hover:bg-rose-500/20"
: "border border-surface-border bg-surface-muted/50 dark:bg-surface-muted/30 hover:bg-surface-hover"
}`}
className={`flex w-full items-center justify-center rounded-xl py-3 text-sm font-bold transition ${
item.key === "logout"
? "bg-rose-500/10 text-rose-600 shadow-sm hover:bg-rose-500/20"
: "border border-surface-border bg-surface-muted/50 dark:bg-surface-muted/30 hover:bg-surface-hover"
}`}
onClick={() => setMenuOpen(false)}
>
{typeof item.label === "function"
@ -531,7 +592,7 @@ export default function UnifiedNavigation() {
</div>
</div>
<div className="border-t border-surface-border p-4 space-y-4">
<div className="border-t border-surface-border p-4 pb-[max(1rem,env(safe-area-inset-bottom))] space-y-4">
<div className="flex flex-col gap-3">
<p className="px-2 text-[10px] font-bold uppercase tracking-widest text-text-muted opacity-50">
{isChinese ? "设置" : "Settings"}

View File

@ -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 (
<div
className={`rounded-xl border px-4 py-3 text-sm ${message.tone} ${className ?? ""}`.trim()}
>
{message.text}
</div>
);
}

View File

@ -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<string, unknown>
}) => 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 (
<div className="rounded-xl border border-slate-200 bg-slate-50 p-4 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-brand">{label}</p>
{network ? <p className="text-xs text-slate-600"> / Network: {network}</p> : null}
</div>
{qrCode ? <span className="rounded-full bg-emerald-50 px-3 py-1 text-[11px] font-semibold text-emerald-700"></span> : null}
</div>
{method.instructions ? (
<p className="mt-2 text-sm text-slate-700">{method.instructions}</p>
) : (
<p className="mt-2 text-sm text-slate-700"></p>
)}
{address ? (
<div className="mt-3 rounded-lg bg-white p-3 text-xs font-mono text-slate-800">
<div className="flex items-center justify-between gap-2">
<span className="truncate" title={address}>
{address}
</span>
<button
type="button"
onClick={handleCopy}
className="rounded-md bg-slate-900 px-2 py-1 text-[11px] font-semibold text-white hover:bg-slate-800"
>
{copied ? '已复制' : '复制'}
</button>
</div>
</div>
) : null}
{qrCode ? (
<div className="mt-3 rounded-lg bg-white p-3">
<Image src={qrCode} alt={`${label} QR`} width={144} height={144} className="mx-auto h-36 w-36 object-contain" unoptimized />
</div>
) : null}
<div className="mt-4 flex flex-wrap gap-2">
<button
type="button"
onClick={handleRecord}
className="inline-flex items-center justify-center rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-brand-dark"
>
</button>
</div>
</div>
)
}

View File

@ -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<TResponse>(
url: string,
payload: Record<string, unknown>,
): Promise<TResponse> {
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<void> {
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<void> {
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;
}

View File

@ -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<string | null>(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 (
<div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-brand">
{title}
</p>
<h3 className="text-xl font-semibold text-slate-900">{plan.name}</h3>
<p className="mt-1 text-sm text-slate-600">{plan.description}</p>
<p className="mt-2 text-lg font-bold text-slate-900">
{plan.currency} {plan.price.toFixed(2)}
{plan.interval ? ` / ${plan.interval}` : ""}
</p>
</div>
<Link
href={`/${config.slug}#editions`}
className="text-sm font-medium text-brand hover:text-brand-dark"
>
{linkLabel}
</Link>
</div>
<div className="mt-4 space-y-3">
<button
type="button"
onClick={handleCheckout}
className="inline-flex w-full items-center justify-center rounded-lg bg-brand px-4 py-3 text-sm font-semibold text-white transition-colors hover:bg-brand-dark"
>
{lang === "zh"
? kind === "subscription"
? "使用 Stripe 订阅"
: "使用 Stripe 购买"
: kind === "subscription"
? "Subscribe with Stripe"
: "Buy with Stripe"}
</button>
<p className="text-xs text-slate-500">
{lang === "zh"
? "购买前需要先登录,支付状态将自动同步到账户中心。"
: "Sign in before checkout. Subscription state will sync back to your account automatically."}
</p>
{statusMessage ? (
<p className="text-sm text-brand-dark">{statusMessage}</p>
) : null}
</div>
</div>
);
}
export default function ProductBillingActions({ config, lang }: ProductBillingActionsProps) {
const [statusMessage, setStatusMessage] = useState<string | null>(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<string, unknown>
}) => {
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 (
<section id="billing" aria-labelledby="billing-title" className="bg-slate-50 py-12">
<section
id="billing"
aria-labelledby="billing-title"
className="bg-slate-50 py-12"
>
<div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 id="billing-title" className="text-2xl font-bold text-slate-900">
{lang === 'zh' ? '支付与订阅' : 'Payments & Subscription'}
<h2
id="billing-title"
className="text-2xl font-bold text-slate-900"
>
{lang === "zh" ? "支付与订阅" : "Payments & Subscription"}
</h2>
<p className="mt-1 text-sm text-slate-600">
{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."}
</p>
</div>
<div className="text-sm text-slate-700">
{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"}
</div>
</div>
<CheckoutStatusBanner className="mt-6" />
<div className="mt-8 grid gap-6 lg:grid-cols-2">
{paygo ? (
<div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-brand">Pay-as-you-go</p>
<h3 className="text-xl font-semibold text-slate-900">{paygo.name}</h3>
<p className="mt-1 text-sm text-slate-600">{paygo.description}</p>
<p className="mt-2 text-lg font-bold text-slate-900">
{paygo.currency} {paygo.price.toFixed(2)}
</p>
</div>
<Link
href={`/${config.slug}#editions`}
className="text-sm font-medium text-brand hover:text-brand-dark"
>
{lang === 'zh' ? '查看方案' : 'View editions'}
</Link>
</div>
<div className="mt-4">
<PayPalPayGoButton
clientId={clientId}
currency={paygo.currency}
amount={paygo.price}
description={paygo.description}
productSlug={config.slug}
planId={paygo.planId}
onApprove={(orderId, data) =>
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 ? (
<div className="mt-5 space-y-2">
<p className="text-sm font-medium text-slate-800">
{lang === 'zh'
? '支持 PayPal / 以太坊 / USDT 扫码记录:'
: 'QR checkout for PayPal, Ethereum, and USDT:'}
</p>
<div className="grid gap-3 md:grid-cols-2">
{paygo.paymentMethods.map((method: BillingPaymentMethod) => (
<CryptoBillingWidget
key={`${paygo.planId}-${method.type}`}
method={method}
planId={paygo.planId}
planName={paygo.name}
kind="paygo"
productSlug={config.slug}
onRecord={(details) =>
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 },
})
}
/>
))}
</div>
</div>
) : null}
</div>
</div>
<BillingCard
config={config}
lang={lang}
title="Pay-as-you-go"
linkLabel={lang === "zh" ? "查看方案" : "View editions"}
kind="paygo"
plan={paygo}
/>
) : null}
{saas ? (
<div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-brand">SaaS</p>
<h3 className="text-xl font-semibold text-slate-900">{saas.name}</h3>
<p className="mt-1 text-sm text-slate-600">{saas.description}</p>
<p className="mt-2 text-lg font-bold text-slate-900">
{saas.currency} {saas.price.toFixed(2)} / {saas.interval ?? 'month'}
</p>
</div>
<Link
href={`/${config.slug}#editions`}
className="text-sm font-medium text-brand hover:text-brand-dark"
>
{lang === 'zh' ? '订阅详情' : 'Subscription details'}
</Link>
</div>
<div className="mt-4">
<PayPalSubscriptionButton
clientId={clientId}
currency={saas.currency}
planId={saas.planId}
productSlug={config.slug}
onApprove={(subscriptionId, data) =>
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 ? (
<div className="mt-5 space-y-2">
<p className="text-sm font-medium text-slate-800">
{lang === 'zh'
? '订阅也可通过 PayPal / 以太坊 / USDT 扫码:'
: 'Subscriptions via PayPal, Ethereum, or USDT QR codes:'}
</p>
<div className="grid gap-3 md:grid-cols-2">
{saas.paymentMethods.map((method: BillingPaymentMethod) => (
<CryptoBillingWidget
key={`${saas.planId}-${method.type}`}
method={method}
planId={saas.planId}
planName={saas.name}
kind="subscription"
productSlug={config.slug}
onRecord={(details) =>
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 },
})
}
/>
))}
</div>
</div>
) : null}
</div>
</div>
<BillingCard
config={config}
lang={lang}
title="SaaS"
linkLabel={lang === "zh" ? "订阅详情" : "Subscription details"}
kind="subscription"
plan={saas}
/>
) : null}
</div>
{statusMessage ? (
<p className="mt-6 text-sm text-brand-dark" role="status">
{statusMessage}
</p>
) : null}
</div>
</section>
)
);
}

View File

@ -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<string, unknown>
}
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<BillingPaymentMethod['type'], string> = {
paypal: '建议使用 PayPal App 扫码,支付成功后系统自动确认订单。',
ethereum: '使用以太坊ERC20转账支付后订单会自动识别并激活。',
usdt: '使用 USDTTRC20转账支付完成后自动续订或开通。',
}
const kindLabel: Record<"paygo" | "subscription", string> = {
paygo: "PAY-AS-YOU-GO",
subscription: "SAAS",
};
export default function BillingOptionsPanel() {
const [statusMessage, setStatusMessage] = useState<string | null>(null)
const [selected, setSelected] = useState<ProductOption | null>(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<string | null>(null);
const [submitting, setSubmitting] = useState<string | null>(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 (
<div key={`${selected?.plan.planId}-${method.type}`} className={paymentCardClass}>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--color-primary)]">{method.label || method.type}</p>
<p className="text-lg font-semibold text-[var(--color-heading)]">{method.type === 'ethereum' ? 'ETHERC20' : 'USDTTRC20'}</p>
<p className="text-sm text-[var(--color-text-subtle)]">{methodHints[method.type]}</p>
</div>
{qrCode ? (
<div className="rounded-xl bg-white p-4 text-center">
<Image
src={qrCode}
alt={`${method.label || method.type} QR`}
width={224}
height={224}
className={qrClassName}
unoptimized
/>
</div>
) : null}
{address ? (
<div className="space-y-2 rounded-xl bg-[color:var(--color-surface-muted)] p-3 text-xs text-[var(--color-text)]">
<div className="flex items-center justify-between">
<span className="font-semibold text-[color:var(--color-heading)]"></span>
{network ? (
<span className="rounded-full bg-white px-3 py-1 text-[11px] font-semibold text-[color:var(--color-heading)]">
{network}
</span>
) : null}
</div>
<div className="flex items-center gap-2 rounded-lg bg-white/60 p-2">
<span className="flex-1 truncate font-mono" title={address}>
{address}
</span>
<button
type="button"
onClick={async () => {
if (!navigator.clipboard?.writeText) return
await navigator.clipboard.writeText(address)
setStatusMessage('已复制钱包地址,完成支付后订单将自动识别。')
}}
className="inline-flex items-center rounded-md bg-white px-3 py-1 text-[11px] font-semibold text-[color:var(--color-heading)] shadow-sm transition-colors hover:bg-[color:var(--color-surface)]"
>
</button>
</div>
</div>
) : null}
<div className="mt-auto flex flex-wrap gap-2">
<button type="button" onClick={() => handleCryptoRecord(method)} className={primaryButtonClass}>
使
</button>
</div>
</div>
)
}
const renderPayPalCard = (method: BillingPaymentMethod) => {
if (!selected) return null
return (
<div className={paymentCardClass}>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--color-primary)]">PayPal</p>
<p className="text-lg font-semibold text-[var(--color-heading)]">PayPal </p>
<p className="text-sm text-[var(--color-text-subtle)]">{methodHints.paypal}</p>
</div>
{method.qrCode ? (
<div className="rounded-xl bg-white p-4 text-center">
<Image src={method.qrCode} alt="PayPal QR" width={224} height={224} className={qrClassName} unoptimized />
</div>
) : null}
<div className="mt-auto space-y-2">
<div className="rounded-lg border border-[color:var(--color-surface-border)] bg-white p-3 text-center">
{selected.kind === 'paygo' ? (
<PayPalPayGoButton
clientId={selected.clientId}
currency={selected.plan.currency}
amount={selected.plan.price}
description={selected.plan.description}
productSlug={selected.product.slug}
planId={selected.plan.planId}
onApprove={(orderId, data) =>
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 },
})
}
/>
) : (
<PayPalSubscriptionButton
clientId={selected.clientId}
currency={selected.plan.currency}
planId={selected.plan.planId}
productSlug={selected.product.slug}
onApprove={(subscriptionId, 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 },
})
}
/>
)}
</div>
<button
type="button"
className={primaryButtonClass}
onClick={() => setStatusMessage('正在使用 PayPal 支付,完成后系统会自动同步订单。')}
>
使
</button>
</div>
</div>
)
if (!productOptions.length) {
return null;
}
return (
<Card>
<div className="flex flex-col gap-5">
<div className="flex items-start justify-between gap-3 flex-col md:flex-row md:items-center">
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--color-primary)]">Product Mode</p>
<h2 className="text-xl font-semibold text-[color:var(--color-heading)]"></h2>
<p className="text-sm text-[var(--color-text-subtle)]">
PAY-AS-YOU-GO SaaS
</p>
<p className="text-xs font-semibold text-[var(--color-heading)]">
/
</p>
</div>
{selected?.clientId ? (
<p className="rounded-full bg-[color:var(--color-surface-muted)] px-4 py-2 text-xs font-semibold text-[var(--color-heading)]">
PayPal /
</p>
) : (
<p className="rounded-full bg-[color:var(--color-surface-muted)] px-4 py-2 text-xs font-semibold text-[var(--color-heading)]">
PayPal Client ID
</p>
)}
<div className="space-y-3">
<div>
<h2 className="text-xl font-semibold text-[var(--color-heading)]">
Stripe
</h2>
<p className="text-sm text-[var(--color-text-subtle)]">
Stripe
</p>
</div>
<CheckoutStatusBanner />
{statusMessage ? (
<p className="text-sm text-[color:var(--color-danger-foreground)]">
{statusMessage}
</p>
) : null}
</div>
<div className="grid gap-4 md:grid-cols-2">
{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 (
<div
key={`${option.product.slug}-${option.kind}`}
className={`flex h-full flex-col gap-3 rounded-2xl border p-5 shadow-sm transition-colors ${
isActive
? 'border-[color:var(--color-primary)] bg-[color:var(--color-surface)]'
: 'border-[color:var(--color-surface-border)] bg-[color:var(--color-surface-muted)] hover:border-[color:var(--color-primary)]'
}`}
>
<div className="space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--color-primary)]">
{kindLabel[option.kind]}
</p>
<h4 className="text-lg font-semibold text-[var(--color-heading)]">{option.plan.name}</h4>
</div>
<span className="rounded-full bg-white px-3 py-1 text-[11px] font-semibold text-[color:var(--color-heading)]">
{isSubscription ? '自动续费' : '一次性购买'}
</span>
</div>
<p className="text-sm text-[var(--color-text-subtle)]">{description}</p>
<p className="text-2xl font-bold text-[var(--color-heading)]">
{option.plan.currency} {option.plan.price.toFixed(0)}
{isSubscription && option.plan.interval ? ` / ${option.plan.interval}` : ''}
</p>
</div>
<div className="mt-auto">
<button
type="button"
onClick={() => setSelected(option)}
className={`${
isActive
? 'bg-[var(--color-primary)] text-white hover:bg-[var(--color-primary-strong)]'
: 'bg-white text-[var(--color-heading)] hover:bg-[color:var(--color-surface)]'
} inline-flex w-full items-center justify-center rounded-md px-4 py-2 text-sm font-semibold shadow-sm transition-colors`}
>
{actionLabel[option.kind]}
</button>
</div>
</div>
)
})}
</div>
{selected ? (
<div className="space-y-4 rounded-2xl border border-[color:var(--color-surface-border)] bg-[color:var(--color-surface-muted)] p-5">
<div className="flex flex-col gap-1">
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--color-primary)]">Payment</p>
<h3 className="text-lg font-semibold text-[var(--color-heading)]"></h3>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{productOptions.map((option) => (
<div
key={`${option.product.slug}-${option.kind}`}
className="rounded-xl border border-[color:var(--color-surface-border)] bg-[color:var(--color-surface)] p-4 shadow-sm"
>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--color-primary)]">
{option.product.name} · {kindLabel[option.kind]}
</p>
<h3 className="text-lg font-semibold text-[var(--color-heading)]">
{option.plan.name}
</h3>
<p className="text-sm text-[var(--color-text-subtle)]">
/ 使
{option.plan.description}
</p>
<p className="text-lg font-semibold text-[var(--color-heading)]">
{option.plan.currency} {option.plan.price.toFixed(2)}
{option.plan.interval ? ` / ${option.plan.interval}` : ""}
</p>
</div>
<div className="grid gap-4 md:grid-cols-3">
{activeMethods.map((method) =>
method.type === 'paypal' ? renderPayPalCard(method) : renderCryptoCard(method),
<div className="mt-4 space-y-2">
<button
type="button"
onClick={() => handleCheckout(option)}
disabled={submitting === option.plan.planId}
className="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)] disabled:cursor-not-allowed disabled:opacity-70"
>
{submitting === option.plan.planId
? "跳转中…"
: option.kind === "subscription"
? "使用 Stripe 订阅"
: "使用 Stripe 购买"}
</button>
{!option.plan.stripePriceId ? (
<p className="text-xs text-[var(--color-text-subtle)]">
Stripe price_id
</p>
) : (
<p className="text-xs text-[var(--color-text-subtle)]">
</p>
)}
</div>
</div>
) : null}
{statusMessage ? <p className="text-sm text-[var(--color-primary)]">{statusMessage}</p> : null}
))}
</div>
</Card>
)
);
}

View File

@ -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<string, unknown>
}
id: string;
provider: string;
kind?: string;
planId?: string;
status: string;
paymentMethod?: string;
externalId: string;
createdAt?: string;
updatedAt?: string;
cancelledAt?: string;
meta?: Record<string, unknown>;
};
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<SubscriptionResponse>('/api/auth/subscriptions', fetcher)
const [submitting, setSubmitting] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const { data, isLoading, mutate } = useSWR<SubscriptionResponse>(
"/api/auth/subscriptions",
fetcher,
);
const [submitting, setSubmitting] = useState<string | null>(null);
const [portalLoading, setPortalLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<Card>
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-xl font-semibold text-[var(--color-heading)]"></h2>
<h2 className="text-xl font-semibold text-[var(--color-heading)]">
</h2>
<p className="text-sm text-[var(--color-text-subtle)]">
PayPal / / USDT Pay-as-you-go SaaS
Stripe
</p>
</div>
<button
type="button"
onClick={async () => {
setPortalLoading(true);
setError(null);
try {
await openStripePortal({ returnPath: "/panel/subscription" });
} catch (err) {
console.warn("Failed to open Stripe portal", err);
setError("暂时无法打开 Stripe 客户门户。");
} finally {
setPortalLoading(false);
}
}}
className="inline-flex 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)] disabled:cursor-not-allowed disabled:opacity-70"
disabled={portalLoading}
>
{portalLoading ? "跳转中…" : "管理 Stripe 账单"}
</button>
</div>
{error ? <p className="mt-3 text-sm text-red-600">{error}</p> : null}
{isLoading ? (
<p className="mt-4 text-sm text-[var(--color-text-subtle)]"></p>
<p className="mt-4 text-sm text-[var(--color-text-subtle)]">
</p>
) : records.length === 0 ? (
<p className="mt-4 text-sm text-[var(--color-text-subtle)]"></p>
<p className="mt-4 text-sm text-[var(--color-text-subtle)]">
</p>
) : (
<div className="mt-4 grid gap-3 md:grid-cols-2">
{records.map((record) => (
<div
key={record.id}
className="rounded-xl border border-[color:var(--color-surface-border)] bg-[color:var(--color-surface)] p-4 shadow-sm"
>
<div className="flex items-center justify-between">
<div>
<p className="text-xs uppercase tracking-wide text-[var(--color-primary)]">{record.provider}</p>
<h3 className="text-base font-semibold text-[var(--color-text)]">{record.kind ?? 'subscription'}</h3>
{record.paymentMethod ? (
<p className="text-xs text-[var(--color-text-subtle)]">{record.paymentMethod}</p>
{records.map((record) => {
const canCancel =
record.provider === "stripe" &&
(record.kind ?? "subscription") === "subscription";
return (
<div
key={record.id}
className="rounded-xl border border-[color:var(--color-surface-border)] bg-[color:var(--color-surface)] p-4 shadow-sm"
>
<div className="flex items-center justify-between">
<div>
<p className="text-xs uppercase tracking-wide text-[var(--color-primary)]">
{record.provider}
</p>
<h3 className="text-base font-semibold text-[var(--color-text)]">
{record.kind ?? "subscription"}
</h3>
{record.paymentMethod ? (
<p className="text-xs text-[var(--color-text-subtle)]">
{record.paymentMethod}
</p>
) : null}
</div>
<span
className={`rounded-full px-3 py-1 text-xs font-semibold ${record.status === "cancelled" ? "bg-red-50 text-red-700" : "bg-green-50 text-green-700"}`}
>
{record.status}
</span>
</div>
<dl className="mt-3 space-y-1 text-sm text-[var(--color-text-subtle)]">
<div className="flex items-center justify-between">
<dt>Plan</dt>
<dd className="font-medium text-[var(--color-text)]">
{record.planId || "—"}
</dd>
</div>
<div className="flex items-center justify-between">
<dt>External ID</dt>
<dd className="break-all text-[var(--color-text)]">
{record.externalId}
</dd>
</div>
<div className="flex items-center justify-between">
<dt>Created</dt>
<dd className="text-[var(--color-text)]">
{formatDate(record.createdAt)}
</dd>
</div>
<div className="flex items-center justify-between">
<dt>Updated</dt>
<dd className="text-[var(--color-text)]">
{formatDate(record.updatedAt)}
</dd>
</div>
{typeof record.meta?.startsAt === "string" ? (
<div className="flex items-center justify-between">
<dt>Starts</dt>
<dd className="text-[var(--color-text)]">
{formatDate(record.meta?.startsAt as string)}
</dd>
</div>
) : null}
{typeof record.meta?.expiresAt === "string" ? (
<div className="flex items-center justify-between">
<dt>Expires</dt>
<dd className="text-[var(--color-text)]">
{formatDate(record.meta?.expiresAt as string)}
</dd>
</div>
) : null}
{record.cancelledAt ? (
<div className="flex items-center justify-between">
<dt>Cancelled</dt>
<dd className="text-[var(--color-text)]">
{formatDate(record.cancelledAt)}
</dd>
</div>
) : null}
{record.meta?.note ? (
<div className="flex items-center justify-between">
<dt></dt>
<dd className="text-[var(--color-text)]">
{String(record.meta?.note)}
</dd>
</div>
) : null}
</dl>
<div className="mt-4 flex flex-wrap gap-2">
<button
type="button"
onClick={() => handleCancel(record.externalId)}
disabled={
!canCancel ||
record.status === "cancelled" ||
submitting === record.externalId
}
className="inline-flex items-center justify-center rounded-md border border-[color:var(--color-surface-border)] px-4 py-2 text-sm font-medium text-[color:var(--color-danger-foreground)] transition-colors hover:border-[color:var(--color-danger-border)] hover:text-[color:var(--color-danger-foreground)] disabled:cursor-not-allowed disabled:opacity-60"
>
{!canCancel
? "历史记录"
: record.status === "cancelled"
? "已取消"
: submitting === record.externalId
? "处理中…"
: "停止订阅"}
</button>
</div>
<span
className={`rounded-full px-3 py-1 text-xs font-semibold ${record.status === 'cancelled' ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}
>
{record.status}
</span>
</div>
<dl className="mt-3 space-y-1 text-sm text-[var(--color-text-subtle)]">
<div className="flex items-center justify-between">
<dt>Plan</dt>
<dd className="font-medium text-[var(--color-text)]">{record.planId || '—'}</dd>
</div>
<div className="flex items-center justify-between">
<dt>External ID</dt>
<dd className="break-all text-[var(--color-text)]">{record.externalId}</dd>
</div>
<div className="flex items-center justify-between">
<dt>Created</dt>
<dd className="text-[var(--color-text)]">{formatDate(record.createdAt)}</dd>
</div>
<div className="flex items-center justify-between">
<dt>Updated</dt>
<dd className="text-[var(--color-text)]">{formatDate(record.updatedAt)}</dd>
</div>
{typeof record.meta?.startsAt === 'string' ? (
<div className="flex items-center justify-between">
<dt>Starts</dt>
<dd className="text-[var(--color-text)]">{formatDate(record.meta?.startsAt as string)}</dd>
</div>
) : null}
{typeof record.meta?.expiresAt === 'string' ? (
<div className="flex items-center justify-between">
<dt>Expires</dt>
<dd className="text-[var(--color-text)]">{formatDate(record.meta?.expiresAt as string)}</dd>
</div>
) : null}
{record.cancelledAt ? (
<div className="flex items-center justify-between">
<dt>Cancelled</dt>
<dd className="text-[var(--color-text)]">{formatDate(record.cancelledAt)}</dd>
</div>
) : null}
{record.meta?.note ? (
<div className="flex items-center justify-between">
<dt></dt>
<dd className="text-[var(--color-text)]">{String(record.meta?.note)}</dd>
</div>
) : null}
</dl>
{record.paymentQr ? (
<div className="mt-3 rounded-lg bg-white p-3">
<Image
src={record.paymentQr}
alt={`QR for ${record.externalId}`}
width={112}
height={112}
className="mx-auto h-28 w-28 object-contain"
unoptimized
/>
</div>
) : null}
<div className="mt-4 flex flex-wrap gap-2">
<button
type="button"
onClick={() => handleCancel(record.externalId)}
disabled={record.status === 'cancelled' || submitting === record.externalId}
className="inline-flex items-center justify-center rounded-md border border-[color:var(--color-surface-border)] px-4 py-2 text-sm font-medium text-[color:var(--color-danger-foreground)] transition-colors hover:border-[color:var(--color-danger-border)] hover:text-[color:var(--color-danger-foreground)] disabled:cursor-not-allowed disabled:opacity-60"
>
{record.status === 'cancelled' ? '已取消' : submitting === record.externalId ? '处理中…' : '停止订阅'}
</button>
</div>
</div>
))}
);
})}
</div>
)}
</Card>
)
);
}

View File

@ -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 (
<div className="space-y-4">
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/panel' },
{ label: 'Subscription', href: '/panel/subscription' },
{ label: "Dashboard", href: "/panel" },
{ label: "Subscription", href: "/panel/subscription" },
]}
/>
<Card>
<h1 className="text-2xl font-semibold text-gray-900"></h1>
<p className="mt-2 text-sm text-gray-600">
Demo
Demo
</p>
</Card>
</div>
)
);
}
return (
<div className="space-y-4">
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/panel' },
{ label: 'Subscription', href: '/panel/subscription' },
{ label: "Dashboard", href: "/panel" },
{ label: "Subscription", href: "/panel/subscription" },
]}
/>
<Card>
<h1 className="text-2xl font-semibold text-gray-900"></h1>
<p className="mt-2 text-sm text-gray-600">
PayPal / ETHERC20/ USDTTRC20
Stripe
</p>
<div className="mt-3 grid gap-3 md:grid-cols-3">
<div className="rounded-xl bg-[color:var(--color-surface-muted)] p-3 text-sm text-gray-700 shadow-sm">
<p className="font-semibold text-[color:var(--color-heading)]"> 1</p>
<p className="text-gray-600">PAYG SaaS</p>
<p className="font-semibold text-[color:var(--color-heading)]">
1
</p>
<p className="text-gray-600">
Stripe
</p>
</div>
<div className="rounded-xl bg-[color:var(--color-surface-muted)] p-3 text-sm text-gray-700 shadow-sm">
<p className="font-semibold text-[color:var(--color-heading)]"> 2</p>
<p className="text-gray-600">PayPal / ETH / USDT </p>
<p className="font-semibold text-[color:var(--color-heading)]">
2 Stripe
</p>
<p className="text-gray-600">
Stripe Checkout
</p>
</div>
<div className="rounded-xl bg-[color:var(--color-surface-muted)] p-3 text-sm text-gray-700 shadow-sm">
<p className="font-semibold text-[color:var(--color-heading)]"> 3</p>
<p className="text-gray-600"> </p>
<p className="font-semibold text-[color:var(--color-heading)]">
3
</p>
<p className="text-gray-600">
Stripe webhook
</p>
</div>
</div>
</Card>
<BillingOptionsPanel />
<SubscriptionPanel />
</div>
)
);
}

View File

@ -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<string, unknown>
paymentMethods?: BillingPaymentMethod[]
name: string;
description?: string;
price: number;
currency: string;
interval?: string;
planId?: string;
stripePriceId?: string;
mode: StripeBillingMode;
meta?: Record<string, unknown>;
};
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<string, ProductConfig>(
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);

View File

@ -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;

View File

@ -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;

View File

@ -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;