4 Commits
test ... test2

Author SHA1 Message Date
eric
e4cd2a991e 's' 2026-05-09 18:22:08 -05:00
eric
05610e48b8 's' 2026-04-02 05:46:52 -05:00
eric
180e81e090 's' 2026-04-02 00:29:36 -05:00
eric
de86a65293 's' 2026-04-01 02:23:17 -05:00
12 changed files with 216 additions and 54 deletions

View File

@@ -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` 关联是否建立。

View File

@@ -1026,6 +1026,8 @@ export default function HomeClient() {
onVerify={checkPaymentFromPB}
onVerifyByEmail={checkVerifyByEmail}
onVerifySuccess={handleVerifySuccess}
wechatNick={wechatNick}
wechatAvatar={wechatAvatar}
/>
)}
</VipLayout>

View File

@@ -11,8 +11,17 @@ import {
import { queryXorPayOrderStatus } from "@/app/lib/xorpayStatus";
import { queryZPayOrderStatus } from "@/app/lib/zpayStatus";
const getTotalFee = () =>
getPaymentConfig().defaultAmount ?? Number(process.env.PAYMENT_DEFAULT_AMOUNT) ?? 8800;
const getDefaultTotalFee = () =>
getPaymentConfig().defaultAmount ?? Number(process.env.PAYMENT_DEFAULT_AMOUNT) ?? 18800;
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;
@@ -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`,

View File

@@ -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微信内无中转直接提交 | 非 directPC/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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] || c);
const userNick = String(wechatNick || userId).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] || c);
const userAvatar = String(wechatAvatar || "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] || c);
const returnUrl = String(resData.return_url || finalReturnUrl).replace(
/[&<>"']/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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,

View File

@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] || c);
const safeAvatar = (avatar || "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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",

View File

@@ -15,6 +15,8 @@ export type XorPayStatusResult = {
status?: string;
error?: string;
url: string;
/** 网关返回的实付金额(元),有则用于换算分;无则由调用方回退默认金额 */
pay_price?: string | number | null;
};
function buildXorPaySign(orderId: string): string {
@@ -55,6 +57,7 @@ export async function queryXorPayOrderStatus(
status,
url: url.toString(),
error: res.ok ? undefined : `status=${res.status}`,
pay_price: data?.pay_price ?? data?.price ?? null,
};
} catch (error) {
return {

View File

@@ -12,6 +12,8 @@ export type ZPayStatusResult = {
status?: string;
error?: string;
url: string;
/** 网关返回的实付金额(元),字段名因接口而异 */
pay_price?: string | number | null;
};
export async function queryZPayOrderStatus(
@@ -38,6 +40,9 @@ export async function queryZPayOrderStatus(
const code = String(data?.code || "").trim();
const status = String(data?.status || "").trim();
const payPrice =
data?.pay_price ?? data?.money ?? data?.price ?? data?.amount ?? null;
return {
ok: res.ok,
paid: res.ok && code === "1" && status === "1",
@@ -47,6 +52,7 @@ export async function queryZPayOrderStatus(
res.ok && code === "1"
? undefined
: String(data?.msg || `status=${res.status}`),
pay_price: payPrice,
};
} catch (error) {
return {

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,9 @@
min-height: 100vh;
background: var(--background);
color: var(--foreground);
/* 防止 landingScene 负边距/扩展背景在微信 WebView 产生横向露白 */
overflow-x: hidden;
overflow-x: clip;
}
.header {

View File

@@ -107,7 +107,7 @@ export function getPaymentConfig(): PaymentConfig {
zpaySubmitUrl:
process.env.ZPAY_SUBMIT_URL || "https://zpayz.cn/submit.php",
/** 支付价格 188 元=18800 分 */
defaultAmount: Number(process.env.PAYMENT_DEFAULT_AMOUNT) || 18800,
defaultAmount: Number(process.env.PAYMENT_DEFAULT_AMOUNT) || 28800,
nomadvipPayPath: "/nomadvip/payh5",
};
}