's'
This commit is contained in:
69
README.md
69
README.md
@@ -1,57 +1,46 @@
|
||||
# NomadVIP | 异度星球
|
||||
|
||||
VIP 会员解锁页,集成 PocketBase 支付校验、XorPay/ZPAY 支付。
|
||||
NomadVIP 前端(Next.js App Router),负责会员购买、支付跳转、支付状态轮询,以及与 `payjsapi` / PocketBase 联动完成 VIP 开通。
|
||||
|
||||
## 服务配置(参考 desktop digital)
|
||||
## 核心流程
|
||||
|
||||
ZPAY、PocketBase、MinIO 配置与 `C:\project\digital` 保持一致,复制 `.env.example` 为 `.env.local` 后按需修改:
|
||||
1. 前端调用 `app/api/pay` 发起支付。
|
||||
2. `app/api/pay` 按设备分流:
|
||||
- 微信内:`xorpay`
|
||||
- PC / 手机浏览器:`zpay`
|
||||
3. 支付成功后通过 `payjsapi` 异步通知写入 PocketBase。
|
||||
4. 若通知延迟,前端会调用 `app/api/pay/confirm` 兜底写入 `payments` + `site_vip`。
|
||||
|
||||
## 关键配置
|
||||
|
||||
复制 `.env.example` 为 `.env.local`,按需修改:
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY` | Server Actions 加密密钥(可选,未设时自动从 `config/server-actions.key` 加载) | `config/server-actions.key` |
|
||||
| `NEXT_PUBLIC_POCKETBASE_URL` | PocketBase 地址 | `https://pocketbase.hackrobot.cn` |
|
||||
| `NEXT_PUBLIC_API_URL` / `PAYMENT_API_URL` | payjsapi 地址 | `https://api.hackrobot.cn` |
|
||||
| `PAYMENT_PROVIDER` | 支付提供商(payjsapi 环境变量) | `xorpay` / `zpay` |
|
||||
| `NEXT_PUBLIC_POCKETBASE_URL` | PocketBase 地址(浏览器侧) | `https://pocketbase.hackrobot.cn` |
|
||||
| `POCKETBASE_URL` | PocketBase 地址(服务端) | `http://127.0.0.1:8090` |
|
||||
| `PAYMENT_API_URL` / `NEXT_PUBLIC_API_URL` | payjsapi 公网地址 | `https://api.hackrobot.cn` |
|
||||
| `PAYMENT_API_INTERNAL_URL` | 同机部署时 payjsapi 内网地址 | `http://127.0.0.1:8007` |
|
||||
| `PAYMENT_DEFAULT_AMOUNT` | 默认支付金额(分) | `18800` |
|
||||
| `ZPAY_SUBMIT_URL` | ZPAY 收银台地址 | `https://zpayz.cn/submit.php` |
|
||||
| `POCKETBASE_EMAIL` / `POCKETBASE_PASSWORD` | confirm 兜底写库所需管理员账号 | - |
|
||||
|
||||
**XorPay**:直接调用 payh5,无 redirect 中转,表单提交至 xorpay 收银台。payjsapi 需设置 `PAYMENT_PROVIDER=xorpay`。切换渠道仅需前端改参数,后端 payjsapi 标准化接口。
|
||||
|
||||
**设备逻辑**:PC 显示二维码 + 支付状态轮询;手机/微信无中转页,直接跳转收银台。默认价格 8.8 元(880 分)。
|
||||
| `MINIO_ENDPOINT` | MinIO 端点 | `minioweb.hackrobot.cn` |
|
||||
| `MINIO_BUCKET` | 存储桶 | `hackrobot` |
|
||||
|
||||
详见 `config/services.config.ts`。
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
访问 `http://localhost:3000`。
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
## 自检与已修复问题(2026-04-01)
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
- 已修复:`app/api/pay/confirm` 在兜底写 `payments` 时使用默认金额,可能导致测试价/临时调价场景记账错误。
|
||||
- 修复后:优先使用支付网关返回的 `pay_price`(元)换算为分;只有拿不到实际金额时才回退默认金额。
|
||||
|
||||
## Learn More
|
||||
## 常见排查
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
- 支付发起失败:优先检查 `PAYMENT_API_INTERNAL_URL` / `PAYMENT_API_URL` 是否可达。
|
||||
- 已支付但未开通:检查 `payjsapi` 的 `/nomadvip/order_status` 与异步通知日志。
|
||||
- 账号已支付但 VIP 未识别:检查 `vip_email_link` 关联是否建立。
|
||||
|
||||
@@ -1026,6 +1026,8 @@ export default function HomeClient() {
|
||||
onVerify={checkPaymentFromPB}
|
||||
onVerifyByEmail={checkVerifyByEmail}
|
||||
onVerifySuccess={handleVerifySuccess}
|
||||
wechatNick={wechatNick}
|
||||
wechatAvatar={wechatAvatar}
|
||||
/>
|
||||
)}
|
||||
</VipLayout>
|
||||
|
||||
@@ -11,9 +11,18 @@ import {
|
||||
import { queryXorPayOrderStatus } from "@/app/lib/xorpayStatus";
|
||||
import { queryZPayOrderStatus } from "@/app/lib/zpayStatus";
|
||||
|
||||
const getTotalFee = () =>
|
||||
const getDefaultTotalFee = () =>
|
||||
getPaymentConfig().defaultAmount ?? Number(process.env.PAYMENT_DEFAULT_AMOUNT) ?? 8800;
|
||||
|
||||
function parsePayPriceToCents(payPrice: unknown): number | null {
|
||||
if (payPrice == null) return null;
|
||||
const raw = String(payPrice).trim();
|
||||
if (!raw) return null;
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value) || value <= 0) return null;
|
||||
return Math.round(value * 100);
|
||||
}
|
||||
|
||||
async function getAdminToken(): Promise<string | null> {
|
||||
const email = process.env.POCKETBASE_EMAIL;
|
||||
const password = process.env.POCKETBASE_PASSWORD;
|
||||
@@ -46,7 +55,7 @@ function resolvePayApiUrl(request: NextRequest): string {
|
||||
async function verifyOrderPaid(
|
||||
request: NextRequest,
|
||||
orderId: string
|
||||
): Promise<boolean> {
|
||||
): Promise<{ paid: boolean; totalFeeCents: number | null }> {
|
||||
const apiUrl = resolvePayApiUrl(request);
|
||||
const url = `${apiUrl}/nomadvip/order_status?order_id=${encodeURIComponent(orderId)}`;
|
||||
console.log("[pay/confirm] verify order_status url=%s", url);
|
||||
@@ -59,11 +68,12 @@ async function verifyOrderPaid(
|
||||
}).finally(() => clearTimeout(timeout));
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const paid = !!data?.paid;
|
||||
const totalFeeCents = parsePayPriceToCents(data?.pay_price);
|
||||
if (!paid && !res.ok) {
|
||||
console.warn("[pay/confirm] order_status non-ok:", res.status, url, data);
|
||||
}
|
||||
if (paid) {
|
||||
return true;
|
||||
return { paid: true, totalFeeCents };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[pay/confirm] verifyOrderPaid fetch error:", e);
|
||||
@@ -79,7 +89,10 @@ async function verifyOrderPaid(
|
||||
xorpay?.url || ""
|
||||
);
|
||||
if (xorpay?.paid) {
|
||||
return true;
|
||||
return {
|
||||
paid: true,
|
||||
totalFeeCents: parsePayPriceToCents(xorpay?.pay_price),
|
||||
};
|
||||
}
|
||||
|
||||
const zpay = await queryZPayOrderStatus(orderId, 5000);
|
||||
@@ -91,7 +104,10 @@ async function verifyOrderPaid(
|
||||
zpay?.error || "",
|
||||
zpay?.url || ""
|
||||
);
|
||||
return !!zpay?.paid;
|
||||
return {
|
||||
paid: !!zpay?.paid,
|
||||
totalFeeCents: parsePayPriceToCents(zpay?.pay_price),
|
||||
};
|
||||
}
|
||||
|
||||
async function createMemosUser(username: string, password: string): Promise<boolean> {
|
||||
@@ -142,8 +158,8 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ ok: false, error: "缺少 order_id 或 user_id" }, { status: 400 });
|
||||
}
|
||||
|
||||
const paid = await verifyOrderPaid(request, orderId);
|
||||
if (!paid) {
|
||||
const verifyResult = await verifyOrderPaid(request, orderId);
|
||||
if (!verifyResult.paid) {
|
||||
console.error("[pay/confirm] 订单未支付 order_id=%s user_id=%s (请检查 payjsapi order_status 接口)", orderId, userId);
|
||||
return NextResponse.json({ ok: false, error: "订单未支付" }, { status: 400 });
|
||||
}
|
||||
@@ -154,7 +170,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const pb = getPocketBaseConfig();
|
||||
const totalFee = getTotalFee();
|
||||
const totalFee = verifyResult.totalFeeCents ?? getDefaultTotalFee();
|
||||
|
||||
const checkExisting = await fetch(
|
||||
`${pb.url}/api/collections/payments/records?filter=${encodeURIComponent(`order_id="${orderId}"`)}&perPage=1`,
|
||||
|
||||
@@ -27,7 +27,7 @@ function buildErrorHtml(errorMsg: string): string {
|
||||
function buildPayHtml(
|
||||
payUrl: string,
|
||||
params: Record<string, unknown>,
|
||||
options?: { directToCashier?: boolean }
|
||||
options?: { directToCashier?: boolean; displayName?: string; avatar?: string }
|
||||
): string {
|
||||
const flat: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
@@ -40,10 +40,13 @@ function buildPayHtml(
|
||||
.map(([k, v]) => `<input type="hidden" name="${escapeAttr(k)}" value="${escapeAttr(v)}" />`)
|
||||
.join("\n");
|
||||
const direct = options?.directToCashier;
|
||||
const displayName = (options?.displayName || "当前用户").trim();
|
||||
const avatar = (options?.avatar || "").trim();
|
||||
const showPayerHint = !!avatar;
|
||||
// direct:微信内无中转,直接提交 | 非 direct:PC/H5 有中转页提示
|
||||
return direct
|
||||
? `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>支付</title><style>body{margin:0;overflow:hidden}</style></head><body><form id="f" action="${escapeAttr(payUrl)}" method="POST" enctype="application/x-www-form-urlencoded">${inputs}</form><script>document.getElementById("f").submit()</script></body></html>`
|
||||
: `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>跳转支付</title><style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f0f4f8;}p{color:#64748b;}</style></head><body><p>正在跳转到支付页面...</p><form id="zpayForm" action="${escapeAttr(payUrl)}" method="POST" enctype="application/x-www-form-urlencoded">${inputs}</form><script>document.getElementById("zpayForm").submit();</script></body></html>`;
|
||||
: `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>跳转支付</title><style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f0f4f8;}.wrap{background:#fff;border:1px solid #e2e8f0;border-radius:14px;padding:14px 16px;box-shadow:0 8px 26px rgba(15,23,42,.08);}.row{display:flex;align-items:center;gap:10px;}.av{width:30px;height:30px;border-radius:9999px;object-fit:cover;background:#e2e8f0;display:flex;align-items:center;justify-content:center;font-size:13px;}.t1{font-size:13px;color:#0f172a;font-weight:600;}.t2{font-size:12px;color:#64748b;}</style></head><body>${showPayerHint ? `<div class="wrap"><div class="row"><img class="av" src="${escapeAttr(avatar)}" alt="${escapeAttr(displayName)}" /><div><div class="t1">开通对象:${escapeAttr(displayName)}</div><div class="t2">正在为你升级 VIP,马上进入支付...</div></div></div></div>` : ""}<form id="zpayForm" action="${escapeAttr(payUrl)}" method="POST" enctype="application/x-www-form-urlencoded">${inputs}</form><script>document.getElementById("zpayForm").submit();</script></body></html>`;
|
||||
}
|
||||
|
||||
function setPayOrderCookie(res: NextResponse, orderId: string) {
|
||||
@@ -157,6 +160,8 @@ export async function POST(request: NextRequest) {
|
||||
const type = String(body?.type || "vip");
|
||||
const channel = String(body?.channel || "wxpay");
|
||||
const device = String(body?.device || "pc");
|
||||
const wechatNick = String(body?.wechat_nick || body?.nick || body?.user_name || "").trim();
|
||||
const wechatAvatar = String(body?.wechat_avatar || body?.avatar || "").trim();
|
||||
// 渠道规则:pc/手机浏览器 -> zpay,仅微信内 -> xorpay
|
||||
const effectiveProvider =
|
||||
device === "wechat" ? "xorpay" : "zpay";
|
||||
@@ -243,6 +248,8 @@ export async function POST(request: NextRequest) {
|
||||
const submitUrl = resData?.pay_url || payConfig.zpaySubmitUrl;
|
||||
const html = buildPayHtml(submitUrl, params || {}, {
|
||||
directToCashier: device === "wechat",
|
||||
displayName: wechatNick || user_id,
|
||||
avatar: wechatAvatar,
|
||||
});
|
||||
const res = new NextResponse(html, {
|
||||
status: 200,
|
||||
@@ -255,6 +262,8 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
if (redirectRes.status === 200 && resData?.qrcode_mode && resData?.img) {
|
||||
const userId = String(user_id || "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c);
|
||||
const userNick = String(wechatNick || userId).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c);
|
||||
const userAvatar = String(wechatAvatar || "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c);
|
||||
const returnUrl = String(resData.return_url || finalReturnUrl).replace(
|
||||
/[&<>"']/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c
|
||||
@@ -293,6 +302,9 @@ h2{color:#1e293b;margin:0 0 8px;font-size:1.5rem;}
|
||||
.countdown-num{font-size:1rem;font-weight:700;color:#1e293b;line-height:1;}
|
||||
.countdown-unit{font-size:9px;color:#94a3b8;}
|
||||
.tip{font-size:13px;color:#64748b;margin-top:16px;}
|
||||
.payer{display:flex;align-items:center;justify-content:center;gap:10px;margin:0 0 14px;}
|
||||
.payer-avatar{width:30px;height:30px;border-radius:9999px;border:1px solid #e2e8f0;object-fit:cover;background:#f1f5f9;display:flex;align-items:center;justify-content:center;font-size:12px;}
|
||||
.payer-text{font-size:12px;color:#475569;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.success-wrap{display:none;}
|
||||
.success-wrap.show{display:block;}
|
||||
.wait-wrap.hide{display:none;}
|
||||
@@ -309,6 +321,7 @@ h2{color:#1e293b;margin:0 0 8px;font-size:1.5rem;}
|
||||
<div class="wait-wrap" id="waitWrap">
|
||||
<h2>${esc(title)}</h2>
|
||||
<p class="hint">${esc(hint)}</p>
|
||||
${userAvatar ? `<div class="payer"><img class="payer-avatar" src="${userAvatar}" alt="${userNick}" /><span class="payer-text">开通对象:${userNick} · 正在升级 VIP</span></div>` : ""}
|
||||
<div class="wait-row">
|
||||
<div class="countdown-wrap">
|
||||
<svg class="countdown-ring" width="72" height="72" viewBox="0 0 72 72">
|
||||
@@ -473,6 +486,8 @@ h2{color:#1e293b;margin:0 0 8px;font-size:1.5rem;}
|
||||
const orderId = String(params?.order_id || params?.out_trade_no || "").trim();
|
||||
const html = buildPayHtml(submitUrl, params as Record<string, unknown>, {
|
||||
directToCashier: device === "wechat" || device === "h5",
|
||||
displayName: wechatNick || user_id,
|
||||
avatar: wechatAvatar,
|
||||
});
|
||||
const res = new NextResponse(html, {
|
||||
status: 200,
|
||||
|
||||
@@ -14,6 +14,7 @@ const ANONYMOUS_USER_COOKIE_MAX_AGE = 60 * 60 * 24 * 30;
|
||||
const PAY_ORDER_COOKIE = "nomadvip_pay_order";
|
||||
const PAY_ORDER_MAX_AGE = 60 * 15;
|
||||
const VIP_PAY_TEST_MODE_KEY = "vipPayTestMode";
|
||||
const PAY_LAUNCH_OVERLAY_ID = "nomadvip-pay-launch-overlay";
|
||||
|
||||
function getStorage(key: string): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
@@ -50,6 +51,41 @@ function setPayOrderCookie(orderId: string): void {
|
||||
)}; Path=/; Max-Age=${PAY_ORDER_MAX_AGE}; SameSite=Lax${secure}`;
|
||||
}
|
||||
|
||||
function getDisplayProfile() {
|
||||
const wechatNick = (getStorage("wechatNick") || "").trim();
|
||||
const userName = (getStorage("userName") || "").trim();
|
||||
const userEmail = (getStorage("userEmail") || "").trim();
|
||||
const wechatAvatar = (getStorage("wechatAvatar") || "").trim();
|
||||
const displayName = wechatNick || userName || userEmail || "当前用户";
|
||||
return { displayName, avatar: wechatAvatar || "" };
|
||||
}
|
||||
|
||||
function showPayLaunchOverlay(displayName: string, avatar?: string): void {
|
||||
if (typeof document === "undefined") return;
|
||||
if (!(avatar || "").trim()) return;
|
||||
const prev = document.getElementById(PAY_LAUNCH_OVERLAY_ID);
|
||||
if (prev) prev.remove();
|
||||
const safeName = displayName.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c);
|
||||
const safeAvatar = (avatar || "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c);
|
||||
const node = document.createElement("div");
|
||||
node.id = PAY_LAUNCH_OVERLAY_ID;
|
||||
node.style.cssText = "position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(15,23,42,.28);pointer-events:none;";
|
||||
node.innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:12px 14px;border-radius:12px;background:#0f172a;color:#e2e8f0;box-shadow:0 10px 30px rgba(2,6,23,.35);max-width:90vw;">
|
||||
<img src="${safeAvatar}" alt="${safeName}" style="width:30px;height:30px;border-radius:9999px;object-fit:cover;border:1px solid rgba(148,163,184,.5);" />
|
||||
<div style="font-size:13px;line-height:1.35;">
|
||||
<div style="font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:56vw;">开通对象:${safeName}</div>
|
||||
<div style="opacity:.88;">正在为你升级 VIP,马上进入支付...</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(node);
|
||||
window.setTimeout(() => {
|
||||
const current = document.getElementById(PAY_LAUNCH_OVERLAY_ID);
|
||||
if (current) current.remove();
|
||||
}, 2400);
|
||||
}
|
||||
|
||||
function savePendingOrder(orderId: string): void {
|
||||
const normalizedOrderId = orderId.trim();
|
||||
if (!normalizedOrderId) return;
|
||||
@@ -118,6 +154,8 @@ async function getOrCreateUserId(): Promise<string> {
|
||||
function redirectToPayZpay(params: {
|
||||
user_id: string;
|
||||
wechat_userid?: string;
|
||||
wechat_nick?: string;
|
||||
wechat_avatar?: string;
|
||||
return_url: string;
|
||||
total_fee: number;
|
||||
type: string;
|
||||
@@ -132,6 +170,8 @@ function redirectToPayZpay(params: {
|
||||
const fields: [string, string][] = [
|
||||
["user_id", params.user_id],
|
||||
...(params.wechat_userid ? [["wechat_userid", params.wechat_userid] as [string, string]] : []),
|
||||
...(params.wechat_nick ? [["wechat_nick", params.wechat_nick] as [string, string]] : []),
|
||||
...(params.wechat_avatar ? [["wechat_avatar", params.wechat_avatar] as [string, string]] : []),
|
||||
["return_url", params.return_url],
|
||||
["total_fee", String(params.total_fee)],
|
||||
["type", params.type],
|
||||
@@ -155,6 +195,8 @@ async function redirectToPayZpayH5(
|
||||
params: {
|
||||
user_id: string;
|
||||
wechat_userid?: string;
|
||||
wechat_nick?: string;
|
||||
wechat_avatar?: string;
|
||||
return_url: string;
|
||||
total_fee: number;
|
||||
type: string;
|
||||
@@ -215,6 +257,8 @@ async function redirectToPayZpayH5(
|
||||
function payWechatXorpaySync(params: {
|
||||
user_id: string;
|
||||
wechat_userid?: string;
|
||||
wechat_nick?: string;
|
||||
wechat_avatar?: string;
|
||||
return_url: string;
|
||||
total_fee: number;
|
||||
type: string;
|
||||
@@ -228,6 +272,8 @@ function payWechatXorpaySync(params: {
|
||||
const fields: [string, string][] = [
|
||||
["user_id", params.user_id],
|
||||
...(params.wechat_userid ? [["wechat_userid", params.wechat_userid] as [string, string]] : []),
|
||||
...(params.wechat_nick ? [["wechat_nick", params.wechat_nick] as [string, string]] : []),
|
||||
...(params.wechat_avatar ? [["wechat_avatar", params.wechat_avatar] as [string, string]] : []),
|
||||
["return_url", params.return_url],
|
||||
["total_fee", String(params.total_fee)],
|
||||
["type", params.type],
|
||||
@@ -271,9 +317,17 @@ export async function triggerPay(
|
||||
(getStorage("userid") || "").trim();
|
||||
const isPayTestMode = getStorage(VIP_PAY_TEST_MODE_KEY) === "1";
|
||||
const totalFee = isPayTestMode ? 100 : getPaymentConfig().defaultAmount ?? 8800;
|
||||
const profile = getDisplayProfile();
|
||||
const shouldShowLaunchOverlay =
|
||||
device !== "wechat" && !!(profile.avatar || "").trim();
|
||||
if (shouldShowLaunchOverlay) {
|
||||
showPayLaunchOverlay(profile.displayName, profile.avatar);
|
||||
}
|
||||
const base = {
|
||||
user_id: userId,
|
||||
wechat_userid: wechatUserId || undefined,
|
||||
wechat_nick: profile.displayName,
|
||||
wechat_avatar: profile.avatar || undefined,
|
||||
return_url: returnUrl,
|
||||
total_fee: totalFee,
|
||||
type: "vip",
|
||||
|
||||
@@ -21,6 +21,8 @@ type LandingPageProps = {
|
||||
user_id?: string
|
||||
) => Promise<boolean | { ok: boolean; error?: string }>;
|
||||
onVerifySuccess: () => void;
|
||||
wechatNick?: string | null;
|
||||
wechatAvatar?: string | null;
|
||||
};
|
||||
|
||||
export function LandingPage({
|
||||
@@ -28,6 +30,8 @@ export function LandingPage({
|
||||
onVerify,
|
||||
onVerifyByEmail,
|
||||
onVerifySuccess,
|
||||
wechatNick,
|
||||
wechatAvatar,
|
||||
}: LandingPageProps) {
|
||||
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
||||
const [promoModalOpen, setPromoModalOpen] = useState(false);
|
||||
@@ -71,6 +75,8 @@ export function LandingPage({
|
||||
onJoin={onJoin}
|
||||
onLogin={() => setLoginModalOpen(true)}
|
||||
payTestMode={payTestMode}
|
||||
wechatNick={wechatNick}
|
||||
wechatAvatar={wechatAvatar}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -98,7 +98,9 @@ export function VipHeader({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="hidden sm:inline max-w-[120px] truncate text-sm"><Copyable text={displayName}>{displayName}</Copyable></span>
|
||||
<span className="inline-block max-w-[90px] sm:max-w-[120px] truncate text-sm">
|
||||
<Copyable text={displayName}>{displayName}</Copyable>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</nav>
|
||||
|
||||
@@ -8,13 +8,23 @@ type LandingCTAProps = {
|
||||
onJoin: () => void;
|
||||
onLogin: () => void;
|
||||
payTestMode?: boolean;
|
||||
wechatAvatar?: string | null;
|
||||
wechatNick?: string | null;
|
||||
};
|
||||
|
||||
/** 设为 true 可重新展示「适合你 / 请止步」区块(下方 JSX 与 vip.module.css 内样式均保留) */
|
||||
const SHOW_CTA_AUDIENCE_BOUNDARY = false;
|
||||
|
||||
export function LandingCTA({ onJoin, onLogin, payTestMode }: LandingCTAProps) {
|
||||
export function LandingCTA({
|
||||
onJoin,
|
||||
onLogin,
|
||||
payTestMode,
|
||||
wechatAvatar,
|
||||
wechatNick,
|
||||
}: LandingCTAProps) {
|
||||
const [restoreDate] = useState<string>(() => formatRestoreDateShort());
|
||||
const hasAvatar = !!(wechatAvatar || "").trim();
|
||||
const badgeName = (wechatNick || "微信用户").trim();
|
||||
|
||||
return (
|
||||
<section className={`${styles.section} ${styles.ctaSection}`} aria-labelledby="cta-heading">
|
||||
@@ -75,6 +85,62 @@ export function LandingCTA({ onJoin, onLogin, payTestMode }: LandingCTAProps) {
|
||||
style={payTestMode ? { background: "linear-gradient(135deg,#16a34a,#22c55e)" } : undefined}
|
||||
>
|
||||
🔒 立即加入
|
||||
{hasAvatar ? (
|
||||
<span
|
||||
title={`${badgeName}(未开通)`}
|
||||
aria-label={`${badgeName}(未开通)`}
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "inline-flex",
|
||||
width: 24,
|
||||
height: 24,
|
||||
marginLeft: 10,
|
||||
verticalAlign: "middle",
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={(wechatAvatar || "").trim()}
|
||||
alt={badgeName}
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: "9999px",
|
||||
objectFit: "cover",
|
||||
border: "1px solid rgba(255,255,255,.72)",
|
||||
boxShadow:
|
||||
"0 0 0 1px rgba(15,23,42,.12), 0 2px 6px rgba(2,6,23,.18)",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: -6,
|
||||
bottom: -5,
|
||||
minWidth: 20,
|
||||
height: 12,
|
||||
padding: "0 4px",
|
||||
borderRadius: 9999,
|
||||
background:
|
||||
"linear-gradient(180deg, #fde68a 0%, #f59e0b 58%, #d97706 100%)",
|
||||
border: "1px solid rgba(251,191,36,.95)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#7c2d12",
|
||||
fontSize: 7,
|
||||
lineHeight: "12px",
|
||||
fontWeight: 800,
|
||||
letterSpacing: ".25px",
|
||||
boxShadow:
|
||||
"0 2px 7px rgba(245,158,11,.45), inset 0 1px 0 rgba(255,255,255,.55)",
|
||||
}}
|
||||
>
|
||||
VIP
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
<button type="button" onClick={onLogin} className={styles.ctaBtnSecondary}>
|
||||
已有账号登录
|
||||
|
||||
Reference in New Issue
Block a user