Merge branch 'main' into release/v0.2
This commit is contained in:
commit
eaa383bb16
@ -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=
|
||||
|
||||
16
README.md
16
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`
|
||||
|
||||
|
||||
51
docs/integrations/stripe-billing.md
Normal file
51
docs/integrations/stripe-billing.md
Normal 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.
|
||||
@ -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[]>(() => {
|
||||
|
||||
28
src/app/api/auth/stripe/checkout/route.ts
Normal file
28
src/app/api/auth/stripe/checkout/route.ts
Normal 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 });
|
||||
}
|
||||
28
src/app/api/auth/stripe/portal/route.ts
Normal file
28
src/app/api/auth/stripe/portal/route.ts
Normal 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 });
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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"}
|
||||
|
||||
44
src/components/billing/CheckoutStatusBanner.tsx
Normal file
44
src/components/billing/CheckoutStatusBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
76
src/components/billing/stripe-client.ts
Normal file
76
src/components/billing/stripe-client.ts
Normal 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;
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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: '使用 USDT(TRC20)转账,支付完成后自动续订或开通。',
|
||||
}
|
||||
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' ? 'ETH(ERC20)' : 'USDT(TRC20)'}</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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 / ETH(ERC20)/ USDT(TRC20)扫码支付。支付成功后系统将自动识别并开通或续订服务。
|
||||
所有套餐统一通过 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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user